Timeouts Everywhere
A call without a timeout does not fail. It waits, and the request that made it waits, and the connection it holds waits, and the buyer looking at a spinner refreshes and makes another one. A timeout turns "lost" into "failed after N milliseconds," and that is the only form of failure a service can reason about: it has a number, it can be logged, it can be mapped to a response, and it can be handed to the idempotency and reconciliation machinery that the rest of this chapter builds. Every call that leaves the process gets one. The request as a whole gets a budget. Each call's timeout is derived from what is left of that budget, not from a constant in a library.
The night of the spring on-sale, Payrail answered in 4 seconds instead of its usual 400 milliseconds, and Stagedoor's Payrail client had no timeout at all. The double charge that Topic 03 of Chapter 1 read slowly began there: a checkout that had been waiting long enough for the buyer's connection to drop and her browser to try again. This topic is the first of the three repairs, and it is the one that makes the other two possible, because a retry policy and an idempotency key both need a moment at which the first attempt is declared over.
The Call That Never Returns
The Payrail client was an httpx.AsyncClient constructed with timeout=None, because a 3-second timeout had once produced a spurious failure during a test and someone removed it. With no timeout, a call ends when the far side answers or closes the socket, and on the incident night Payrail's edge did neither for a minute at a time. The client's connection pool held 20 connections. Within 12 seconds all 20 were occupied by calls that would not return for 60 seconds, and every checkout after that parked on pool acquisition behind them. Buyers refreshed. Each refresh was a new task waiting on the same pool.
12:00:41.207 WARN payrail.pool_acquire_wait waiting=3 in_flight=20 oldest_ms=12040 12:00:52.311 WARN payrail.pool_acquire_wait waiting=41 in_flight=20 oldest_ms=23144 12:01:14.980 WARN payrail.pool_acquire_wait waiting=188 in_flight=20 oldest_ms=45813 12:01:30.004 INFO health status=ok cpu=3% loop_lag_ms=1 db_pool_in_use=0 errors_5xx=0 12:01:41.552 WARN payrail.pool_acquire_wait waiting=402 in_flight=20 oldest_ms=60003
The log shows 20 calls in flight, the oldest of them a minute old, and a queue of tasks waiting for one of those 20 connections that grows from 3 to 402 in a minute. Between those lines is the health check, and it is the point of the exercise: CPU at 3 percent, event-loop lag of 1 millisecond, no database connections in use, zero errors. By every internal measure the instance was healthy and idle. It was idle because every checkout on it was asleep on a socket, and it reported no errors because nothing had failed; nothing had finished either. A timeout of 3 seconds would have produced 20 failures in the first 3 seconds, a 5xx rate the dashboard would have shown, and a pool that freed itself 20 times a minute instead of never.
Every Boundary Gets a Number
The number is different at each boundary because the boundaries are different. Postgres on the same network answers a hold-path statement in 5 milliseconds and a report in 2 seconds, so the statement timeout is per operation: 5 seconds as the default set for the session, 500 milliseconds set with SET LOCAL statement_timeout inside the hold transaction, both under the 30-second ceiling that Chapter 6 put on the application role. Redis answers in under a millisecond, and a Redis call that has taken 100 milliseconds is a Redis that is gone; waiting 3 seconds for it would stall the seat-map path 30 times longer than it takes to learn the truth. Payrail is on the other side of the internet, answers in 400 milliseconds at P50, and gets 3 seconds. The request as a whole gets 10 seconds at the load balancer, and the service is expected to answer before that.
| Boundary | Timeout | Where it is set | Why that number |
|---|---|---|---|
| Postgres, any statement | 5 s | session default for stagedoor_app | the slowest legitimate query is a 2-second report |
| Postgres, hold path | 500 ms | SET LOCAL in the hold transaction | the transaction takes 5 ms; 100 times that is a lock queue, not a slow query |
| Postgres, pool acquire | 5 s | the pool, Chapter 6 | a request that cannot get a connection in 5 s should fail with a 503, not wait |
| Redis, any command | 100 ms | client socket timeout | a normal answer is under 1 ms; 100 ms is the cache being absent |
| Payrail, connect | 1 s | httpx connect | a handshake that takes over a second means nobody is there |
| Payrail, whole call | 3 s | httpx read and the deadline cap | P50 is 400 ms; 3 s is a slow Payrail, 4 s is the incident |
| Client request, whole | 10 s | the load balancer, and the context deadline | the buyer's patience, and the balancer's, end here |
Seven rows, and they live in the configuration of Topic 23 of Chapter 4, in one place, with their reasons beside them. The reasons are the part a new engineer reads. A Redis timeout of 100 milliseconds looks like a typo next to Payrail's 3 seconds until the row says that a cache which takes 100 milliseconds has already failed at its one job. A hold-path statement timeout of 500 milliseconds looks tight until the row says the transaction takes 5 milliseconds and the only thing that can make it take 500 is a queue of other holds on the same row, which Chapter 6's NOWAIT already refused to wait for. Every number in the table is 3 to 100 times the healthy latency, and none of them is a guess.
The Budget That Propagates
The per-call numbers are ceilings. The actual timeout of any given call is the smaller of its ceiling and what remains of the request's budget, and the budget lives in the request context of Topic 21 of Chapter 4 as a deadline: a timestamp set at the edge, 10 seconds after the checkout request arrived. The seat hold spends 130 milliseconds of it. Payrail's usual answer spends 400. When Payrail is slow and the first attempt has used its 3 seconds, the one retry the idempotency key makes safe is a decision made with about 6.9 seconds left, and it gets its full 3 seconds. A third is made with 3.9 seconds left and gets 3. A fourth, if the policy of Topic 38 allowed one, would be made with 870 milliseconds left and would get 870, not 3,000.
The point of the arithmetic is the last row. A call that is given more time than the request has left is a call whose answer nobody will receive: the load balancer gives up at 10 seconds and sends the buyer its own 504, the service keeps talking to Payrail for 3 more seconds, and whatever Payrail says goes to a closed socket. Capping every call by the remaining budget means the service's own 504 leaves before the balancer's, carries a Problem Details body the client can act on, and leaves the order in a state the service knows about. Every outbound client in Stagedoor reads ctx.remaining(); none of them reads its ceiling from configuration alone.
Connect, Read, and the Whole Exchange
A single timeout number hides three questions. Is anyone there: the connect timeout, which covers the TCP handshake and the TLS handshake, and which should be short, because a far side that takes a second to accept a connection is down or unreachable and a further 2 seconds of waiting learns nothing. Are they still sending: the read timeout, which is the longest the client will wait for the next bytes of the response, and which resets every time bytes arrive. And how long can the whole exchange take: the total, which the other two do not bound. A read timeout of 3 seconds on a response that arrives in 200-byte chunks every 2 seconds never fires, and a 6-kilobyte body takes 60 seconds to read through it.
payrail = httpx.AsyncClient(
base_url=settings.payrail_url,
timeout=httpx.Timeout(connect=1.0, read=3.0, write=3.0, pool=1.0), # never None
limits=httpx.Limits(max_connections=20),
)
async def charge(order, ctx):
budget = min(3.0, ctx.remaining()) # the whole exchange, capped by the deadline
async with asyncio.timeout(budget): # total: connect + send + every read together
r = await payrail.post("/v1/charges", json=body,
headers={"Idempotency-Key": f"order-{order.public_id}"})
return parse_charge(r)
The client is built once, at startup, with four numbers that httpx keeps separately: a second to connect, 3 seconds between bytes when reading, 3 seconds to send, and a second to wait for a free connection in its pool of 20. None of them is the total. The total is the asyncio.timeout block around the call, and its number is the smaller of Payrail's 3-second ceiling and the request's remaining budget, so a slow, trickling response is cut off at 3 seconds regardless of how often a byte arrives, and a call made with 400 milliseconds left is cut off at 400. The pool timeout of 1 second is the number that would have ended the incident in its first minute: a task that cannot get one of the 20 connections within a second fails with a pool error the handler can map, instead of joining a queue that grew to 402.
The library default matters, because the incident was a library default. httpx ships with 5 seconds on all four; a client built with timeout=None turns them all off. The older requests library ships with no timeout at all, and a requests.get with no argument waits for as long as the socket lives, which is the classic trap of a whole generation of Python services. Set the numbers explicitly on every client so that the next reader sees a decision rather than a default.
What Timing Out Means
A timeout means unknown. Topic 03 of Chapter 1 drew the four outcomes of one call and the two the caller cannot tell apart, and a timeout is precisely the caller sitting in that ambiguity: Payrail may have never seen the request, or may have charged the card and been writing the response when the 3 seconds ran out. The handler's response is therefore not "failed" and not "try again." It is 504 with the Problem Details type https://stagedoor.example/problems/payment-unresolved, and the order stays pending, with a payments row that records the attempt and no provider_ref. The buyer's page says the payment is being confirmed. The reconciliation job of Chapter 10 asks Payrail tonight whether a charge with idempotency key order-{public_id} exists, and moves the order to paid or failed on Payrail's answer.
The alternative is the double charge, this time committed by the service instead of the browser. A handler that catches the timeout, marks the order failed and tells the buyer to try again has just told a buyer whose card was charged to charge it again, and nothing in the second request knows about the first. Chapter 2 reserved 502, 503 and 504 for the edge and the drain path; this 504 and the breaker's 503 in Topic 40 are the two the service adds on purpose, each with a stable type, so a 5xx from a handler without one of those types is still a bug. Marek's rule from Chapter 1 applies here in its exact form: what if this succeeds and I never hear back? Then the order waits, and reconciliation answers.
Timeouts on the Way In
The same fact runs in the other direction. The service is somebody's far side, and Topic 10 of Chapter 2 gave the edge its numbers: a 60-second idle timeout for a keep-alive connection with nothing on it, 10 seconds for a client to send its headers or its body. What the edge cannot do is stop a handler that has run past the client's patience. If the buyer's browser gives up at 10 seconds and the handler is at 11, still waiting on Payrail, it is doing work whose result will be written to a socket nobody is reading, and holding a connection from the pool and a slot in Payrail's 20 while it does.
The deadline middleware of Topic 21 of Chapter 4 therefore wraps the handler itself in asyncio.timeout with the route's budget. When it expires, the task is cancelled at whatever await it is sitting on: the pooled connection's context manager exits and returns the connection, the Payrail socket is closed, and the handler's response is the 504 above. The server side of a query needs its own guard, because cancelling the client does not by itself stop Postgres executing the statement; that is what statement_timeout in the table is for, and it is why the table has rows on both sides of every boundary. A timeout only on the client's side is a client that has stopped listening to a server that is still working.
- No timeout on the outbound client —
requestshas none by default andtimeout=Noneremoves httpx's, and the result is the incident above: 20 connections asleep for a minute each and 402 tasks queued behind them on an instance that reports itself healthy. - One timeout for every call — 3 seconds on Redis lets a dead cache stall the seat-map path 30 times longer than the 100 milliseconds it takes to know the cache is gone, at 2,600 requests a second.
- A per-call timeout longer than the remaining budget — the balancer answers 504 at 10 seconds, the service keeps talking to Payrail until 13, and the answer is written to a closed socket.
- Treating a timeout as failure — the handler marks the order
failed, the buyer is told to try again, and the retry that follows is the second charge on a card that was charged the first time. - Timeouts only on the client side — the handler is cancelled and its connection returned to the pool, and Postgres keeps executing the 2-second report for a request that nobody is waiting for, because
statement_timeoutwas never set.
- Put a timeout on every call that leaves the process, and keep the numbers in one configuration table with the reason for each beside it.
- Carry a deadline in the request context and cap every outbound timeout by
ctx.remaining(), so no call is given more time than the request has left. - Set connect, read, write and pool separately on the HTTP client, and bound the whole exchange with
asyncio.timeoutfrom the deadline, because a read timeout alone never fires on a slow trickle. - Treat every timeout as "outcome unknown": answer 504 with a stable Problem Details type, leave the order
pending, and route it to the idempotency key and the reconciliation job rather than to a retry. - Guard the server side of every boundary as well, with
statement_timeouton Postgres and a deadline around the handler, so that cancelling a caller also stops the work.
context.WithTimeout, the deadline as a first-class valueJava HttpClient connect timeout plus a per-request timeoutEnvoy route timeouts, the edge's version of the same budgetPostgreSQL statement_timeout, the server side of the boundaryKnowledge Check
The Payrail client has no timeout and Payrail stops answering. What happens to the instance over the next minute?
- The error rate climbs to 100 percent because each stuck call is logged as a failed request
- The event loop saturates and CPU reaches 100 percent from the tasks spinning on the socket
- The client pool fills with calls that never return while the health check stays green
- The load balancer notices the stall and routes all checkouts to the other instance
Why does Redis get a 100-millisecond timeout when Payrail gets 3 seconds?
- Each number is set relative to what a healthy answer from that boundary takes
- Redis calls are retried more often, so each attempt is given a smaller share of the budget
- A longer Redis timeout would exhaust the pool of 20 in the same way Payrail's did
- The request budget is split evenly across the calls a request makes, and Redis makes more
A checkout has 400 milliseconds left on its deadline when the Payrail client is about to make a call whose ceiling is 3 seconds. What timeout does the call get, and why?
- 3 seconds, because the ceiling is a property of the Payrail boundary and does not change from one request to another
- 3 seconds, because each attempt starts a fresh budget of its own from the moment the client decides to make it
- No call at all, because 400 milliseconds is below the 1-second connect timeout and the attempt cannot succeed
- 400 milliseconds, because an answer after the balancer's 10 seconds reaches nobody and the service's 504 must leave first
The charge call to Payrail times out at 3 seconds. What does the handler know about the buyer's card, and what should it do?
- The card was not charged, so the order is marked failed and the buyer is told to try again
- Nothing certain, so the order stays pending and reconciliation asks Payrail tonight
- The card was probably charged, so the order is marked paid and the tickets are sent
- Nothing certain, so the handler calls Payrail again at once with the remaining budget
You got correct