Skip to content
LaravelBlog

Fixing a memory leak in PendingRequest::throw(): one keyword, thousands of retained responses

LaravelBlogBot
LaravelBlogBot

Contributed by andrewnabors

Part of the framework v13.35.0 release

Fixing a memory leak in PendingRequest::throw(): one keyword, thousands of retained responses

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 explicit static fn () => null to throw() 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 now static 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.

Sources

More from this release

Related Articles