Fixing a memory leak in PendingRequest::throw(): one keyword, thousands of retained responses
Contributed by andrewnabors
Part of the framework v13.35.0 release
This is a bug fix release for the HTTP client. Pull request #61802 changes exactly one keyword in PendingRequest::throw(): the default failure callback is now declared static, which removes a reference cycle that was keeping every request sent with ->throw() — response body included — alive in memory until PHP's cycle collector happened to run. If you are upgrading from v13.34.0 there is nothing to change in your code: the fix is drop-in and behavior-identical. But if you run queue workers, Octane, or long-lived CLI processes that make HTTP calls through the client, it removes a slow memory leak you may have been fighting without knowing why.
What changed
Here is the entire fix:
// Before (v13.34.0)
$this->throwCallback = $callback ?: fn () => null;
// After
$this->throwCallback = $callback ?: static fn () => null;
A non-static arrow function created inside an instance method implicitly captures $this. Since throw() stores that closure on the PendingRequest itself, the object ended up referencing itself through its own $throwCallback. Declaring the closure static drops the implicit $this binding. The default callback takes no arguments and uses nothing from the instance, so nothing observable changes: throw(), throwIf(), custom callbacks, and retries all behave exactly as before.
Why it leaked memory
A self-reference alone wouldn't be fatal — PHP's garbage collector collects cycles. The problem is what the cycle pins. PendingRequest keeps $transferStats, and Guzzle's TransferStats holds the full PSR-7 response, body included. So every completed ->throw() request kept its entire response in memory.
The cycle collector doesn't run when memory runs low. It runs when its root buffer reaches 10,000 possible roots. In a long-running process, memory can hit memory_limit long before a collection triggers. The PR's benchmark, against a local server returning a 1.3 MB JSON body, makes the retention visible:
| Call | Kept after the call | Freed by gc_collect_cycles() |
|---|---|---|
Http::baseUrl($url)->post(...) |
0 KB | 0 cycles |
Http::baseUrl($url)->throw()->post(...) |
1,306 KB | 36 cycles, 1,306 KB |
Http::baseUrl($url)->throw(static fn () => null)->post(...) |
0 KB | 0 cycles |
This is the same class of cycle that #61438 already fixed for the default beforeSending callback in the constructor — throw()'s default callback was simply missed.
Who feels it
Anyone calling ->throw() repeatedly inside a process that outlives a single request: queue workers, Octane workers, and long CLI commands or backfills. The concrete report behind this PR: a CLI job sending one ~1.3 MB embeddings request per batch watched memory grow ~1.4 MB per batch and died at 128 MB during a Guzzle stream read. A manual gc_collect_cycles() reclaimed all of it.
SDKs built on the client are exposed too. Anything that always calls ->throw() when constructing requests — for example laravel/ai's CreatesClient trait — hit this leak on every single request.
How to verify it
You can reproduce the old behavior with a WeakReference and the cycle collector disabled:
Http::fake(['*' => Http::response('ok')]);
gc_disable();
$pending = Http::throw();
$pending->get('https://example.com');
$ref = WeakReference::create($pending);
unset($pending);
var_dump($ref->get() === null); // false before this fix: still referenced by its own callback
gc_collect_cycles();
var_dump($ref->get() === null); // true: only the cycle collector freed it before
On v13.34.0 the first dump prints false — the PendingRequest, its transfer stats, and the response survive long after the caller is done. After this fix it prints true. The PR adds exactly this assertion as testPendingRequestIsReleasedWithoutGarbageCollectionAfterThrow, which fails before the change and passes after; the full tests/Http suite (615 tests) is green.
Upgrade impact
The upgrade is a free win:
- No code changes required. If you call
->throw()or->throw($callback), your code is unchanged and correct as-is. - No behavior change. The default callback still accepts nothing and does nothing; error handling,
throwIf(), and retry interactions are untouched. - Workarounds can go. If you inserted
gc_collect_cycles()calls, or passed an explicitstatic fn () => nulltothrow()to dodge the leak, you can remove that scaffolding after upgrading. - Memory now stays flat in workers and long jobs, instead of growing by the size of every response body until a collection runs.
Takeaways
- One-keyword fix: the default
throw()callback is nowstatic fn () => null. - It removes a self-reference cycle that pinned each
->throw()response — body included — until PHP's cycle collector ran. - The leak hit queue workers, Octane, and long CLI jobs hardest, sometimes fatally at
memory_limit. - Upgrading from v13.34.0 requires no code changes and changes no behavior; memory simply stops growing.