What you'll learn: how to decide what runs on the async event loop, in a thread or in a separate process, how to call another service with one shared httpx client, how to set per-attempt timeouts and one deadline for the whole call, how to retry with exponential backoff and jitter without making an outage worse, and how to test all of it offline with
httpx.MockTransportand a fake clock.
At a glance
Figure 1. What a handler waits on decides how it must be written
How to read this: async removes idle waiting, not work; the question is always what the handler is waiting for.
async defhelps only while the code awaits. A blocking call inside it freezes every other request.- Use one
httpx.AsyncClientper process, with a timeout on every call. - A timeout limits one attempt; a deadline limits the whole call.
- Retry only failures that can pass (timeouts, 429, 5xx) and only requests that are safe to repeat.
- Add jitter to every wait; when a server sends
Retry-After, wait at least that long. - Test with
httpx.MockTransportand a fake clock. A retry test never sleeps for real.
Scenario
Composite scenario, not a real company. Lumen Industrial Supply's product assistant shows live stock next to every recommended product. Stock comes from an ERP system behind a slow API: usually quick, sometimes seconds, and about one call in twenty fails with a 503. All numbers here are illustrative. After four seconds a sales engineer calls the warehouse instead. The stock call must be fast when it can be, honest when it cannot, and never the reason the assistant hangs.
What you will be able to do
- Pick
async def,defor a separate worker by asking what the handler waits on. - Configure a shared
httpx.AsyncClientwith per-attempt timeouts. - Write a retry loop with backoff, full jitter and
Retry-After, bounded by one deadline. - Test retries and timeouts offline with
httpx.MockTransportand a fake clock.
Prerequisites
- FastAPI: an AI-ready service for routes, models and the app object.
- Reliability and scaling for the theory this lesson turns into code (failure taxonomy, one retry owner, circuit breakers).
Core ideas
Async is about waiting, not about speed
Predict first. One server process runs an async def handler whose body calls time.sleep(1) (a blocking wait). Ten requests arrive at once. About how long until the tenth caller gets an answer?
Answer
About ten seconds. time.sleep never hands control back to the single event loop thread, so requests run one at a time. As a plain def, FastAPI would run each call in a thread pool and the waits would overlap.
Python's asyncio works like Node: one thread, switching only at an await. The Node.js guide says a blocked thread "cannot handle requests from any other clients". Spring MVC assumes applications can block the current thread and absorbs that with a large thread pool.
| Question | Spring MVC | Node.js | FastAPI |
|---|---|---|---|
| Who runs your handler | a servlet container thread from a large pool | the single event loop | async def: the event loop. def: a thread pool |
| What a blocking call costs | one pool thread while it waits | every client stalls | in async def, every client stalls. In def, one pool thread |
| Slow computation | occupies one pool thread | offload it (computation pool or child process) | another process or worker |
FastAPI's rule: if a library supports await, use async def and await it; if not, use plain def; if unsure, use def. The capstone's /discovery endpoint is a plain def because its pipeline calls a provider through a blocking interface.
Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/test_m1_l4_retry.py
def test_a_task_that_never_awaits_blocks_every_other_task() -> None:
log: list[str] = []
async def cpu_bound() -> None:
log.append("cpu start")
sum(i * i for i in range(10_000)) # no await: nothing else can run until this returns
log.append("cpu end")
async def ticker() -> None:
for _ in range(3):
log.append("tick")
await asyncio.sleep(0)
async def main() -> None:
await asyncio.gather(cpu_bound(), ticker())
asyncio.run(main())
assert log[:2] == ["cpu start", "cpu end"] and log[2:] == ["tick"] * 3
The ticker gets no turn until the computation returns: async overlaps waits, it does not speed up a calculation. If blocking code is unavoidable inside async def, asyncio.to_thread runs it in another thread. For heavy computation, use more processes or a worker.
In the interview: "Your endpoint got slower after you made it async. Why?" A good answer names the blocked loop (a blocking call or long computation inside async def), then the fixes in order: an async client, plain def, to_thread, a separate process.
One shared client and four timeouts
Create the client once at start-up and close it at shutdown. The httpx documentation explains why: a client pools and reuses connections, while a new client per request opens a new one every time.
Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/m1_l4_gateway.py
def create_gateway(upstream: str, transport: httpx.AsyncBaseTransport | None = None, budget_s: float = 2.0) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# One client for the whole process, so connections are pooled and reused.
app.state.client = httpx.AsyncClient(base_url=upstream, transport=transport)
yield
await app.state.client.aclose()
app = FastAPI(lifespan=lifespan)
The transport argument is the test seam: production passes nothing, tests pass httpx.MockTransport.
httpx has four timeouts, and the default is to raise TimeoutException after 5 seconds of network inactivity.
| Timeout | It limits | Exception |
|---|---|---|
| connect | opening the connection | ConnectTimeout |
| read | waiting for the next chunk of data | ReadTimeout |
| write | sending data | WriteTimeout |
| pool | getting a connection from the pool | PoolTimeout |
The read timeout does not limit the whole response: a server that sends one byte every 4 seconds never trips it, so you need a deadline on top. These exceptions and ConnectError share the base class httpx.TransportError, so one except covers them. A 503 is not an exception unless you call raise_for_status().
Which failures to retry
| Outcome | Retry? | Why |
|---|---|---|
| Timeout, connection error | yes, if safe to repeat | the next attempt may reach a healthy server |
| 429, 503 | yes, honour Retry-After |
the server asked you to come back |
| 500, 502, 504 | yes, few attempts | often transient; a bug fails identically each time |
| 400, 401, 403, 404, 422 | no | the request is wrong and fails again |
RFC 9110 says PUT, DELETE and the safe methods (GET, HEAD, OPTIONS, TRACE) are idempotent: repeating them has the same intended effect. A client SHOULD NOT automatically retry a non-idempotent method unless it can know the request is idempotent or detect that the original was never applied. A POST that creates an order is the classic case: retry it only with an idempotency key, as the capstone's /discovery endpoint supports.