fix: bound remote fetch connection pool acquire wait by fetch timeout#78
fix: bound remote fetch connection pool acquire wait by fetch timeout#78cmyui wants to merge 2 commits into
Conversation
The remote client acquired its pooled connection with no timeout, so when all pooled connections were busy, concurrent fetches blocked indefinitely in pool.acquire() -- a wait that fetch_timeout_millis never covered, since it only bounds the socket read after acquisition. Pass fetch_timeout_millis as the acquire timeout and map the pool's existing EmptyPoolError to FetchException(408). 408 lands in the non-retryable bucket of __should_retry_fetch by design: retrying a starved pool would re-enter the same queue and add pressure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Reviewed by Cursor Bugbot for commit ac735dc. Configure here.
| conn = self._connection_pool.acquire(timeout=self.config.fetch_timeout_millis / 1000) | ||
| except EmptyPoolError: | ||
| raise FetchException(408, f"Timed out waiting {self.config.fetch_timeout_millis}ms for a connection " | ||
| f"from the pool (max_size={self._connection_pool.max_size})") |
There was a problem hiding this comment.
Pool timeout not surfaced publicly
Medium Severity
FetchException(408) from a pool acquire timeout is non-retryable, so __fetch_internal catches it and returns without re-raising. fetch_v2 then returns None instead of propagating the error, and async paths invoke callbacks with None variants and no error.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ac735dc. Configure here.
There was a problem hiding this comment.
Good catch, fixed in 207ae5a; the pool-acquire timeout now raises a builtin TimeoutError, which takes the same retry classification as the socket read timeout (socket.timeout = TimeoutError on 3.10+). Both ways of violating fetch_timeout_millis now behave identically.
There was a problem hiding this comment.
Also! I noticed that __fetch_internal returns None for any non-retryable FetchException instead of re-raising, despite fetch_v2's docstring saying it throws — that predates this PR and still applies to real 4xx responses. Happy to file that separately if maintainers agree it's a bug.
From what I can tell, the matrix is now:
| Failure | Retry classification | fetch_retries=0 (default) |
Retries configured, all fail |
|---|---|---|---|
| Socket read timeout | retryable | returns {} |
raises after exhaustion |
| Pool-acquire timeout (this PR) | retryable | returns {} |
raises after exhaustion |
| 5xx / 429 response | retryable | returns {} |
raises after exhaustion |
| Non-retryable 4xx response | not retried | returns None, silently |
returns None, silently |
Raising FetchException(408) put the pool-wait timeout in the non-retryable 4xx bucket, where __fetch_internal swallows the error and fetch_v2 returns None with no signal (flagged by Bugbot). Raise a builtin TimeoutError instead -- the same classification as the socket read timeout -- so both violations of fetch_timeout_millis behave identically: empty variants at fetch_retries=0, bounded retries then raise when retries are configured, and async callbacks receive the error. Adds an end-to-end parity test on fetch_v2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
| conn = self._connection_pool.acquire() | ||
| try: | ||
| conn = self._connection_pool.acquire(timeout=self.config.fetch_timeout_millis / 1000) |
There was a problem hiding this comment.
alternativly, add a new parameter (e.g. fetch_pool_acquire_timeout_millis) to avoid behavior changes to existing clients with their existing configuration


Summary
Sibling to #77, independently mergeable. #77 makes the connection pool's size configurable; this PR bounds how long a fetch may wait for a connection from that pool.
Today the remote client acquires its pooled connection with no timeout:
With the pool's default
timeout=None, the blocking branch is an unboundedwhile self.is_pool_empty(): self._lock.wait()— so when all pooled connections are busy (with the current pool of 1: whenever two fetches overlap during evaluation-API latency), concurrent callers queue indefinitely.fetch_timeout_millisnever applies to this wait; it only bounds the socket read after acquisition. In production we observed request handlers pinned inacquire()for the entire duration of an upstream latency event, and the queued backlog continued failing requests for minutes after the API recovered (details and magnitude in #77's description).Change
The pool already implements a deadline path (
acquire(timeout=...)raisingEmptyPoolError) — it just was never used. This PR:fetch_timeout_millisas the acquire timeout at the single call site, andEmptyPoolErrorto a builtinTimeoutErrorwith a descriptive message.TimeoutErrorgets the same retry classification as the socket read timeout (socket.timeoutis the same type on Python ≥ 3.10), so both ways of violatingfetch_timeout_millis— waiting for a connection, or waiting on the read — are indistinguishable to callers: withfetch_retries=0(default)fetch_v2returns the usual empty variants result; with retries configured it backs off and raises after exhaustion (each attempt's wait now bounded); async callbacks receive the error.Tests: pool-exhausted fetch raises
TimeoutErrorpromptly instead of hanging; the timeout is classified retryable like a read timeout; end-to-endfetch_v2parity (empty dict atfetch_retries=0, notNone); existing fetch-options test updated for the newacquire(timeout=...)signature.This is an intentional behavior change, and we'd like your opinion on it before merge:
TimeoutErrorafterfetch_timeout_millis(default 10s), handled exactly like a socket read timeout — empty variants atfetch_retries=0, bounded retries then raise when retries are configured. Worst case per attempt becomesfetch_timeout_millis(queue wait) +fetch_timeout_millis(socket read) = 2× the configured value, instead of unbounded.We believe bounding by default is the right call — an indefinite silent hang is strictly worse than a timely, handleable error, and users who set
fetch_timeout_millisalmost certainly believe it already bounds their fetch latency. But if you'd prefer no default-behavior change, we're happy to switch this to an opt-infetch_pool_acquire_timeout_millisconfig (defaultNone= current infinite wait) — it's a three-line delta. Let us know which shape you want.Checklist
fetch_timeout_millis(see section above).Note
Medium Risk
Behavioral change under concurrent load: overlapping fetches may now time out waiting for a pool connection rather than hanging unbounded, affecting latency and error/retry outcomes.
Overview
Remote variant fetches no longer block indefinitely when the HTTP connection pool is exhausted (default max_size=1).
__do_fetchnow callsacquire(timeout=fetch_timeout_millis / 1000)and turnsEmptyPoolErrorinto aTimeoutErrorthat names the configured wait and pool size.RemoteEvaluationConfigdocs clarify thatfetch_timeout_millisalso caps pool-queue wait, not only the socket read after a connection is obtained. Withfetch_retries=0, a pool wait timeout follows the same path as other retryable timeouts (e.g.fetchreturns{});fetch_v2can still propagate errors depending on retry settings.Tests cover prompt failure under a held connection,
TimeoutErrorretry classification, parity with read-timeout behavior onfetch_v2, and the mockedacquire(**kwargs)signature.Reviewed by Cursor Bugbot for commit 207ae5a. Bugbot is set up for automated code reviews on this repo. Configure here.