Repository navigation
[13.x] Fix reference cycle from PendingRequest::throw() default callback - #61802
Merged
taylorotwell merged 1 commit intoSep 30, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
When
PendingRequest::throw()is called without a callback, it stores a non-static arrow function:A non-static arrow function created inside an instance method captures
$this, so thePendingRequestnow references itself through its own$throwCallback. ThePendingRequestalso keeps$transferStats, and Guzzle'sTransferStatsholds 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_limitbefore 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 examplelaravel/ai, whoseCreatesClientalways calls it).This is the same cycle #61438 fixed for the default
beforeSendingcallback in the constructor. The default callback inthrow()still has it, on 13.34.0,13.xandmaster.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, thengc_collect_cycles()):gc_collect_cycles()Http::baseUrl($url)->post(...)Http::baseUrl($url)->throw()->post(...)Http::baseUrl($url)->throw(static fn () => null)->post(...)Reproduce
Without
->throw(), or with->throw(static fn () => null), the firstvar_dumpprintstrue.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()andthrowIf()with or without a callback, and retries, work as before. The existingtests/Httpsuite passes (615 tests).Test
testPendingRequestIsReleasedWithoutGarbageCollectionAfterThrowsends a faked request through->throw()with GC disabled and asserts, through aWeakReference, that thePendingRequestis 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 callgc_collect_cycles(). No existing feature changes.🤖 Generated with Claude Code