Skip to content

    Forward Deployed Engineer

    Async, HTTP Clients, Timeouts and Retries in Python

    Call a slow upstream from FastAPI with httpx: choose async or threads, set timeouts, retry with backoff and jitter under one deadline, and test it offline.

    By Published 11 min read

    Share

    This lesson is part of Forward Deployed Engineer: Python and FastAPI Foundations. Enroll for free, then sign in, to read it in full.

    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.MockTransport and a fake clock.

    At a glance

    Figure 1. What a handler waits on decides how it must be written

    flowchart LR accTitle: Choosing between async def, def and a worker accDescr: A request arrives at a handler. If the handler waits on a network call made with an async client, write async def and await it, and the event loop serves other requests during the wait. If it waits on a blocking library, write a plain def and FastAPI runs it in a thread pool. If it does heavy computation, neither helps and the work goes to more processes or a separate worker. R["Request arrives"] --> Q{"What does the handler wait on?"} Q -->|"a network call with an async client"| A["async def and await: the event loop serves other requests during the wait"] Q -->|"a blocking library"| B["plain def: FastAPI runs it in a thread pool"] Q -->|"computation: parsing, scoring, maths"| C["Neither helps: more processes or a separate worker"]

    How to read this: async removes idle waiting, not work; the question is always what the handler is waiting for.

    • async def helps only while the code awaits. A blocking call inside it freezes every other request.
    • Use one httpx.AsyncClient per 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.MockTransport and 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, def or a separate worker by asking what the handler waits on.
    • Configure a shared httpx.AsyncClient with 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.MockTransport and a fake clock.

    Prerequisites

    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.

    Retry-After is a number of seconds or an HTTP date. The lesson's code reads the seconds form and ignores the date form.

    Backoff with jitter, written down

    In code: the wait before each retry is random between 0 and a window that doubles each time, up to a cap (full jitter).

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/m1_l4_retry.py

    @dataclass(frozen=True)
    class RetryPolicy:
        max_attempts: int = 3  # the first try plus two retries
        base_s: float = 0.2
        cap_s: float = 2.0
    
        def window(self, retry_index: int) -> float:
            """Upper bound of the wait before retry number `retry_index` (0 is the first retry)."""
            return min(self.cap_s, self.base_s * 2**retry_index)
    
        def backoff(self, retry_index: int, rng: random.Random) -> float:
            """Full jitter: a uniform random wait between 0 and the window."""
            return rng.uniform(0, self.window(retry_index))
    Retry number (from 0) Window Wait is random between
    0 0.2 s 0 and 0.2 s
    1 0.4 s 0 and 0.4 s
    2 0.8 s 0 and 0.8 s
    3 1.6 s 0 and 1.6 s
    4 2.0 s (capped, not 3.2) 0 and 2.0 s

    Without jitter, clients that failed together retry together and hit the recovering server in one spike. A lesson test draws 500 waits per row and checks that all stay inside the window and reach into its top tenth.

    One deadline for the whole call

    A timeout limits one attempt. Three attempts of 1 second plus waits can keep the caller waiting over 3 seconds without any timeout being exceeded. The fix is a deadline: a time budget every attempt and wait draws from.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/m1_l4_retry.py

    @dataclass
    class Deadline:
        """A time budget that every attempt and every wait draws from."""
    
        total_s: float
        clock: Callable[[], float] = time.monotonic
        started: float = field(init=False)
    
        def __post_init__(self) -> None:
            self.started = self.clock()
    
        def remaining(self) -> float:
            return max(0.0, self.total_s - (self.clock() - self.started))

    The clock argument lets tests pass a fake clock and never wait, as the capstone's WorkflowRunner does.

    Figure 2. One call with a 3.0 second deadline and a 1.0 second attempt timeout (illustrative)

    sequenceDiagram accTitle: Timeouts and retries under one deadline accDescr: The Service starts a call with a deadline of three seconds. Attempt one to the Upstream gets a 503. The Service waits a random time up to 0.2 seconds. Attempt two gets no answer and times out after one second. The Service waits a random time up to 0.4 seconds. Attempt three has only the time left as its timeout. If the Upstream still fails, the Service stops and tells the Caller instead of waiting longer. autonumber participant C as Caller participant S as Service participant U as Upstream C->>S: GET stock, deadline 3.0 s S->>U: attempt 1, timeout 1.0 s U-->>S: 503 Note over S: wait random 0 to 0.2 s S->>U: attempt 2, timeout 1.0 s U--xS: no answer, ReadTimeout Note over S: wait random 0 to 0.4 s S->>U: attempt 3, timeout = time left U-->>S: 200 or failure S-->>C: answer, or 502 or 504 before the deadline

    How to read this: each attempt asks the deadline how long it may take, and a wait that would pass the deadline is never started.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/m1_l4_retry.py

        last = "no attempt made"
        for attempt in range(policy.max_attempts):
            left = deadline.remaining()
            if left <= 0:
                raise DeadlineExceeded(f"no time left before attempt {attempt + 1}")
            hinted: float | None = None
            try:
                response = await client.get(url, timeout=min(attempt_timeout_s, left))
                if response.status_code not in RETRYABLE_STATUS:
                    return response
                last = f"status {response.status_code}"
                hinted = retry_after_seconds(response)
            except httpx.TransportError as exc:  # timeouts and connection failures
                last = type(exc).__name__
            if attempt == policy.max_attempts - 1:
                break
            wait = policy.backoff(attempt, rng) + (hinted or 0.0)  # Retry-After is a floor, jitter goes on top
            if wait >= deadline.remaining():
                raise DeadlineExceeded(f"a wait of {wait:.2f}s would pass the deadline (last: {last})")
            await sleep(wait)
        raise RetriesExhausted(f"{policy.max_attempts} attempts failed (last: {last})")

    Note three details: no wait after the last attempt; the attempt timeout shrinks to the time left; and two errors, RetriesExhausted (upstream failing, answer 502) and DeadlineExceeded (budget gone, answer 504).

    Retries stack, and the load multiplies

    If the browser, your service and the SDK under it all retry, the layers multiply. Set layers to 3 and attempts to 3 in the widget.

    Retries across the caller, our service and the SDK (illustrative) (interactive when JavaScript is on)
    Attempts reaching the bottom layer per top-level call27
    Load at the bottom for 4 calls per second108 per second

    The widget's start values give 27 attempts per call, 108 per second at 4 calls per second. Give retries to one layer and switch the others off: the reliability lesson calls this "one retry owner".

    Worked example

    Lumen's stock call uses the default policy (3 attempts, base 0.2 s, cap 2.0 s), a 1.0 s attempt timeout, and a 3.0 s deadline. The upstream hangs on every attempt. Waits take the full window, which is the worst case.

    Step Starts at Takes Ends at
    Attempt 1 (timeout 1.0 s) 0.0 s 1.0 s 1.0 s
    Wait (window 0.2 s) 1.0 s 0.2 s 1.2 s
    Attempt 2 (timeout 1.0 s) 1.2 s 1.0 s 2.2 s
    Wait (window 0.4 s) 2.2 s 0.4 s 2.6 s
    Attempt 3 (timeout min(1.0, 0.4 left) = 0.4 s) 2.6 s 0.4 s 3.0 s

    Assumptions: all values illustrative; the upstream never answers; jitter draws the longest wait each time.

    • Without the deadline the same policy can take 3 x 1.0 + 0.2 + 0.4 = 3.6 seconds, which the method worst_case_s returns.
    • With the 3.0 s deadline the third attempt is cut to 0.4 s.
    • If the upstream answers 503 with Retry-After: 30 and the deadline is 5 s, the code raises DeadlineExceeded at once: a 30 second wait could never fit.

    At the customer: "What will the engineer see when the ERP is down?" A good answer: an honest "stock unavailable, last checked at ..." within the deadline, never a spinner that ends in a timeout. Agree the deadline users tolerate, then fit attempt timeouts inside it.

    Build it

    Everything runs offline. From capstone/fde-product-discovery-copilot:

    ./.venv/Scripts/python -m pytest lessons/M1-L4

    No test opens a socket. httpx.MockTransport takes a handler that returns a response or raises an httpx exception. This test makes attempt 1 time out and attempt 2 succeed:

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L4/test_m1_l4_retry.py

    def test_a_timeout_raised_by_the_transport_is_retried() -> None:
        state = {"n": 0}
    
        def flaky(request: httpx.Request) -> httpx.Response:
            state["n"] += 1
            if state["n"] == 1:
                raise httpx.ReadTimeout("slow upstream", request=request)
            return httpx.Response(200, json={"qty": 1})
    
        go, calls, _ = run(flaky)
        assert asyncio.run(go()).status_code == 200 and len(calls) == 2
    1. Run the suite and read the test names: they are the specification.
    2. In m1_l4_retry.py drop 502 from RETRYABLE_STATUS and see which tests fail.
    3. In m1_l4_gateway.py follow how RetriesExhausted becomes 502 and DeadlineExceeded 504.

    Exercise

    Decision exercise. Lumen's ERP has two endpoints. GET /stock/{sku} is read-only. POST /orders creates an order and accepts an optional Idempotency-Key header. One call in five times out. Choose a policy and justify it:

    1. Retry every call up to 5 times, immediately.
    2. Retry GET up to 3 times with backoff and jitter; retry POST only with an idempotency key; both under a deadline.
    3. Never retry; fail fast.
    Model answer

    Option 2. Immediate retries multiply load on an overloaded server and can duplicate orders. Fail-fast turns a one-in-five blip into a visible error. Choose option 3 for POST /orders if the ERP has no idempotency support: a duplicate order costs more than an error message. Reconsider if the ERP returns 429 for a quota limit, which waiting does not clear.

    The task. Write a test (no new production code) proving a call never outlasts its deadline when the upstream never answers.

    Acceptance criteria:

    • The transport handler advances a fake clock by the attempt's timeout, then raises httpx.ReadTimeout.
    • The test is parametrized over at least four deadlines (for example 0.5, 1.0, 3.0 and 10.0 seconds) and several random seeds.
    • It asserts that the fake clock never ends after the deadline.

    Done when pytest passes for every deadline and seed, and fails if you change min(attempt_timeout_s, left) in get_with_retries to attempt_timeout_s.

    Hint 1: how does a handler know the timeout?

    request.extensions["timeout"] is a dict with the keys connect, read, write and pool. The test test_each_attempt_gets_the_per_attempt_timeout reads it.

    Hint 2: how does the fake clock move?

    One small class with a now value: __call__ returns now (the Deadline clock) and async sleep adds to it (the sleep argument). The handler adds the read timeout to now before raising. Reference solution: lessons/M1-L4/test_m1_l4_solution_deadline.py.

    Common mistakes

    • A new client per request. The pool is never used. Create one client at start-up.
    • time.sleep or a blocking library inside async def. It freezes every request. Use await asyncio.sleep, an async client or plain def.
    • Timeouts without a deadline. Three 1-second attempts and their waits outlast the caller. Budget the whole call.
    • Retrying non-idempotent requests blindly. A retried POST can create two orders. Send an idempotency key or do not retry.

    Knowledge check

    1. A blocking database driver inside `async def` stalls the service. Why, and what are two fixes?

    The blocking call never yields to the event loop, so no other request runs until it returns. Make the handler a plain def, use asyncio.to_thread, or use a driver that supports await.

    2. Why is the httpx read timeout not enough to bound the total time of a call?

    It limits the wait for the next chunk, not the whole response, and each retry gets a fresh timeout. A deadline that every attempt and wait draws from bounds the whole call.

    3. Which would you retry: 404, 503 with Retry-After: 2, a ReadTimeout on a GET, a ReadTimeout on a POST without an idempotency key?

    Retry the 503 (wait at least 2 seconds plus jitter) and the GET timeout. Do not retry the 404 (the request is wrong) or the POST: RFC 9110 says a client SHOULD NOT automatically retry non-idempotent methods, because the first attempt may have succeeded.

    4. A caller times out at 3 seconds; your service allows 3 attempts of 2 seconds. What is wrong?

    The service keeps working after its caller has given up, and the caller sees a timeout instead of your error. Give the service a shorter deadline, for example 2.5 seconds, so it answers first.

    5. Three layers each make 3 attempts. How many requests reach the upstream per call, and what changes?

    27 (3 x 3 x 3), because each outer attempt triggers all attempts below it. Choose one layer to own retries and set the others to one attempt.

    You can now

    • Decide between async def, def and a worker from what the handler waits on.
    • Share one httpx.AsyncClient and name the four timeouts.
    • Write a retry loop with backoff, jitter, Retry-After and a deadline.
    • Say which requests are safe to retry.
    • Test retries offline with MockTransport and a fake clock.

    Further reading

    Last verified: 2026-11-02

    Next

    Errors, configuration and logging

    Enroll to keep reading

    Lesson 7 of 9 · Forward Deployed Engineer: Python and FastAPI Foundations

    Call a slow upstream from FastAPI with httpx: choose async or threads, set timeouts, retry with backoff and jitter under one deadline, and test it offline.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Python and FastAPI Foundations, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments