{{ message }}
Repository navigation
fix(agents): send requests through a reused utopia-php/client - #13794
Merged
Merged
Conversation
utopia-php/fetch's cURL adapter registers non-static closures on the handle, which binds the adapter and leaves CurlHandle -> closure -> adapter -> handle as a reference cycle. Every adapter built a fresh fetch client per request, so each call left a live handle (TLS state, keep-alive socket, eventfd) until PHP's cycle collector ran. Long-lived Swoole workers rarely reach that threshold: the threats worker on Cloud grew ~50 MB/h and piled up CLOSE_WAIT sockets to api.openai.com until its 1 GiB limit. The fetch package is archived, so the cycle cannot be fixed there. Adapters now share one utopia-php/client instance: inside a coroutine a pool of keep-alive Swoole clients, elsewhere a single keep-alive cURL client, built once per adapter with its timeout and reused across requests. Callers that already own a pooled client can inject it with setClient(). Streaming goes through the PSR-18 stream() sink as raw chunks, so the adapters' SSE processing takes strings instead of fetch Chunk objects. Measured in the appwrite/ce:cl-2.2.0-1 image with GC never triggered: 200 agent calls grew RSS by 2.5 MB with a flat fd count, against 18 MB and two leaked fds per call before. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
✨ Benchmark resultsComparing
Per-scenario breakdown & investigation detailsMetrics below reflect the current branch (after). Δ P95 compares against the base.
Top API waits (after)
|
…the old hook name free utopia-php/client needs PHP 8.5, so the package cannot keep advertising 8.3. The base adapter's default-client builder is named defaultClient() so it cannot collide with subclasses that overrode the former protected OpenAI::createClient() hook. Tests assert what a caller can observe (requests reaching the injected client, headers sent) rather than client identity or a pinned API version. Co-Authored-By: Claude <noreply@anthropic.com>
Member
Author
# Conflicts: # composer.lock
…r a response A fixed three second pool wait against a ninety second request timeout turned a busy pool into a new failure mode. Restores the tests for the client lifecycle around setTimeout(), which are observable through the public getClient() and setClient() API.
The package already breaks on this release, so the client is a constructor dependency like every other adapter setting instead of a setter that could be called mid-flight. The client accessor is protected: callers configure it, they do not read it back.
loks0n
approved these changes
Sep 21, 2026
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 does this PR do?
Moves
packages/agentsoff the archivedutopia-php/fetchontoutopia-php/client, which the rest of the codebase already uses.The problem
Every agent call built a new fetch client. Its cURL adapter registers non-static closures on the handle, and a closure declared in a method captures
$this, so each call left a reference cycle that refcounting can never free:On Cloud the threats worker grew about 50 MB an hour, accumulated
CLOSE_WAITsockets toapi.openai.com, and restarted at its 1 GiB limit.utopia-php/fetchis archived, so the cycle cannot be fixed there.The fix
Each adapter builds one client on first use and sends every request through it. Inside a Swoole coroutine that is a pool of keep-alive coroutine clients; elsewhere a single keep-alive cURL client that uses
staticclosures and one reset handle:$client, for processes that already share a pool. There is no setter.setTimeout()rebuilds a client the adapter built and leaves an injected one alone.stream()sink, so SSE processing takes raw string chunks.This is a breaking release of the package: PHP 8.5 (matching
utopia-php/client), string chunks instead ofUtopia\Fetch\Chunkinprocess(), and theOpenAI::createClient()hook replaced byheaders(). Rootcomposer.jsondeclarespsr/http-client, which it already hoisted.Test Plan
bin/monorepo check agentsandbin/monorepo test agentspass (139 tests). New tests cover the injected client, reuse across sends, thesetTimeout()rebuild rules, request shape, SSE reassembly across chunk boundaries, and error status propagation.appwrite/ce:cl-2.2.0-1(Swoole 6.2.2, PHP 8.5.10) running the realAgent/OpenAI/Conversationcode with GC disabled: before, 200 calls grew RSS by 18 MB and leaked 2 fds per call; after, RSS grows 2.5 MB in total and the fd count stays flat.Related PRs and Issues
Cloud consumes
appwrite/server-ceatdev-main, socomposer update appwrite/server-cethere picks this up forworker-threatsonce merged.Checklist