Skip to content

Support Guzzle 8 alongside Guzzle 7 - #13

Merged
soukicz merged 2 commits into
soukicz:masterfrom
bobsoukup:guzzle-8
Oct 25, 2026
Merged

soukicz merged 2 commits into
soukicz:masterfrom
bobsoukup:guzzle-8

Conversation

@bobsoukup

Copy link
Copy Markdown

Applications on Guzzle 8 can't install soukicz/llm today, because it pins guzzlehttp/guzzle ^7.9, guzzlehttp/promises ^2.0 and guzzlehttp/psr7 ^2.7. That blocks Guzzle 8 in microservice/invoice-extractor, for example. This PR allows Guzzle 8 (with Promises 3 and PSR-7 3) and keeps Guzzle 7.9+ working.

Widening the constraints alone is not enough. On Guzzle 8 the unchanged code breaks in two places:

  1. Every retry without a Retry-After header crashes. RetryMiddleware::exponentialDelay() was removed in 8 (it has been deprecated since 7.11), so the call fails with Error: Call to undefined method.
  2. Timeouts are no longer retried. A timeout before the response headers is now NetworkTimeoutException, which is no longer a ConnectException, so the retry decider gave up after one attempt. A real cURL run against a slow local server showed it: 1 attempt on Guzzle 8, against 4 on Guzzle 7.

Changes

  • test: pin retry delays and which network failures are retried: tests written on master first; they pass there on Guzzle 7.
    • A timeout without a response is retried.
    • A receive error after the request was sent is not retried: Guzzle 7 reports it as RequestException, Guzzle 8 as NetworkException.
    • Without Retry-After, the delays are 1 s, 2 s, 4 s. A middleware pushed below the retry middleware records delay and drops it, so the tests don't sleep.
  • feat: support Guzzle 8 alongside Guzzle 7:
    • composer.json: guzzle ^7.9 || ^8.0, promises ^2.0 || ^3.0, psr7 ^2.7 || ^3.0.
    • HttpClientFactory:
      • the retry delay is computed locally, with the same formula as before;
      • the decider retries ConnectException, plus Guzzle 8's NetworkTimeoutException, referenced by class name so the code still loads and passes PHPStan on Guzzle 7.
    • phpstan.neon: the generics.notGeneric ignore matches only with Promises 2, so it is marked reportUnmatched: false.
    • CI: a new job runs the tests and PHPStan on the newest Guzzle 7, because a plain install now resolves to Guzzle 8. The prefer-lowest job still covers the oldest Guzzle 7.

Retry policy: kept as on Guzzle 7, on purpose

Guzzle 8 reclassifies more no-response failures as NetworkException, such as send and receive errors after the request went out. Catching Psr\Http\Client\NetworkExceptionInterface would have retried those POSTs, and the provider may already have run, and billed, them. So the decider keeps Guzzle 7's set: failed connections, where nothing was sent, and timeouts before a response.

The one narrowing: an empty reply (cURL 52) was a ConnectException in Guzzle 7, so it was retried, but it is a plain NetworkException in Guzzle 8, so it no longer is. Making retries configurable per request is a possible follow-up; it is not part of this PR.

Verification

Unit tests and PHPStan (analyse src, level 5) were run in containers. No composer.lock was used, as in CI.

Dependencies PHP Tests PHPStan
master, Guzzle 7.16.0 / Promises 2.6.0 / PSR-7 2.13.2 (baseline) 8.3 431 OK OK
branch, Guzzle 8.3.0 / Promises 3.1.0 / PSR-7 3.2.0 8.3, 8.4, 8.5 434 OK OK
branch, Guzzle 7.16.0 (--with guzzlehttp/guzzle:^7.9) 8.3 434 OK OK
branch, --prefer-lowest (Guzzle 7.15.2, Promises 2.5.1, PSR-7 2.13.0) 8.3 434 OK OK
master code + the new tests, Guzzle 8.3.0 8.3 3 errors (the two retry regressions) 2 errors

End-to-end checks used the real cURL handler against PHP's built-in server; no external API was called.

  • Timeout: the server sleeps 2 s and the client sets timeout => 0.3.
    • branch on Guzzle 7: ConnectException after 4 attempts, 8.2 s;
    • branch on Guzzle 8: NetworkTimeoutException after 4 attempts, 8.2 s;
    • master on Guzzle 8: 1 attempt, 0.3 s.
  • Streaming: OpenAICompatibleClient against a local SSE endpoint that sends the chunks with 150 ms pauses, then a plain request. Identical on Guzzle 7 and 8:
    • the same stream events, with the Czech text intact;
    • the same stop reason and token counts;
    • the same request headers seen by the server;
    • incremental delivery on both.

