diff --git a/src/Core/DefaultContainer.php b/src/Core/DefaultContainer.php index ef4bb7a..00e88a2 100644 --- a/src/Core/DefaultContainer.php +++ b/src/Core/DefaultContainer.php @@ -72,7 +72,7 @@ private function registerDefaultBindings(): void EventDispatcher::class, ); $this->container->add(ClockInterface::class, SystemClock::class); - $this->container->add( + $this->container->addShared( RequestSchedulerInterface::class, /** @phpstan-ignore return.type */ fn (): RequestSchedulerInterface => $this->container->get(ArrayRequestScheduler::class), diff --git a/src/Downloader/Middleware/RetryMiddleware.php b/src/Downloader/Middleware/RetryMiddleware.php new file mode 100644 index 0000000..db96a4f --- /dev/null +++ b/src/Downloader/Middleware/RetryMiddleware.php @@ -0,0 +1,111 @@ +getRequest(); + + /** @var int $retryCount */ + $retryCount = $request->getMeta('retry_count', 0); + + /** @var list $retryOnStatus */ + $retryOnStatus = $this->option('retryOnStatus'); + + /** @var int $maxRetries */ + $maxRetries = $this->option('maxRetries'); + + if (!\in_array($response->getStatus(), $retryOnStatus, true) || $retryCount >= $maxRetries) { + return $response; + } + + $delay = $this->getDelay($retryCount); + + $this->logger->info( + 'Retrying request', + [ + 'uri' => $request->getUri(), + 'status' => $response->getStatus(), + 'retry_count' => $retryCount + 1, + 'delay_ms' => $delay, + ], + ); + + $retryRequest = $request + ->withMeta('retry_count', $retryCount + 1) + ->addOption('delay', $delay); + + $this->scheduler->schedule($retryRequest); + + return $response->drop('Request being retried'); + } + + private function getDelay(int $retryCount): int + { + /** @var int|list $backoff */ + $backoff = $this->option('backoff'); + + if (\is_int($backoff)) { + return $backoff * 1000; + } + + if (!\is_array($backoff)) { + throw new InvalidArgumentException( + 'backoff must be an integer or array, ' . \gettype($backoff) . ' given.', + ); + } + + if ([] === $backoff) { + throw new InvalidArgumentException('backoff array cannot be empty.'); + } + + $nonIntegerValues = \array_filter($backoff, static fn ($value) => !\is_int($value)); + + if ([] !== $nonIntegerValues) { + throw new InvalidArgumentException( + 'backoff array must contain only integers. Found: ' . + \implode(', ', \array_map('gettype', $nonIntegerValues)), + ); + } + + $delay = $backoff[$retryCount] ?? $backoff[\array_key_last($backoff)]; + + return $delay * 1000; + } + + private static function defaultOptions(): array + { + return [ + 'retryOnStatus' => [500, 502, 503, 504], + 'maxRetries' => 3, + 'backoff' => [1, 5, 10], + ]; + } +} diff --git a/tests/Downloader/Middleware/RetryMiddlewareTest.php b/tests/Downloader/Middleware/RetryMiddlewareTest.php new file mode 100644 index 0000000..c2b1b68 --- /dev/null +++ b/tests/Downloader/Middleware/RetryMiddlewareTest.php @@ -0,0 +1,159 @@ +scheduler = new ArrayRequestScheduler($this->createMock(ClockInterface::class)); + $this->logger = new FakeLogger(); + $this->middleware = new RetryMiddleware($this->scheduler, $this->logger); + } + + public function testDoesNotRetrySuccessfulResponse(): void + { + $response = $this->makeResponse(status: 200); + $this->middleware->configure([]); + + $result = $this->middleware->handleResponse($response); + + self::assertSame($response, $result); + self::assertFalse($result->wasDropped()); + self::assertCount(0, $this->scheduler->forceNextRequests(10)); + } + + public function testDoesNotRetryNonRetryableErrorResponse(): void + { + $response = $this->makeResponse(status: 404); + $this->middleware->configure(['retryOnStatus' => [500]]); + + $result = $this->middleware->handleResponse($response); + + self::assertSame($response, $result); + self::assertFalse($result->wasDropped()); + self::assertCount(0, $this->scheduler->forceNextRequests(10)); + } + + public function testRetriesARetryableResponse(): void + { + $request = $this->makeRequest('https://example.com'); + $response = $this->makeResponse(request: $request, status: 503); + $this->middleware->configure([ + 'retryOnStatus' => [503], + 'maxRetries' => 2, + 'backoff' => [1, 2, 3], + ]); + + $result = $this->middleware->handleResponse($response); + + self::assertTrue($result->wasDropped()); + + $retriedRequests = $this->scheduler->forceNextRequests(10); + self::assertCount(1, $retriedRequests); + + $retriedRequest = $retriedRequests[0]; + self::assertSame(1, $retriedRequest->getMeta('retry_count')); + self::assertSame('https://example.com', $retriedRequest->getUri()); + self::assertSame(1000, $retriedRequest->getOptions()['delay']); + } + + public function testStopsRetryingAfterMaxRetries(): void + { + $request = $this->makeRequest()->withMeta('retry_count', 3); + $response = $this->makeResponse(request: $request, status: 500); + $this->middleware->configure(['maxRetries' => 3, 'backoff' => [1, 2, 3]]); + + $result = $this->middleware->handleResponse($response); + + self::assertSame($response, $result); + self::assertFalse($result->wasDropped()); + self::assertCount(0, $this->scheduler->forceNextRequests(10)); + } + + public function testUsesBackoffArrayForDelay(): void + { + $request = $this->makeRequest()->withMeta('retry_count', 2); + $response = $this->makeResponse(request: $request, status: 500); + $this->middleware->configure(['backoff' => [1, 5, 10]]); + + $this->middleware->handleResponse($response); + + $retriedRequest = $this->scheduler->forceNextRequests(10)[0]; + self::assertSame(10000, $retriedRequest->getOptions()['delay']); + } + + public function testUsesLastBackoffValueIfRetriesExceedBackoffCount(): void + { + $request = $this->makeRequest()->withMeta('retry_count', 5); + $response = $this->makeResponse(request: $request, status: 500); + $this->middleware->configure(['backoff' => [1, 5, 10], 'maxRetries' => 6]); + + $this->middleware->handleResponse($response); + + $retriedRequest = $this->scheduler->forceNextRequests(10)[0]; + self::assertSame(10000, $retriedRequest->getOptions()['delay']); + } + + public function testUsesIntegerBackoffForDelay(): void + { + $request = $this->makeRequest()->withMeta('retry_count', 2); + $response = $this->makeResponse(request: $request, status: 500); + $this->middleware->configure(['backoff' => 5]); + + $this->middleware->handleResponse($response); + + $retriedRequest = $this->scheduler->forceNextRequests(10)[0]; + self::assertSame(5000, $retriedRequest->getOptions()['delay']); + } + + public static function invalidBackoffProvider(): array + { + return [ + 'empty array' => [[], 'backoff array cannot be empty.'], + 'array with non-int' => [[1, 'a', 3], 'backoff array must contain only integers. Found: string'], + 'string' => ['not-an-array', 'backoff must be an integer or array, string given.'], + 'float' => [1.23, 'backoff must be an integer or array, double given.'], + ]; + } + + #[DataProvider('invalidBackoffProvider')] + public function testThrowsExceptionOnInvalidBackoff(mixed $backoff, string $expectedMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $response = $this->makeResponse(status: 500); + $this->middleware->configure(['backoff' => $backoff]); + + $this->middleware->handleResponse($response); + } +}