/** * PSR-3 logger double: the SDK is framework-agnostic, so retry decisions are asserted * on the records the connector sends to its logger (in the app: channel finance_alfabank). */ class RetryRecordingLogger extends AbstractLogger { /** @var list}> */ public array $records = []; public function log($level, string|Stringable $message, array $context = []): void { $this->records[] = [ 'level' => $level, 'message' => (string) $message, 'context' => $context, ]; } } /** * Sender that fails on every attempt with a transport error (timeout/DNS/TLS), * which is exactly the case where Saloon's retry loop asks the connector. * Counts physical attempts so a test can prove how many requests reached the wire. */ class RetryCountingFailingSender extends GuzzleSender { public static int $attempts = 0; public function send(PendingRequest $pendingRequest): Response { self::$attempts++; throw new FatalRequestException(new RuntimeException('Connection timed out'), $pendingRequest); } } /** * Requests that create or move money. Repeating any of them is a duplicate-payment * risk, so this list is enumerated on purpose: if a money operation ever becomes * retryable, this test must fail and force a conscious review. * * @return array}> */ function retryMoneyOperations(): array { return [ 'BaaS: POST /payoffs' => [CreatePayoffRequest::class], 'BaaS: PATCH accept' => [PayoffAcceptRequest::class], 'BaaS: POST tax refund' => [CreateTaxRefundRequest::class], 'BaaS: PATCH tax refund accept' => [TaxRefundAcceptRequest::class], 'SBP: QRCreversal' => [QrcReversalRequest::class], ]; } /** * SBP uses POST even for read-only endpoints. These calls are safe to repeat because * they create nothing at the bank; the list is duplicated here deliberately so that * widening the connector's allow-list requires updating the test too. * * @return list> */ function retrySbpReadOnlyPostOperations(): array { return [ Apoloz\AlfaBank\Sbp\Requests\Status\GetQrcStatusRequest::class, Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalStatusRequest::class, Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalHistoryRequest::class, Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrStatusRequest::class, Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrHistoryRequest::class, Apoloz\AlfaBank\Sbp\Requests\Subscription\GetQrSubStatusRequest::class, Apoloz\AlfaBank\Sbp\Requests\Management\GetBlockedQrListRequest::class, Apoloz\AlfaBank\Sbp\Requests\Registry\GetBankListRequest::class, Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\GetUniQrStatusRequest::class, Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink\GetCashUniQrStatusRequest::class, ]; } /** * All request classes shipped by the SDK with the HTTP method declared in their defaults. * * @return array, Method> */ function retryAllRequestClasses(): array { $sourceRoot = dirname(__DIR__, 2).'/src'; $classes = []; foreach (['Requests', 'Sbp/Requests'] as $directory) { $path = $sourceRoot.'/'.$directory; if (! is_dir($path)) { continue; } $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); foreach ($files as $file) { if ($file->getExtension() !== 'php') { continue; } $relative = substr($file->getPathname(), strlen($sourceRoot) + 1, -4); $class = 'Apoloz\\AlfaBank\\'.str_replace('/', '\\', $relative); if (! class_exists($class)) { continue; } $reflection = new ReflectionClass($class); if ($reflection->isAbstract() || ! $reflection->isSubclassOf(Request::class)) { continue; } $classes[$class] = $reflection->getDefaultProperties()['method'] ?? Method::GET; } } ksort($classes); return $classes; } function retryConnectorFor(string $requestClass): Connector { return str_starts_with($requestClass, 'Apoloz\\AlfaBank\\Sbp\\') ? new SbpConnector(createTestConfig(), 'test-key-alias') : new AlfaBankConnector(createTestConfig()); } function retryRequestInstance(string $requestClass): Request { return (new ReflectionClass($requestClass))->newInstanceWithoutConstructor(); } /** Pending request built from a harmless GET so that no body is signed or merged. */ function retryNeutralPendingRequest(Connector $connector): PendingRequest { $neutral = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/'; } }; return new PendingRequest($connector, $neutral); } function retryTransportFailure(Connector $connector): FatalRequestException { return new FatalRequestException( new RuntimeException('Connection timed out'), retryNeutralPendingRequest($connector), ); } function retryBankVerdict(Connector $connector, int $status = 400): RequestException { $pending = retryNeutralPendingRequest($connector); $response = new Response( new PsrResponse($status, ['Content-Type' => 'application/json', 'X-Request-ID' => 'test-request-id'], '{"error":"bad_request"}'), $pending, new PsrRequest('GET', 'https://test.alfabank.ru/'), ); return new RequestException($response); } // --- money operations are never repeated ------------------------------------- test('money operations are never retried on transport failure', function (string $requestClass): void { $connector = retryConnectorFor($requestClass); $decision = $connector->handleRetry( retryTransportFailure($connector), retryRequestInstance($requestClass), ); expect($decision)->toBeFalse(); })->with(retryMoneyOperations()); test('QRCreversalData is never retried: the bank treats it as the previous message of a reversal', function (): void { $connector = new SbpConnector(createTestConfig(), 'test-key-alias'); $decision = $connector->handleRetry( retryTransportFailure($connector), retryRequestInstance(GetQrcReversalDataRequest::class), ); expect($decision)->toBeFalse(); }); // --- read-only operations keep their resilience ------------------------------ test('read-only GET requests are retried on transport failure', function (): void { $connector = new AlfaBankConnector(createTestConfig()); $getRequest = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/payoffs'; } }; expect($connector->handleRetry(retryTransportFailure($connector), $getRequest))->toBeTrue(); }); test('SBP read-only POST operations are still retried on transport failure', function (string $requestClass): void { $connector = new SbpConnector(createTestConfig(), 'test-key-alias'); $decision = $connector->handleRetry( retryTransportFailure($connector), retryRequestInstance($requestClass), ); expect($decision)->toBeTrue(); })->with(retrySbpReadOnlyPostOperations()); test('every mutating request class in the SDK is non-retryable by default', function (): void { $sbpReadOnlyPost = retrySbpReadOnlyPostOperations(); $mutating = array_filter( retryAllRequestClasses(), static fn (Method $method, string $class): bool => ! in_array($method, [Method::GET, Method::HEAD, Method::OPTIONS], true) && ! in_array($class, $sbpReadOnlyPost, true), ARRAY_FILTER_USE_BOTH, ); expect($mutating)->not->toBeEmpty(); foreach ($mutating as $class => $method) { $connector = retryConnectorFor($class); $decision = $connector->handleRetry( retryTransportFailure($connector), retryRequestInstance($class), ); expect($decision)->toBeFalse("{$method->value} {$class} must not be retried"); } }); // --- deterministic bank verdicts --------------------------------------------- test('bank verdict responses are never retried, even for read-only requests', function (): void { $connector = new AlfaBankConnector(createTestConfig()); $getRequest = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/payoffs'; } }; expect($connector->handleRetry(retryBankVerdict($connector, 400), $getRequest))->toBeFalse() ->and($connector->handleRetry(retryBankVerdict($connector, 500), $getRequest))->toBeFalse(); }); // --- the decision is actually enforced by Saloon's retry loop ---------------- test('a failing non-idempotent POST is sent exactly once', function (): void { $connector = new class(createTestConfig()) extends AlfaBankConnector { protected string $defaultSender = RetryCountingFailingSender::class; }; $connector->retryInterval = 0; $request = new class extends Request { protected Method $method = Method::POST; public function resolveEndpoint(): string { return '/payoffs'; } }; RetryCountingFailingSender::$attempts = 0; try { $connector->send($request); } catch (FatalRequestException) { // expected: the original transport failure is surfaced to the caller } expect(RetryCountingFailingSender::$attempts)->toBe(1); }); test('a failing read-only GET still uses all configured attempts', function (): void { $connector = new class(createTestConfig()) extends AlfaBankConnector { protected string $defaultSender = RetryCountingFailingSender::class; }; $connector->retryInterval = 0; $request = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/payoffs'; } }; RetryCountingFailingSender::$attempts = 0; try { $connector->send($request); } catch (FatalRequestException) { // expected: retries exhausted, original failure surfaced } expect(RetryCountingFailingSender::$attempts)->toBe(3); }); // --- observability ----------------------------------------------------------- test('the retry decision is logged with the request, method and verdict', function (): void { $logger = new RetryRecordingLogger; $connector = new AlfaBankConnector(createTestConfig()); $connector->withLogging($logger); $getRequest = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/payoffs'; } }; $connector->handleRetry(retryTransportFailure($connector), $getRequest); $connector->handleRetry(retryBankVerdict($connector, 400), $getRequest); // The connector's logging middleware shares this logger, so keep only retry records. $records = array_values(array_filter( $logger->records, static fn (array $record): bool => $record['message'] === '[alfa-sdk][retry]', )); expect($records)->toHaveCount(2) ->and($records[0]['context']['decision'])->toBe('retry') ->and($records[0]['context']['http_method'])->toBe('GET') ->and($records[1]['context']['decision'])->toBe('stop') ->and($records[1]['context']['http_status'])->toBe(400) ->and($records[1]['context']['request_id'])->toBe('test-request-id'); }); // --- explicit policy overrides ------------------------------------------------ test('the deny-list overrides the read-only default', function (): void { $readOnly = new class extends Request { protected Method $method = Method::GET; public function resolveEndpoint(): string { return '/'; } }; $connector = new AlfaBankConnector(createTestConfig()); expect($connector->handleRetry(retryTransportFailure($connector), $readOnly))->toBeTrue(); $connector->denyRetriesFor([$readOnly::class]); expect($connector->handleRetry(retryTransportFailure($connector), $readOnly))->toBeFalse(); }); //