Choosing Default Timeouts for Library Code¶
Most code that talks to a network never sets a timeout; it inherits whatever the library chose. For a library author, that default is the timeout most users will run in production, so it deserves the same care as the API. Measured on Python 3.14 against a local server that accepted TCP connections and then never sent a byte, with each client used exactly as its documentation's first example: httpx raised ReadTimeout after 5.06 s and websockets raised TimeoutError after 10.01 s. aiohttp, asyncpg.connect and a plain asyncio.open_connection read were all still waiting when the test stopped them at 12 s — their defaults are 300 s total, 60 s, and none. redis-py's asyncio client has a 5-second socket timeout, but retried it: its ping() raised TimeoutError only after 58.81 s. This guide sets library defaults that are finite, explainable and overridable — and keeps retries from multiplying them.
Prerequisites¶
- Python 3.11+ asyncio.
- Deadline propagation, from propagating deadlines across async service calls.
- The topic overview, Timeouts & Deadlines.
1. Measure what your users actually get¶
Run each client against a server that accepts and never responds — the failure mode of an overloaded or wedged dependency — with no timeout configuration at all:
async def silent(reader, writer):
await asyncio.sleep(3600) # accept, then say nothing
server = await asyncio.start_server(silent, "127.0.0.1", 58990)
async with asyncio.timeout(12): # the test's own cap
async with httpx.AsyncClient() as client:
await client.get("http://127.0.0.1:58990/")
Measured: httpx's default Timeout(5.0) applies to connect, read, write and pool waits, and it raised ReadTimeout at 5.06 s. websockets' default open_timeout of 10 s ended the handshake at 10.01 s. aiohttp's default ClientTimeout(total=300, sock_connect=30) has no read limit below five minutes. asyncpg.connect defaults to a 60-second connect timeout and command_timeout=None. An asyncio.open_connection stream has no timeout at all. Each of these is a reasonable choice for someone; none of them tells the user what will happen until it happens.
Verify: for each client your code uses, the effective timeout against a silent server is known, not assumed.
2. Make every default finite and phase-specific¶
A library default should never be "wait forever", and it should name the phase it bounds. httpx's design is a useful model: one object, separate fields for connect, read, write and pool, a finite default for each:
@dataclass(frozen=True)
class Timeouts:
connect: float = 5.0 # TCP + TLS handshake
read: float = 30.0 # between bytes of a response, not the whole response
write: float = 30.0 # waiting for the peer to accept data
pool: float = 5.0 # waiting for a free connection
total: float | None = None # optional overall ceiling, off by default
class Client:
def __init__(self, *, timeouts: Timeouts = Timeouts()):
self.timeouts = timeouts
Separate phases matter because their healthy ranges differ by orders of magnitude: a connect over a local network takes milliseconds, a large download can legitimately take minutes. A single total timeout either cuts off large legitimate transfers or allows a stuck connect to wait minutes — aiohttp's default 300-second total is the second case. Bound writes too; a peer that stops reading otherwise blocks forever, as measured in timing out writes to slow clients.
Verify: every network phase in the library has a finite default, documented in one place.
3. Keep retries from multiplying the default¶
A per-attempt timeout times the number of attempts is the real worst case. redis-py's socket_timeout defaults to 5 seconds, which sounds bounded, but its asyncio client also retries by default; against the silent server, ping() raised TimeoutError only after 58.81 s. Users who read "5 s" plan for 5 s:
class Client:
def __init__(self, *, timeouts=Timeouts(), retries: int = 2, total_budget: float = 15.0):
self.timeouts, self.retries, self.total_budget = timeouts, retries, total_budget
async def request(self, op):
async with asyncio.timeout(self.total_budget): # caps attempts x timeouts
for attempt in range(self.retries + 1):
try:
return await op(self.timeouts)
except (ConnectionError, TimeoutError):
if attempt == self.retries:
raise
await asyncio.sleep(0.1 * 2 ** attempt)
Document the worst case — attempts times per-attempt timeout plus backoff, or the total budget, whichever is smaller — next to the defaults. When the library is used inside a caller's deadline, the caller's asyncio.timeout still wins, as in making retry loops cancellation-safe.
Verify: the documented worst-case time for one call matches a measurement against a silent server.
4. Respect the caller's deadline¶
A library default is a fallback for callers who did not think about time. Callers who did should be able to pass a deadline in, and the library should never extend it:
async def fetch(self, path: str, *, timeout: float | None = None):
budget = self.timeouts.total if timeout is None else timeout
if budget is None:
return await self._fetch(path)
async with asyncio.timeout(budget):
return await self._fetch(path)
Because asyncio.timeout nests — an inner timeout cannot outlast an outer one — a caller wrapping the call in its own deadline gets that deadline even if the library's default is longer. Libraries should not catch TimeoutError raised by an outer deadline and retry it: inside the library, an outer timeout arrives as CancelledError, which must pass through untouched, as distinguished in telling TimeoutError apart from CancelledError.
Verify: a test calling the library under a shorter outer asyncio.timeout ends at the outer deadline.
5. Choose the numbers¶
There is no universally right default, but there are defensible ones. A starting point for a client library talking to services on the same network:
DEFAULTS = Timeouts(
connect=5.0, # well above a healthy handshake, well below a user's patience
read=30.0, # per read, so long downloads still work
write=30.0,
pool=5.0, # waiting for a pooled connection usually means overload
)
Raise read limits for libraries whose normal responses are slow — report generation, long polling — and lower connect limits for latency-sensitive services. Whatever you choose, state it in the documentation's first example, raise a distinct exception type per phase so users can tell a connect failure from a slow response, and test the defaults against a silent server in CI. For deriving values from observed latency rather than from judgement, see deriving timeouts from latency percentiles.
Verify: the library's tests include a silent-server test for each phase, asserting the documented default within a small tolerance.
Verification¶
Library timeout defaults are sound when:
- Every network phase has a finite default, measured against a silent server.
- Retries are capped by a total budget, so the worst case is what the documentation says.
- Callers' deadlines override the defaults and are never extended or retried.
- Each phase raises a distinct, documented exception.
Diagnostic Hook: when a service hangs for about a minute on a dependency that the configuration says has a 5-second timeout, look for retries around the timeout. redis-py's 5-second socket timeout became 58.81 seconds before its ping() failed against a silent server here.
Pitfalls & edge cases¶
- "No timeout" defaults. Measured: asyncio streams still waiting at 12 s, indefinitely in practice.
- One large total timeout. aiohttp's default allows a stuck request 300 s.
- Retries multiplying timeouts. Measured: 5 s became 58.81 s.
- Libraries retrying the caller's deadline. The outer deadline must end the call.
Frequently Asked Questions¶
What is httpx's default timeout?
5 seconds for each of connect, read, write and pool. Against a silent server it raised ReadTimeout after 5.06 s.
Does aiohttp have a default timeout?
A total of 300 s with sock_connect 30 s and no read limit. Against a silent server it was still waiting when the test stopped at 12 s.
Why did redis-py take a minute to time out?
Its 5 s socket timeout is retried by default; ping() against a silent server raised TimeoutError after 58.81 s. Lower retries or wrap calls in asyncio.timeout.
What default timeouts should an async client library use?
Finite, per phase and documented, such as 5 s connect, 30 s read and write, 5 s pool wait, with retries capped by a total budget and the caller's deadline always respected.
Related¶
- Timeouts & Deadlines — up to the topic overview.
- Timing out database queries — the layers a database client needs.
- Resilience, Cancellation & Error Handling — the section overview.