Files
alfa-bank-sdk/src/Core/AlfaBankConnector.php
Anatoliy Poloz 29645b7a5f feat(retries): never repeat non-idempotent or verdict requests
Saloon retries whenever an exception is thrown (handleRetry defaults to true)
and both connectors set tries = 3, so POST /payoffs, PATCH /payoffs/{id}/accept
and SBP QrcReversalRequest were sent to the bank up to three times while the
caller believed it had made a single request. For payouts a repeated POST is a
duplicate-payment risk, not a harmless retry.

HasSafeRetries replaces the HasTries default at the connector level (Saloon
retries only when the per-send callable, the request and the connector all
allow it):
- a deterministic bank verdict (4xx/5xx) is never retried;
- an explicit deny-list covers money operations;
- an explicit allow-list keeps retries for read-only SBP operations, since that
  API uses POST even for reads (GetQrcReversalDataRequest stays denied: the bank
  matches the following reversal against it, EBD 44 / I14044);
- by default only GET/HEAD/OPTIONS are retried.

Every decision is logged through the connector's PSR-3 logger as
'[alfa-sdk][retry]', so physical request count per operation is countable via
X-Request-ID.

Tests: tests/Feature/RetryPolicyTest.php (23 tests), including an assertion on
the real retry loop that a failing non-idempotent POST is sent exactly once,
while a read-only GET still uses all configured attempts.
2026-09-29 10:44:18 +03:00

195 lines
7.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Core;
// <SECTION name="module_imports" intent="Import necessary contracts, services, and base framework components for the connector's operation.">
// <BELIEF_STATE "Saloon's plugin architecture requires importing traits for features like retries and timeouts. Local contracts (SignerInterface) are imported for dependency injection.">
use Apoloz\AlfaBank\Contracts\SignerInterface;
use Apoloz\AlfaBank\Enums\Environment;
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware;
use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware;
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator;
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreatePayoffRequest;
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreateTaxRefundRequest;
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\PayoffAcceptRequest;
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\TaxRefundAcceptRequest;
use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling;
use Apoloz\AlfaBank\Traits\HasSafeRetries;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\Connector;
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
use Saloon\Http\PendingRequest;
use Saloon\Http\Request;
use Saloon\PaginationPlugin\Contracts\HasPagination;
use Saloon\PaginationPlugin\OffsetPaginator;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
use Saloon\Traits\Plugins\HasTimeout;
class AlfaBankConnector extends Connector implements HasPagination
{
use AlwaysThrowOnErrors;
use AuthorizationCodeGrant;
use HasAlfaErrorHandling;
use HasTimeout;
// Replaces the HasTries default handleRetry (always retry) with a safe policy:
// transport failures are retried for read-only requests only, bank verdicts never.
use HasSafeRetries;
public function __construct(
protected AlfaConfig $alfaConfig,
protected ?SignerInterface $signer = null,
protected bool $loggingEnabled = true,
protected ?LoggerInterface $logger = null,
) {
$this->tries = 3;
$this->retryInterval = 500;
$this->logger = $this->logger ?? new NullLogger;
// Money operations: repeating them is a duplicate-payment risk, so they are
// denied explicitly in addition to the read-only-method rule.
$this->denyRetriesFor([
CreatePayoffRequest::class,
PayoffAcceptRequest::class,
CreateTaxRefundRequest::class,
TaxRefundAcceptRequest::class,
]);
}
public function resolveBaseUrl(): string
{
return $this->alfaConfig->baseUrl;
}
public function boot(PendingRequest $pendingRequest): void
{
$pendingRequest->middleware()->onRequest(new RequestIdMiddleware);
if ($this->loggingEnabled) {
$pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger));
$pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger));
}
}
public function paginate(Request $request): OffsetPaginator
{
return new AlfaOffsetPaginator($this, $request);
}
// login() kept for ClientCredentials compatibility (legacy/simple flow)
public function login(?string $clientId = null, ?string $clientSecret = null, array $scopes = []): static
{
$oauthConfig = $this->oauthConfig();
// Allow overriding credentials via method arguments
if ($clientId) {
$oauthConfig->setClientId($clientId);
}
if ($clientSecret) {
$oauthConfig->setClientSecret($clientSecret);
}
if (empty($oauthConfig->getClientId()) || empty($oauthConfig->getClientSecret())) {
throw new \RuntimeException('Client ID/Secret not configured.');
}
$request = new GetClientCredentialsTokenRequest($oauthConfig, $scopes);
$response = $this->send($request);
// Use the trait's helper to parse the token
$authenticator = $this->createOAuthAuthenticatorFromResponse($response);
$this->authenticate($authenticator);
return $this;
}
// </SECTION>
// </SECTION>
// <SECTION name="signer_accessor" intent="Provide access to the configured signing service for requests that require a digital signature.">
// <BELIEF_STATE "Requests should not instantiate their own signing logic. The connector acts as a service locator, providing the globally-configured Signer service. This ensures that all signatures are generated consistently.">
public function getSigner(): SignerInterface
{
if (! $this->signer) {
throw new \RuntimeException('No signer configured for this connector.');
}
return $this->signer;
}
// </SECTION>
// <SECTION name="logging_mutator" intent="Attach a PSR-3 logger to the connector for debugging.">
// <BELIEF_STATE "Logging is enabled by default with a NullLogger (silent). Pass a real logger to start seeing output, or disable entirely via withoutLogging().">
public function withLogging(LoggerInterface $logger): static
{
$this->logger = $logger;
$this->loggingEnabled = true;
return $this;
}
public function withoutLogging(): static
{
$this->loggingEnabled = false;
return $this;
}
/**
* Enable automated JWS response verification and decoding.
*/
public function withJwsVerification(string $publicKeyPem): static
{
$this->middleware()->onResponse(new VerifyJwsResponseMiddleware($publicKeyPem));
return $this;
}
protected function defaultOauthConfig(): OAuthConfig
{
$idUrl = $this->alfaConfig->baseUrl === Environment::PRODUCTION->value
? Environment::ID_PRODUCTION->value
: Environment::ID_SANDBOX->value;
return OAuthConfig::make()
->setClientId($this->alfaConfig->clientId ?? '')
->setClientSecret($this->alfaConfig->clientSecret ?? '')
->setRedirectUri($this->alfaConfig->redirectUri ?? '')
->setAuthorizeEndpoint($idUrl.'/oidc/authorize')
->setTokenEndpoint('/oidc/token')
->setAllowBaseUrlOverride();
}
protected function defaultConfig(): array
{
$config = [
'cert' => $this->alfaConfig->certificatePath,
'ssl_key' => $this->alfaConfig->privateKeyPath,
'verify' => true,
];
if ($this->alfaConfig->caBundlePath) {
$config['verify'] = $this->alfaConfig->caBundlePath === 'false' ? false : $this->alfaConfig->caBundlePath;
}
return $config;
}
protected function defaultHeaders(): array
{
return [
'Accept' => 'application/json',
// Content-Type не устанавливаем здесь, каждый Request сам определяет его через трейты (HasJsonBody, HasFormBody и т.д.)
// OAuth токен запросы требуют application/x-www-form-urlencoded
];
}
// </SECTION>
}