Review notes

  • Self-review of every Guzzle, Promises and PSR-7 call site against the three upgrade guides found nothing else to change. Checked:
    • request methods (all uppercase);
    • header values (all strings);
    • Utils::unwrap / Create;
    • Pool;
    • multipart;
    • ClientException::getResponse();
    • the cache rebuilding Response from getHeaders();
    • the SSE parser's eof() / read() loop.
  • A second opinion from OpenAI Codex agreed on those areas. It flagged that catching the PSR-18 interface would broaden POST retries on Guzzle 8; that is fixed as described above.
  • It also noted two things to know, not changed here:
    • Guzzle 8 rejects non-string header values, which affects the $headers argument of HttpClientFactory::createClient();
    • Guzzle 8's stream handler (used only without ext-curl) defaults read_timeout to 60 s.

Test environment

None: this is a library with no *.simplia.net environment. It was verified by the unit-test matrix and the local end-to-end runs above.

Upgrade review

  • Range: guzzlehttp/guzzle 7.16 → 8.3 (8.0.0–8.3.0), guzzlehttp/promises 2.6 → 3.1, guzzlehttp/psr7 2.13 → 3.2. Read: Guzzle UPGRADING.md "7.0 to 8.0" (tag 8.3.0); the promises and psr7 UPGRADING.md "3.0" sections (branch 3.0).

  • Breaking / behaviour changes affecting us:

    • RetryMiddleware::exponentialDelay() removed → local delay function;
    • timeout reclassified as NetworkTimeoutException → retried explicitly;
    • send/receive errors → NetworkException: kept unretried;
    • empty reply no longer a ConnectException: accepted, documented above;
    • Promises 3 generics → PHPStan ignore made optional.
    • Checked, no impact: header value strictness, method casing, getHandlerContext() removal (unused), RequestException::getResponse() removal (we only call it on ClientException), stream and multipart changes.
  • Deprecated APIs we used: RetryMiddleware::exponentialDelay(), deprecated in 7.11 → replaced. Grepped src/ for every Guzzle, Promises and PSR-7 symbol.

  • Workarounds removed: none. Searched for workaround, TODO and Guzzle version comments.

  • Adopted improvements: none. Reviewed Guzzle 8's generic PromiseInterface<T> PHPDoc; adopting it would need PromiseInterface<LLMResponse> annotations across the clients and would only type-check on Promises 3.

  • Deferred improvements:

    • generic promise annotations, once Guzzle 7 support is dropped;
    • configurable retry policy (idempotent vs non-idempotent requests);
    • an explicit read_timeout for streaming.

    These are proposals for the maintainer; no issues are filed.

🤖 Generated with Claude Code

Bob Soukup and others added 2 commits October 25, 2026 15:34
Guzzle 8 removes RetryMiddleware::exponentialDelay() and reclassifies
network failures: a timeout before the response headers is
NetworkTimeoutException, no longer a ConnectException, and send or
receive errors become NetworkException instead of RequestException.
Pin today's behaviour on Guzzle 7 first, so the Guzzle 8 support can
show it keeps it:
- a timeout without a response is retried;
- a receive error after the request was sent is not, because the
  provider may already have run (and billed) it;
- without Retry-After, the delays are 1 s, 2 s, 4 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Applications on Guzzle 8 cannot install this library: it pins
guzzlehttp/guzzle ^7.9, promises ^2.0 and psr7 ^2.7. Allowing the new
majors alone is not enough, because two things break on Guzzle 8:

- RetryMiddleware::exponentialDelay() is gone (deprecated since 7.11),
  so every retry without a Retry-After header died with "Call to
  undefined method". The delay is now computed here, with the same
  formula (1 s, 2 s, 4 s).
- A timeout before the response headers is NetworkTimeoutException,
  no longer a ConnectException, so it was not retried any more. The
  decider now also retries that class. It keeps everything else as on
  Guzzle 7: Guzzle 8's other NetworkExceptions (send and receive
  errors) are not retried, because the request may already have been
  processed. The only narrowing is the empty reply (cURL 52), which
  Guzzle 7 reported as ConnectException and Guzzle 8 does not.

Also:
- PHPStan's generics.notGeneric ignore only matches with Promises 2,
  so it no longer reports when it is unmatched.
- A CI job runs the tests and PHPStan on the newest Guzzle 7, now that
  a plain install picks Guzzle 8. The prefer-lowest job keeps covering
  the oldest Guzzle 7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@soukicz
soukicz merged commit dc2e779 into soukicz:master Oct 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants