fix: reuse one HTTP connection instead of a client per call - #32
Merged
Conversation
Greptile SummaryThis PR replaces per-call Fetch clients with an injectable, adapter-lifetime PSR-18 client to reuse the underlying HTTP connection.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains within the eligible follow-up review scope. Important Files Changed
Reviews (3): Last reviewed commit: "fix: reuse one HTTP connection instead o..." | Re-trigger Greptile |
loks0n
force-pushed
the
fix/pooled-client-connection-reuse
branch
from
August 4, 2026 09:02
ffc50fa to
be3fbfb
Compare
Adapter::call() built a new Utopia\Fetch\Client for every request. Each one leaks its cURL handle and the connection behind it through a closure reference cycle (utopia-php/fetch#22), so a long-running process grows without bound in native memory that PHP's memory_limit cannot see. In production this OOM-killed task-billing-payments: the pod idles at ~330MB of its 512Mi limit, and the daily due-invoice batch (~1000 invoices, several Stripe calls each) added ~200MB in four minutes and crossed the limit. The PHP heap stayed flat throughout, so nothing was logged before the kernel killed it. Stripe now builds its requests with utopia-php/psr7's factory and sends them with a utopia-php/client that keeps one cURL handle for the lifetime of the adapter. Measured over 400 identical requests: before +800 fds, +21 MB RSS after +0 fds, +48 kB RSS The client is injected through the constructor and readonly, so the transport is the caller's to choose: a pool for coroutine contexts, a retry decorator, its own timeouts, or a double under test. Adapter::call() and the METHOD_* constants are gone with it. A PSR-18 client and a PSR-17 factory already express "build a request, send it", so the indirection bought nothing but a second vocabulary for HTTP — seven of the nine constants had no caller. Adapter goes back to being the provider contract, with no imports and no transport of its own; Stripe owns its client because the wire format is its concern, not the contract's. Two consequences of the swap: - utopia-php/client requires PHP 8.5, so pay does too. The CI matrix drops 8.0-8.3. - The multipart/form-data branch and its flatten() helper are removed. No adapter used them, and the new request factory models multipart as typed parts rather than a flattened array, so a future adapter should build them through that instead of resurrecting the old shape. GET params move from the request body to the query string, which is where they belong. Stripe accepts either — verified against the live API that a created[gt] filter is honoured both ways — so this changes the wire format, not behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loks0n
force-pushed
the
fix/pooled-client-connection-reuse
branch
from
August 4, 2026 09:06
be3fbfb to
611b446
Compare
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.
What
Adapter::call()built a newUtopia\Fetch\Clientfor every request. Each one leaks its cURL handle and the connection behind it via a closure reference cycle (utopia-php/fetch#22). Stripe now builds requests with utopia-php/psr7's factory and sends them with autopia-php/clientthat keeps one handle for the lifetime of the adapter.Adapter::call()and theMETHOD_*constants go with it. A PSR-18 client and a PSR-17 factory already say "build a request, send it", so the indirection bought nothing but a second vocabulary for HTTP — and seven of the nine method constants had no caller.Adapteris back to being the provider contract: no imports, no transport. Stripe owns its client, because the wire format is Stripe's concern and not the contract's.The client is injected through the constructor and readonly. Pay has no opinion about pooling — a caller that needs one wraps
utopia-php/clientin itsPooldecorator and passes it in. Same for retries, timeouts, or a double under test. The default is a plain connection-reusing cURL client, so the common case needs no wiring.Net -55 lines.
Why
This is the root cause of
task-billing-paymentsOOM-killing in production (cloud-fra1-prod, ~6×/day, escalating).The pod idles at ~330 MB of its 512Mi limit. Every day at ~00:20 UTC the newly-due invoice batch runs — ~1000 invoices, several Stripe calls each — and memory climbs to the limit in about four minutes:
It then crash-loops (four container starts in 13 minutes) until the batch clears. The leak is native cURL/TLS memory, so the PHP heap stays flat and
memory_limitnever trips — nothing is logged, the kernel just kills the process. That is why this looked like a mystery restart with clean logs.Verification
Both sides of an identical 400-request workload against a local echo server:
Request shape is unchanged — same method, path,
application/x-www-form-urlencodedbody with Stripe'saddress[city]bracket encoding, same User-Agent:Injection and the default were each checked end to end:
The repo's live-Stripe suite is the real contract check and runs on this PR.
Breaking changes
utopia-php/clientrequires it, so pay does too. CI matrix drops 8.0–8.3. Cloud is already on 8.5.Adapter::call(),Adapter::handleError()and theMETHOD_*constants are removed. An out-of-tree adapter that used them should build requests with the PSR-17 factory and send them with its own PSR-18 client, asStripe::execute()now does.Stripe::__constructtakes an optional third?ClientInterface $client.multipart/form-datasupport and theprotected flatten()helper are removed. No adapter used either; the new request factory models multipart as typed parts, so a future adapter should build them through that.created[gt]filter is honoured either way — so this is a wire-format change, not a behavioural one.Notes for the reviewer
composer check(PHPStan) fails onmainas well — phpstan1.9.x-devchokes on PHP 8.5 itself and reports nothing aboutsrc/. Left alone; it is not in CI and fixing it means a PHPStan major bump.CURLOPT_FOLLOWLOCATION => false). The Stripe API does not redirect.Follow-up
Cloud builds
Payper DI container inapp/init/resources.php, so the HTTP path still gets a fresh client per request. Worth a follow-up to construct it with a shared pooled client once this lands. The CLI tasks — the ones OOMing — hold a single long-lived adapter and are fixed by this alone.🤖 Generated with Claude Code