fix(agents): send requests through a reused utopia-php/client by ChiragAgg5k · Pull Request #13794 · appwrite/appwrite · GitHub
Skip to content

fix(agents): send requests through a reused utopia-php/client - #13794

Merged
ChiragAgg5k merged 6 commits into
mainfrom
fix/agents-utopia-client
Sep 21, 2026
Merged

ChiragAgg5k merged 6 commits into
mainfrom
fix/agents-utopia-client

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Moves packages/agents off the archived utopia-php/fetch onto utopia-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:

  per call:

  CurlHandle ──> closure ──> Adapter ──> $this->handle ──> CurlHandle
      ^                                                        │
      └────────────────────────────────────────────────────────┘
                     cycle: only the GC can free it

  call 1   [handle + TLS + socket]   ─┐
  call 2   [handle + TLS + socket]    ├─ pile up until gc_collect_cycles()
  call 3   [handle + TLS + socket]    │  (rarely reached in a long-lived worker)
  ...                                ─┘

On Cloud the threats worker grew about 50 MB an hour, accumulated CLOSE_WAIT sockets to api.openai.com, and restarted at its 1 GiB limit. utopia-php/fetch is 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 static closures and one reset handle:

  before                              after

  send() ─> new Client ─> leaked      send() ─┐
  send() ─> new Client ─> leaked      send() ─┼─> one client ─> keep-alive connection(s)
  send() ─> new Client ─> leaked      send() ─┘   (pool of 8 inside Swoole)
  • Every adapter constructor takes an optional $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.
  • A request waits for a free pooled connection as long as it would wait for a response, so a busy pool delays a call instead of failing it.
  • Streaming goes through the PSR-18 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 of Utopia\Fetch\Chunk in process(), and the OpenAI::createClient() hook replaced by headers(). Root composer.json declares psr/http-client, which it already hoisted.

Test Plan

  • bin/monorepo check agents and bin/monorepo test agents pass (139 tests). New tests cover the injected client, reuse across sends, the setTimeout() rebuild rules, request shape, SSE reassembly across chunk boundaries, and error status propagation.
  • Reproduction in appwrite/ce:cl-2.2.0-1 (Swoole 6.2.2, PHP 8.5.10) running the real Agent/OpenAI/Conversation code 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-ce at dev-main, so composer update appwrite/server-ce there picks this up for worker-threats once merged.

Checklist

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>
@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Comment thread packages/agents/composer.json
Comment thread packages/agents/src/Adapter.php Outdated
Comment thread packages/agents/tests/Adapters/Adapter.php Outdated
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/agents-utopia-client (after).

Metric Before After Change
🚀 Requests/sec 198.69 178.36 🔴 -10.2%
⏱️ Latency P50 85.93 ms 98.8 ms 🔴 +15%
⏱️ Latency P95 206.46 ms 227.75 ms 🔴 +10.3%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 98.8 227.75 11,400 178.36 +21.29
Account 191.75 360.4 600 10.14 +12.84
TablesDB 95.48 173.62 6,200 99.61 +8.63
Storage 91.47 193.86 3,000 49.68 +31.93
Functions 141.91 272.03 1,600 27.23 +39.84

Top API waits (after)

API request Max wait (ms)
account.name.update 590.05
account.prefs.update 505.47
functions.variables.update 482.92
tablesdb.rows.increment 467.07
tablesdb.rows.delete 431.09

…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>
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

…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.
@ChiragAgg5k
ChiragAgg5k merged commit e52e02f into main Sep 21, 2026
53 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the fix/agents-utopia-client branch September 21, 2026 11:09
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