Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

[HttpClient] Fix GuzzleHttpHandler consuming responses out of band - #65632

#65632
Merged
nicolas-grekas merged 1 commit into
symfony:8.1symfony/symfony:8.1from
peter17:patch-6peter17/symfony:patch-6Copy head branch name to clipboard
Aug 25, 2026
Merged

[HttpClient] Fix GuzzleHttpHandler consuming responses out of band#65632
nicolas-grekas merged 1 commit into
symfony:8.1symfony/symfony:8.1from
peter17:patch-6peter17/symfony:patch-6Copy head branch name to clipboard

Conversation

@peter17

@peter17 peter17 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? 8.1
Bug fix? yes
New feature? no
Deprecations? no
Issues -
License MIT

Follow-up to your closing note on #65306: the accurate "already consumed" exception added by #65311 did point at the real culprit, and it is the Guzzle handler.

Problem

streamPending() ticks Guzzle's task queue from a finally inside its stream() loop, so the queue runs while the iterator is suspended. Guzzle invokes .then() callbacks from that queue, and such a callback may wait on another promise, which drives the handler again. The nested call then consumes a second stream() iterator over the same responses, behind the back of the suspended one.

The suspended iterator resumes with stale bookkeeping, and from AsyncResponse's point of view the response was consumed out of band. Three failures follow, all reachable through the plain Guzzle API:

LogicException: Instance of "Symfony\Component\HttpClient\Response\CurlResponse" is already
consumed and cannot be managed by "Symfony\Component\HttpClient\NoPrivateNetworkHttpClient".
#0 [internal function]: Symfony\Component\HttpClient\Response\AsyncResponse::stream()
#1 Response/ResponseStream.php(40): Generator->next()
#2 GuzzleHttpHandler.php(170): Symfony\Component\HttpClient\Response\ResponseStream->next()
#3 GuzzleHttpHandler.php(104): Symfony\Component\HttpClient\GuzzleHttpHandler->streamPending()
#4 guzzlehttp/promises/src/Promise.php(251): {closure:GuzzleHttpHandler::__invoke():103}()
LogicException: Cannot change a rejected promise to fulfilled
#1 GuzzleHttpHandler.php(258): GuzzleHttp\Promise\Promise->resolve()
#2 GuzzleHttpHandler.php(221): Symfony\Component\HttpClient\GuzzleHttpHandler->resolveResponse()

plus responses stranded in $pending: never settled, they are garbage collected unconsumed and throw from their destructor, uncaught, with a stack starting at AsyncResponse::__destruct().

This is what I have been chasing in production behind NoPrivateNetworkHttpClient (~10 crashes per 50k requests): a crawler driving concurrent Guzzle promises whose callbacks wait on each other.

Reproducer

Against current 8.1 this prints 55 leaked LogicExceptions and then dies at shutdown; with the patch it prints only done.

repro.php
<?php

require __DIR__.'/vendor/autoload.php';

use GuzzleHttp\Psr7\Request;
use Symfony\Component\HttpClient\CurlHttpClient;
use Symfony\Component\HttpClient\GuzzleHttpHandler;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Contracts\HttpClient\Test\TestHttpServer;

TestHttpServer::start(8057);
mt_srand(1);

$handler = new GuzzleHttpHandler(new NoPrivateNetworkHttpClient(new CurlHttpClient(), null, ['127.0.0.1']));

$urls = [
    'http://127.0.0.1:8057/302?location=http%3A%2F%2F127.0.0.1%3A8057%2F',
    'http://127.0.0.1:8057/chunked',
    'http://127.0.0.1:8057/timeout-body',
    'http://127.0.0.1:8057/json',
];

for ($round = 0; $round < 12; ++$round) {
    $promises = [];
    for ($i = 0; $i < 5; ++$i) {
        $promises[$i] = $handler(new Request('GET', $urls[array_rand($urls)]), ['timeout' => 0.2 + mt_rand(0, 4) / 10]);
    }

    // Guzzle runs these from its task queue, which the handler ticks from inside its stream() loop
    foreach ($promises as $i => $p) {
        $wait = static function () use ($promises, $i) {
            foreach (\array_slice($promises, $i + 1) as $q) {
                try {
                    $q->wait();
                } catch (\Throwable) {
                }
            }
        };
        $p->then($wait, $wait);
    }

    foreach ($promises as $p) {
        try {
            $p->wait();
        } catch (\GuzzleHttp\Exception\GuzzleException) {
            // expected: aggressive timeouts against a slow fixture server
        } catch (\Throwable $e) {
            echo 'LEAKED ', $e::class, ': ', $e->getMessage(), "\n";
        }
    }
}

echo "done\n";

Fix

The task queue now runs once the iterator is gone, so a callback that waits on another promise still makes progress. It just starts its loop when none is running. A $streaming guard keeps a nested call from starting a second loop.

