php-agentic/tests/Unit/Concerns/HasRetryTest.php

389 lines
12 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
/**
* Tests for the HasRetry trait.
*
* Exercises retry logic, exponential backoff, and error classification
* in isolation from any real HTTP provider.
*/
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use GuzzleHttp\Psr7\Response as PsrResponse;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/**
* Build a testable object that uses the HasRetry trait.
*
* sleep() is overridden so tests run without actual delays.
* The recorded sleep durations are accessible via ->sleepCalls.
*/
function retryService(int $maxRetries = 3, int $baseDelayMs = 1000, int $maxDelayMs = 30000): object
{
return new class($maxRetries, $baseDelayMs, $maxDelayMs) {
use HasRetry;
public array $sleepCalls = [];
public function __construct(int $maxRetries, int $baseDelayMs, int $maxDelayMs)
{
$this->maxRetries = $maxRetries;
$this->baseDelayMs = $baseDelayMs;
$this->maxDelayMs = $maxDelayMs;
}
public function runWithRetry(callable $callback, string $provider): Response
{
return $this->withRetry($callback, $provider);
}
public function computeDelay(int $attempt, ?Response $response = null): int
{
return $this->calculateDelay($attempt, $response);
}
protected function sleep(int $milliseconds): void
{
$this->sleepCalls[] = $milliseconds;
}
};
}
/**
* Build an Illuminate Response wrapping a real PSR-7 response.
*
* @param array<string,string> $headers
*/
function fakeHttpResponse(int $status, array $body = [], array $headers = []): Response
{
return new Response(new PsrResponse($status, $headers, json_encode($body)));
}
// ---------------------------------------------------------------------------
// withRetry success paths
// ---------------------------------------------------------------------------
describe('withRetry success', function () {
it('returns response immediately on first-attempt success', function () {
$service = retryService();
$response = fakeHttpResponse(200, ['ok' => true]);
$result = $service->runWithRetry(fn () => $response, 'TestProvider');
expect($result->successful())->toBeTrue();
expect($service->sleepCalls)->toBeEmpty();
});
it('returns response after one transient 429 failure', function () {
$service = retryService();
$calls = 0;
$result = $service->runWithRetry(function () use (&$calls) {
$calls++;
return $calls === 1
? fakeHttpResponse(429)
: fakeHttpResponse(200, ['ok' => true]);
}, 'TestProvider');
expect($result->successful())->toBeTrue();
expect($calls)->toBe(2);
});
it('returns response after one transient 500 failure', function () {
$service = retryService();
$calls = 0;
$result = $service->runWithRetry(function () use (&$calls) {
$calls++;
return $calls === 1
? fakeHttpResponse(500)
: fakeHttpResponse(200, ['ok' => true]);
}, 'TestProvider');
expect($result->successful())->toBeTrue();
expect($calls)->toBe(2);
});
it('returns response after one ConnectionException', function () {
$service = retryService();
$calls = 0;
$result = $service->runWithRetry(function () use (&$calls) {
$calls++;
if ($calls === 1) {
throw new ConnectionException('Network error');
}
return fakeHttpResponse(200, ['ok' => true]);
}, 'TestProvider');
expect($result->successful())->toBeTrue();
expect($calls)->toBe(2);
});
it('returns response after one RequestException', function () {
$service = retryService();
$calls = 0;
$result = $service->runWithRetry(function () use (&$calls) {
$calls++;
if ($calls === 1) {
throw new RequestException(fakeHttpResponse(503));
}
return fakeHttpResponse(200, ['ok' => true]);
}, 'TestProvider');
expect($result->successful())->toBeTrue();
expect($calls)->toBe(2);
});
});
// ---------------------------------------------------------------------------
// withRetry max retry limits
// ---------------------------------------------------------------------------
describe('withRetry max retry limits', function () {
it('throws after exhausting all retries on persistent 429', function () {
$service = retryService(maxRetries: 3);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(429);
}, 'TestProvider');
})->toThrow(RuntimeException::class);
expect($calls)->toBe(3);
});
it('throws after exhausting all retries on persistent 500', function () {
$service = retryService(maxRetries: 3);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(500);
}, 'TestProvider');
})->toThrow(RuntimeException::class);
expect($calls)->toBe(3);
});
it('throws after exhausting all retries on persistent ConnectionException', function () {
$service = retryService(maxRetries: 2);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
throw new ConnectionException('Timeout');
}, 'TestProvider');
})->toThrow(RuntimeException::class, 'connection error');
expect($calls)->toBe(2);
});
it('respects a custom maxRetries of 1 (no retries)', function () {
$service = retryService(maxRetries: 1);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(500);
}, 'TestProvider');
})->toThrow(RuntimeException::class);
expect($calls)->toBe(1);
});
it('error message includes provider name', function () {
$service = retryService(maxRetries: 1);
expect(fn () => $service->runWithRetry(fn () => fakeHttpResponse(500), 'MyProvider'))
->toThrow(RuntimeException::class, 'MyProvider');
});
});
// ---------------------------------------------------------------------------
// withRetry non-retryable errors
// ---------------------------------------------------------------------------
describe('withRetry non-retryable client errors', function () {
it('throws immediately on 401 without retrying', function () {
$service = retryService(maxRetries: 3);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(401, ['error' => ['message' => 'Unauthorised']]);
}, 'TestProvider');
})->toThrow(RuntimeException::class, 'TestProvider API error');
expect($calls)->toBe(1);
});
it('throws immediately on 400 without retrying', function () {
$service = retryService(maxRetries: 3);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(400);
}, 'TestProvider');
})->toThrow(RuntimeException::class);
expect($calls)->toBe(1);
});
it('throws immediately on 404 without retrying', function () {
$service = retryService(maxRetries: 3);
$calls = 0;
expect(function () use ($service, &$calls) {
$service->runWithRetry(function () use (&$calls) {
$calls++;
return fakeHttpResponse(404);
}, 'TestProvider');
})->toThrow(RuntimeException::class);
expect($calls)->toBe(1);
});
});
// ---------------------------------------------------------------------------
// withRetry sleep (backoff) behaviour
// ---------------------------------------------------------------------------
describe('withRetry exponential backoff', function () {
it('sleeps between retries but not after the final attempt', function () {
$service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000);
try {
$service->runWithRetry(fn () => fakeHttpResponse(500), 'TestProvider');
} catch (RuntimeException) {
// expected
}
// 3 attempts → 2 sleeps (between attempt 1-2 and 2-3)
expect($service->sleepCalls)->toHaveCount(2);
});
it('does not sleep when succeeding on first attempt', function () {
$service = retryService();
$service->runWithRetry(fn () => fakeHttpResponse(200), 'TestProvider');
expect($service->sleepCalls)->toBeEmpty();
});
it('sleeps once when succeeding on the second attempt', function () {
$service = retryService(maxRetries: 3, baseDelayMs: 100, maxDelayMs: 10000);
$calls = 0;
$service->runWithRetry(function () use (&$calls) {
$calls++;
return $calls === 1 ? fakeHttpResponse(500) : fakeHttpResponse(200);
}, 'TestProvider');
expect($service->sleepCalls)->toHaveCount(1);
expect($service->sleepCalls[0])->toBeGreaterThanOrEqual(100);
});
});
// ---------------------------------------------------------------------------
// calculateDelay exponential backoff formula
// ---------------------------------------------------------------------------
describe('calculateDelay', function () {
it('returns base delay for attempt 1', function () {
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
// delay = 1000 * 2^0 = 1000ms, plus up to 25% jitter
$delay = $service->computeDelay(1);
expect($delay)->toBeGreaterThanOrEqual(1000)
->and($delay)->toBeLessThanOrEqual(1250);
});
it('doubles the delay for attempt 2', function () {
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
// delay = 1000 * 2^1 = 2000ms, plus up to 25% jitter
$delay = $service->computeDelay(2);
expect($delay)->toBeGreaterThanOrEqual(2000)
->and($delay)->toBeLessThanOrEqual(2500);
});
it('quadruples the delay for attempt 3', function () {
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
// delay = 1000 * 2^2 = 4000ms, plus up to 25% jitter
$delay = $service->computeDelay(3);
expect($delay)->toBeGreaterThanOrEqual(4000)
->and($delay)->toBeLessThanOrEqual(5000);
});
it('caps the delay at maxDelayMs', function () {
$service = retryService(baseDelayMs: 10000, maxDelayMs: 5000);
// 10000 * 2^0 = 10000ms → capped at 5000ms
$delay = $service->computeDelay(1);
expect($delay)->toBe(5000);
});
it('respects numeric Retry-After header (in seconds)', function () {
$service = retryService(maxDelayMs: 60000);
$response = fakeHttpResponse(429, [], ['Retry-After' => '10']);
// Retry-After is 10 seconds = 10000ms
$delay = $service->computeDelay(1, $response);
expect($delay)->toBe(10000);
});
it('caps Retry-After header value at maxDelayMs', function () {
$service = retryService(maxDelayMs: 5000);
$response = fakeHttpResponse(429, [], ['Retry-After' => '60']);
// 60 seconds = 60000ms → capped at 5000ms
$delay = $service->computeDelay(1, $response);
expect($delay)->toBe(5000);
});
it('falls back to exponential backoff when no Retry-After header', function () {
$service = retryService(baseDelayMs: 1000, maxDelayMs: 30000);
$response = fakeHttpResponse(500);
$delay = $service->computeDelay(1, $response);
expect($delay)->toBeGreaterThanOrEqual(1000)
->and($delay)->toBeLessThanOrEqual(1250);
});
});