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.
195 lines
7.2 KiB
PHP
195 lines
7.2 KiB
PHP
<?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>
|
||
}
|