Skip to content

fix: bound remote fetch connection pool acquire wait by fetch timeout - #78

Open
cmyui wants to merge 3 commits into
amplitude:mainfrom
cmyui:fix-bound-pool-acquire-wait
Open

fix: bound remote fetch connection pool acquire wait by fetch timeout#78
cmyui wants to merge 3 commits into
amplitude:mainfrom
cmyui:fix-bound-pool-acquire-wait

Conversation

@cmyui

@cmyui cmyui commented Jul 24, 2026

Copy link
Copy Markdown

Summary

Sibling to #77, independently mergeable. #77 makes the connection pool's size configurable; this PR makes the time a fetch may wait for a connection from that pool configurable — opt-in, with no default-behavior change.

Today the remote client acquires its pooled connection with no timeout:

# remote/client.py
conn = self._connection_pool.acquire()

With the pool's default timeout=None, the blocking branch is an unbounded while 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_millis never applies to this wait; it only bounds the socket read after acquisition. In production we observed request handlers pinned in acquire() 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

Adds fetch_pool_acquire_timeout_millis to RemoteEvaluationConfig, default None:

  • None (default): behavior is exactly today's — the acquire waits indefinitely. No existing user sees any change.
  • When set: the acquire uses the pool's existing deadline path (acquire(timeout=...) raising EmptyPoolError — implemented but previously unused), and exhaustion is mapped to a builtin TimeoutError with a descriptive message.

TimeoutError gets the same retry classification as the socket read timeout (socket.timeout is the same type on Python ≥ 3.10), so when enabled, both ways a fetch can stall — waiting for a connection, or waiting on the read — are indistinguishable to callers: with fetch_retries=0 (default) fetch_v2 returns the usual empty variants result; with retries configured it backs off and raises after exhaustion (each attempt's wait bounded); async callbacks receive the error.

(An earlier revision raised FetchException(408), which landed in the non-retryable 4xx path where __fetch_internal returns silently — fetch_v2 produced a bare None with no error signal. Credit to the Bugbot comment below for flagging it; that swallow behavior for non-retryable 4xx responses predates this PR and may be worth a separate issue.)

Tests: config defaults to None; pool-exhausted fetch with the timeout set raises TimeoutError promptly instead of hanging; the timeout is classified retryable like a read timeout; end-to-end fetch_v2 parity (empty dict at fetch_retries=0, not None); existing fetch-options test updated for the new acquire(timeout=...) signature.

Question for maintainers: what should the long-term default be?

This PR deliberately ships with no default-behavior change so it's an easy merge. But we'd value your opinion on the end state:

  1. Keep the dedicated opt-in parameter (this PR as-is)?
  2. Bound the acquire wait by fetch_timeout_millis by default? We suspect users who set fetch_timeout_millis believe it already bounds their fetch latency, and an indefinite silent hang is arguably worse than a timely, handleable error — but that's a default-behavior change you may want to schedule for a major version.
  3. Some other shape (e.g. folding it into a broader concurrency config alongside feat: make remote evaluation connection pool size configurable #77's connection_pool_max_size)?

Happy to rework this PR to whichever you prefer.

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: no — new optional config; default None preserves existing behavior exactly.

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>
@cmyui
cmyui requested a review from a team as a code owner July 24, 2026 22:52

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Comment thread src/amplitude_experiment/remote/client.py Outdated
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)

@cmyui cmyui Jul 25, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternativly, add a new parameter (e.g. fetch_pool_acquire_timeout_millis) to avoid behavior changes to existing clients with their existing configuration

…millis

Default None preserves today's unbounded acquire wait exactly; setting
the new config bounds it, surfacing exhaustion as TimeoutError with the
same retry classification as a socket read timeout. Reworked from
reusing fetch_timeout_millis so the PR carries no default-behavior
change; whether to bound by default is posed to maintainers in the PR
description instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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