Skip to content

[13.x] Fix reference cycle from PendingRequest::throw() default callback - #61802

Merged
taylorotwell merged 1 commit into
laravel:13.xfrom
andrewnabors:fix-throw-callback-reference-cycle
Sep 30, 2026
Merged

taylorotwell merged 1 commit into
laravel:13.xfrom
andrewnabors:fix-throw-callback-reference-cycle

Conversation

@andrewnabors

Copy link
Copy Markdown
Contributor

The problem

When PendingRequest::throw() is called without a callback, it stores a non-static arrow function:

public function throw(?callable $callback = null)
{
    $this->throwCallback = $callback ?: fn () => null;

    return $this;
}

A non-static arrow function created inside an instance method captures $this, so the PendingRequest now references itself through its own $throwCallback. The PendingRequest also keeps $transferStats, and Guzzle's TransferStats holds the full PSR-7 response. So every request sent with ->throw() stays in memory, body included, after the caller is done with it. Only PHP's cycle collector frees it. The collector runs when its root buffer reaches 10,000 entries, not when memory runs low.

In long-running processes such as queue workers, Octane, commands and backfills, memory then grows with each request and can reach memory_limit before a collection runs. We hit this in a CLI job that sent one ~1.3 MB embeddings request per batch: memory grew ~1.4 MB per batch and the process died at 128 MB during a Guzzle stream read. gc_collect_cycles() reclaimed all of it. SDKs that build their clients with ->throw() hit this on every request (for example laravel/ai, whose CreatesClient always calls it).

This is the same cycle #61438 fixed for the default beforeSending callback in the constructor. The default callback in throw() still has it, on 13.34.0, 13.x and master.

Measured against a local server returning a 1.3 MB JSON body (Laravel 13.33, PHP 8.5; one warm-up call first, then memory_get_usage() before and after one call, then gc_collect_cycles()):

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

Reproduce

use Illuminate\Support\Facades\Http;

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: the PendingRequest (and its transferStats/response) is still alive

gc_collect_cycles();
var_dump($ref->get() === null); // true: only the cycle collector frees it

Without ->throw(), or with ->throw(static fn () => null), the first var_dump prints true.

The fix

The default callback is now static fn () => null. It takes no arguments and uses nothing from the instance, so behaviour is unchanged: throw() and throwIf() with or without a callback, and retries, work as before. The existing tests/Http suite passes (615 tests).

Test

testPendingRequestIsReleasedWithoutGarbageCollectionAfterThrow sends a faked request through ->throw() with GC disabled and asserts, through a WeakReference, that the PendingRequest is freed as soon as the last reference goes. It fails before this change and passes after.

Benefit to end users

Memory in queue workers, Octane and long CLI jobs that make HTTP calls with ->throw() stays flat, without anyone having to call gc_collect_cycles(). No existing feature changes.

🤖 Generated with Claude Code

throw() without a callback stored a non-static arrow function, which
captures $this, so the PendingRequest referenced itself. Because the
PendingRequest also holds $transferStats (the Guzzle TransferStats with
the full PSR-7 response), every request sent with ->throw() kept its
response body alive until PHP's cycle collector happened to run.

Follows laravel#61438, which fixed the same cycle for the default
beforeSending callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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