Connection pooling¶
A provider built per run pays a DNS lookup and a TLS handshake for every turn, and under
load the sockets outlive the runs that opened them until the process runs out of
descriptors. ClientPool is where connections live instead: providers borrow from it,
and it decides what may be shared with what.
from tesserix_adk.models import ClientPool, PoolConfig
from tesserix_adk.models.providers import AnthropicProvider, OpenAIProvider
async with ClientPool(PoolConfig(max_connections=50, max_keepalive=10)) as clients:
fast = OpenAIProvider("gpt-4o", pool=clients)
careful = AnthropicProvider("claude-sonnet-5", pool=clients)
...
A provider given no pool still owns its own client, so nothing about the single-provider case changes.
What may share a connection¶
Sharing is decided by a key, and the key is the whole safety argument:
| Part of the key | Why it is part of it |
|---|---|
| provider | Two vendors are two endpoints. |
| base URL | A regional host or a gateway is a different pool. |
| credential digest | Two tenants against one endpoint must never be handed each other's connection. |
| transport settings | Timeouts and limits, since one client honours one set of them. |
The credential appears as a truncated digest, never as itself. A key is compared, logged and used as a metric label, and a secret that reaches any of those has leaked.
Rotation¶
The credential is resolved per request, not captured at construction. When it changes, the key changes: the next request opens a pool on the new key, and the old client is retired rather than closed — it stops being handed out, and it closes once the requests already on it have finished. Nothing in flight is cut off, and nothing new goes out on the old key.
Exhaustion is bounded¶
acquire_seconds is how long a caller waits for a free connection. When none comes free
the request fails with PoolExhaustedError, which is retryable: the endpoint is fine and
the process is over-subscribed. Bounding it is the point — an unbounded wait for a
connection turns a downstream slowdown into a run that queues past its own deadline.
Forks¶
A pool inherited across a fork is half-open whatever its bookkeeping says: the
descriptors belong to the parent's event loop. Each client records the pid that opened it,
and a client from another process is discarded rather than used. The inherited counter
says how often that happened.
Metrics¶
pool.metrics is a snapshot, taken rather than watched, so a reader that prints four
counters prints four counters from one moment:
| Counter | Meaning |
|---|---|
opened |
Clients created. |
reused |
Times an existing client was handed back instead. |
retired |
Clients replaced by rotation, whose in-flight work was left to finish. |
inherited |
Clients discarded because they came from another process. |
exhaustions |
Requests that failed because no connection came free in time. |
open_now |
Clients currently held. |
waited_seconds |
Total time spent inside provider requests waiting on the pool. |
Lifetime¶
The pool is an async context manager and closing it closes everything it opened, because a
pool nobody closes is the leak this exists to fix. A provider's own aclose() closes only
a client it owns; one borrowed from a pool is the pool's to close.
Per-vendor ceilings go in per_provider, and an entry there replaces the defaults for that
provider entirely:
Runnable version: examples/connection_pooling.py.