Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/php-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ jobs:
- name: Run tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --testsuite default

test-guzzle-7:
name: Tests and PHPStan with Guzzle 7 (PHP 8.3)
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, json, zlib
coverage: none

- name: Install dependencies (newest Guzzle 7)
run: composer update --prefer-dist --no-progress --with-all-dependencies --with "guzzlehttp/guzzle:^7.9"

- name: Run tests
run: vendor/bin/phpunit --testsuite default

- name: Run PHPStan
run: vendor/bin/phpstan analyse src

test-lowest-dependencies:
name: Test with lowest dependencies (PHP 8.3)
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
"require": {
"php": ">=8.3",
"psr/http-message": "^1.1 || ^2.0",
"guzzlehttp/guzzle": "^7.9",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.7",
"guzzlehttp/guzzle": "^7.9 || ^8.0",
"guzzlehttp/promises": "^2.0 || ^3.0",
"guzzlehttp/psr7": "^2.7 || ^3.0",
"ext-zlib": "*",
"swaggest/json-schema": "^0.12.43",
"ramsey/uuid": "^4.8"
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ parameters:
- src
ignoreErrors:
-
# Guzzle Promises 2 (Guzzle 7) has no generic PromiseInterface; with Promises 3 (Guzzle 8) it does
identifier: generics.notGeneric
reportUnmatched: false
13 changes: 10 additions & 3 deletions src/Http/HttpClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
use GuzzleHttp\Middleware;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\RetryMiddleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Soukicz\Llm\Cache\CacheInterface;

class HttpClientFactory {
private const MAX_RETRIES = 3;
/** Guzzle 8 only; a string so the class may be missing on Guzzle 7 */
private const NETWORK_TIMEOUT_EXCEPTION = 'GuzzleHttp\\Exception\\NetworkTimeoutException';

public static function createClient(?callable $customMiddleware = null, ?CacheInterface $cache = null, array $headers = []): Client {
$options = [
Expand Down Expand Up @@ -71,7 +72,12 @@ private static function addRetryMiddleware(HandlerStack $handler): void {
if ($retries >= self::MAX_RETRIES) {
return false;
}
if ($exception instanceof ConnectException) {
// Retry only failures without a response that cannot have been processed or that Guzzle 7
// already retried: a failed connection (DNS, TCP, TLS - nothing was sent) and a timeout before
// the response. Guzzle 8 reports that timeout as NetworkTimeoutException, which is not a
// ConnectException. Send/receive errors after the request went out stay unretried, as on Guzzle 7:
// the provider may already have run (and billed) the request
if ($exception instanceof ConnectException || is_a($exception, self::NETWORK_TIMEOUT_EXCEPTION)) {
return true;
}

Expand All @@ -82,7 +88,8 @@ private static function addRetryMiddleware(HandlerStack $handler): void {

$delay = static function (int $retries, ?ResponseInterface $response = null): int {
if ($response === null || !$response->hasHeader('Retry-After')) {
return RetryMiddleware::exponentialDelay($retries);
// 1 s, 2 s, 4 s; RetryMiddleware::exponentialDelay() is deprecated in Guzzle 7.11 and removed in 8
return (int) 2 ** ($retries - 1) * 1000;
}

$retryAfter = $response->getHeaderLine('Retry-After');
Expand Down
81 changes: 81 additions & 0 deletions tests/Http/HttpClientFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\NetworkException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\NetworkTimeoutException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Soukicz\Llm\Cache\CacheInterface;
use Soukicz\Llm\Http\HttpClientFactory;
use Soukicz\Llm\Tests\Cache\InMemoryCache;
Expand Down Expand Up @@ -131,6 +135,83 @@ public function testRetriesConnectExceptions(): void {
$this->assertSame('ok', (string) $response->getBody());
}

/**
* A timeout before any response arrives must be retried like a refused connection.
* Guzzle 7 reports it as ConnectException, Guzzle 8 as NetworkTimeoutException
* (a NetworkException that is not a ConnectException)
*/
public function testRetriesNetworkTimeoutsWithoutResponse(): void {
$client = $this->createClient();
$this->recordDelays($client, $delays);
$request = new Request('GET', 'https://example.com/api');
$timeout = class_exists(NetworkTimeoutException::class)
? new NetworkTimeoutException('cURL error 28: Operation timed out', $request)
: new ConnectException('cURL error 28: Operation timed out', $request);
$this->mockHandler->append($timeout, new Response(200, [], 'ok'));

$response = $client->get('https://example.com/api');

$this->assertSame('ok', (string) $response->getBody());
$this->assertSame(0, $this->mockHandler->count());
}

/**
* A receive error after the request was sent is not retried: the provider may already have run
* (and billed) it. Guzzle 7 reports it as RequestException, Guzzle 8 as NetworkException
*/
public function testDoesNotRetryReceiveErrorsAfterTheRequestWasSent(): void {
$client = $this->createClient();
$this->recordDelays($client, $delays);
$request = new Request('POST', 'https://example.com/api');
$recvError = class_exists(NetworkException::class)
? new NetworkException('cURL error 56: Recv failure: Connection reset by peer', $request)
: new RequestException('cURL error 56: Recv failure: Connection reset by peer', $request);
$this->mockHandler->append($recvError, new Response(200));

try {
$client->post('https://example.com/api');
$this->fail('Expected the receive error');
} catch (RequestException|NetworkException $e) {
$this->assertSame($recvError, $e);
}
$this->assertSame(1, $this->mockHandler->count(), 'The request must not be sent again');
}

public function testRetryDelayDoublesWithoutRetryAfterHeader(): void {
$client = $this->createClient();
$this->recordDelays($client, $delays);
$this->mockHandler->append(
new Response(500),
new Response(502),
new Response(503),
new Response(200),
);

$client->get('https://example.com/api');

$this->assertSame([null, 1000, 2000, 4000], $delays);
}

/**
* Records the delay the retry middleware asks for on each attempt and drops it, so the
* MockHandler does not sleep. Pushed last, so it runs between the retry middleware and the transport
*
* @param list<int|null>|null $delays
*/
private function recordDelays(Client $client, ?array &$delays): void {
$delays = [];
/** @var HandlerStack $stack */
$stack = $client->getConfig('handler');
$stack->push(static function (callable $handler) use (&$delays): callable {
return static function (RequestInterface $request, array $options) use ($handler, &$delays) {
$delays[] = $options['delay'] ?? null;
unset($options['delay']);

return $handler($request, $options);
};
});
}

public function testSuccessfulResponsesAreCachedAndReplayed(): void {
$cache = new InMemoryCache();
$client = $this->createClient($cache);
Expand Down
Loading