Three cases cannot make progress at all, because on_headers and on_stats are called inline from the loop, as Guzzle's contract requires. The handler now reports each of them the way Guzzle's own CurlMultiHandler does:

  • Waiting on any promise of the same handler from such a callback throws RequestException, naming the cause. Without that, Guzzle rejects the promise itself with "Invoking the wait callback did not resolve the promise", which names nothing and is not even a GuzzleException, so callers filtering on that interface miss it.
  • Calling execute() from such a callback throws LogicException, instead of returning while transfers are still outstanding, which its own docblock says it never does.
  • A promise Guzzle has already rejected in that situation is left rejected. The transfer is still tracked here, so the handler used to settle it a second time when the transfer finished, and that escaped as LogicException: Cannot change a rejected promise to fulfilled. That one also happens without the two points above, whenever an inline callback waits on its own promise.

Behaviour change

A promise waited on from on_headers or on_stats now fails with RequestException instead of silently corrupting the stream. Creating a new request from such a callback and waiting on it fails the same way. Both used to appear to work, at the cost of the corruption above.

Test

Four tests, all MockHttpClient-based and deterministic, no reflection involved:

  • testWaitingFromACallbackDoesNotConsumeASecondStream asserts the invariant directly, never two stream() iterators alive at once, with a .then() callback waiting on a sibling promise. 2 live iterators before the patch, 1 after.
  • testWaitingFromAnInlineCallbackFailsWithoutStrandingTheHandler, testRunningTheEventLoopFromAnInlineCallbackIsRejected and testAPromiseGuzzleRejectedFromAnInlineCallbackIsNotResolvedAfterwards cover the three cases above. The last one fails on 8.1 without any of this patch.

Guzzle runs .then() callbacks from its task queue, which streamPending() ticked
from inside its own stream() loop, while the iterator was suspended. A callback
that waits on another promise then drove the handler again, consuming a second
stream() iterator over the same responses.

The suspended iterator resumed with stale bookkeeping: chunks for a response
whose wrapped one had been swapped in the meantime crashed with "Instance of X
is already consumed and cannot be managed", promises got settled twice, and
responses were stranded in $pending, to throw later from their destructor.

The task queue now runs once the iterator is gone, so such a callback still
makes progress, and a guard keeps a nested call from starting a second loop.

on_headers and on_stats are called inline from the loop, as Guzzle's contract
requires, so a promise waited on from there cannot make progress. The handler
now reports that, the way Guzzle's own CurlMultiHandler does, instead of
letting Guzzle reject the promise with a message that names no cause. Such a
promise is settled while the transfer is still tracked here, so the handler
also stops settling a promise that is no longer pending, which used to escape
as "Cannot change a rejected promise to fulfilled".
@nicolas-grekas

Copy link
Copy Markdown
Member

Thank you, the analysis is correct and the reproducer made it straightforward to verify. I amended your commit with three changes and pushed.

The guard alone reintroduced one of the three failures it removes. A guarded nested wait is a no-op, so Guzzle rejects that promise itself. The response stays in $pending, and when the outer loop finishes it, resolveResponse() resolves the now rejected promise. With on_headers waiting on a sibling, then execute():

base 8.1: a => one / execute() returned / b => two
this PR:  a => one / execute() THROW LogicException: Cannot change a rejected promise to fulfilled

Added a private settle() that skips a promise which is no longer pending. That also fixes a case broken on 8.1 without this patch: an inline callback waiting on its own promise raises the same LogicException, where Guzzle's own CurlMultiHandler simply leaves the promise rejected.

The refusal now names its cause. Invoking the wait callback did not resolve the promise explains nothing, and RejectionException extends \RuntimeException instead of implementing GuzzleException, so callers filtering on that interface never see it. It now throws RequestException with the wording CurlMultiHandler uses.

execute() now throws instead of returning early. while (!$this->streaming && ...) made it return while transfers were still outstanding, which its own docblock says it never does. Guzzle throws LogicException there.

Three tests cover those, each failing both on 8.1 and on the previous state of this pull request.

I ran 20 probes on 8.1, on your version and on this one: concurrent mixed requests, a transport error mid-flight, cancel while siblings run, cancel from a .then(), a chunked body into a sink, a response never waited on, Utils::settle(), execute(), tick(), both of those from on_headers, and on_stats waiting on a sibling and on itself. Only the four cases above differ between the three states. Your reproducer goes from 14 leaked exceptions to 0, and the full HttpClient suite is green.

I also rewrote the description, since that is what people read once this is merged rather than the thread.

@nicolas-grekas

Copy link
Copy Markdown
Member

Thank you @peter17.

@nicolas-grekas
nicolas-grekas merged commit e7fac28 into symfony:8.1 Aug 25, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.