Skip to content

fix: reuse one HTTP connection instead of a client per call - #32

Merged
loks0n merged 1 commit into
mainfrom
fix/pooled-client-connection-reuse
Aug 4, 2026
Merged

fix: reuse one HTTP connection instead of a client per call#32
loks0n merged 1 commit into
mainfrom
fix/pooled-client-connection-reuse

Conversation

@loks0n

@loks0n loks0n commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Adapter::call() built a new Utopia\Fetch\Client for 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 a utopia-php/client that keeps one handle for the lifetime of the adapter.

Adapter::call() and the METHOD_* 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. Adapter is 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/client in its Pool decorator 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-payments OOM-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:

00:17  332 MB   loop starts
00:19  374 MB
00:20  450 MB
00:21  533 MB   ← limit is 536 MB. OOMKilled.

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_limit never 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:

fds RSS
before (client per call) +800 +21 MB
after (reused connection) +0 +48 kB

Request shape is unchanged — same method, path, application/x-www-form-urlencoded body with Stripe's address[city] bracket encoding, same User-Agent:

POST  method=POST uri=/customers ct=application/x-www-form-urlencoded
      body=name=Test%20customer&email=test%40example.com&address%5Bcity%5D=Berlin&address%5Bcountry%5D=DE
      user-agent=Darwin-25.5.0:php-8.5.8

Injection and the default were each checked end to end:

injected client used: user-agent=injected-client/1.0
default  client used: user-agent=Darwin-25.5.0:php-8.5.8

The repo's live-Stripe suite is the real contract check and runs on this PR.

Breaking changes

  • PHP 8.5 floor. utopia-php/client requires it, so pay does too. CI matrix drops 8.0–8.3. Cloud is already on 8.5.
  • Adapter::call(), Adapter::handleError() and the METHOD_* 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, as Stripe::execute() now does.
  • Stripe::__construct takes an optional third ?ClientInterface $client.
  • multipart/form-data support and the protected 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.
  • GET params move from the request body to the query string. Stripe accepts both — verified against the live API that a 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 on main as well — phpstan 1.9.x-dev chokes on PHP 8.5 itself and reports nothing about src/. Left alone; it is not in CI and fixing it means a PHPStan major bump.
  • Redirect following is dropped (the new adapter sets CURLOPT_FOLLOWLOCATION => false). The Stripe API does not redirect.

Follow-up

Cloud builds Pay per DI container in app/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

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces per-call Fetch clients with an injectable, adapter-lifetime PSR-18 client to reuse the underlying HTTP connection.

  • Moves Stripe request construction to the PSR-7 request factory.
  • Moves GET parameters into the query string and form-encodes other request bodies.
  • Removes transport helpers and HTTP method constants from the base adapter.
  • Raises the PHP requirement and CI floor to PHP 8.5.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains within the eligible follow-up review scope.

Important Files Changed

Filename Overview
src/Pay/Adapter/Stripe.php Introduces an injectable persistent HTTP client and moves Stripe request construction, transmission, decoding, and error handling into the Stripe adapter.
src/Pay/Adapter.php Removes the shared Fetch transport implementation, method constants, multipart flattening helper, and base error handler.
composer.json Replaces Fetch with PSR HTTP interfaces and Utopia client/PSR-7 packages while raising the PHP floor to 8.5.
composer.lock Regenerates the dependency lockfile for the new HTTP stack.
.github/workflows/tests.yml Updates the test matrix to cover PHP 8.5 and nightly.

Reviews (3): Last reviewed commit: "fix: reuse one HTTP connection instead o..." | Re-trigger Greptile

@loks0n
loks0n force-pushed the fix/pooled-client-connection-reuse branch from ffc50fa to be3fbfb Compare August 4, 2026 09:02
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
loks0n force-pushed the fix/pooled-client-connection-reuse branch from be3fbfb to 611b446 Compare August 4, 2026 09:06
@loks0n
loks0n merged commit fe0b08b into main Aug 4, 2026
4 checks passed
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.

1 participant