โฑ๏ธ Pattern 5: Timeouts & Retries

Handle network failures gracefully. Bounds, backoff, and partial-failure handling.

The Problem: Network is Unreliable

Reality: Network calls fail. Services time out, return 500s, hang indefinitely, or flake intermittently.

Naive code: Just await client.get(url) and hope. If it hangs, you wait forever. If one fails, the whole batch fails.

Production code: Timeout each call, retry with backoff, and handle partial failures (some succeed, some fail, you report both).

Three Layers of Resilience

  1. Timeout: Don't wait forever. asyncio.timeout(5) cancels if it runs too long.
  2. Retry: Try again with exponential backoff (0.5s, 1s, 2s, ...). Services often recover quickly.
  3. Partial failure: gather(..., return_exceptions=True) so one failure doesn't sink the batch.

Visualization: Timeout & Retry Loop

flowchart TB START(["Request"]) --> A1["Attempt 1
asyncio.timeout(5s)"] A1 -->|"timeout โŒ"| B1["wait 0.5s (backoff)"] B1 --> A2["Attempt 2
asyncio.timeout(5s)"] A2 -->|"500 error โŒ"| B2["wait 1.0s (backoff)"] B2 --> A3["Attempt 3
asyncio.timeout(5s)"] A3 -->|"success โœ…"| OK(["Return response"]) A3 -->|"still failing"| GIVEUP["raise โ€” give up
after max retries"] A1 -.success.-> OK A2 -.success.-> OK style OK fill:#dcfce7,stroke:#16a34a,color:#15803d style GIVEUP fill:#fee2e2,stroke:#dc2626,color:#991b1b style B1 fill:#fef3c7,stroke:#d97706 style B2 fill:#fef3c7,stroke:#d97706
Backoff doubles each time (0.5s โ†’ 1s โ†’ 2s โ€ฆ). Bounded waits beat both extremes: never hang forever, never fail on the first blip.

The Complete Code

Single Request with Timeout & Retry

import asyncio
import httpx

async def fetch_with_retry(client, url, max_retries=3):
    for attempt in range(1, max_retries + 1):
        try:
            # asyncio.timeout cancels the call if it runs too long
            async with asyncio.timeout(5):
                response = await client.get(url)
                response.raise_for_status()
                return response.json()
        except (asyncio.TimeoutError, httpx.HTTPError) as e:
            if attempt == max_retries:
                raise  # last attempt, give up
            # Exponential backoff: 0.5s, 1s, 2s, 4s...
            backoff = 0.5 * (2 ** (attempt - 1))
            print(f"Attempt {attempt} failed ({e}); retrying in {backoff}s")
            await asyncio.sleep(backoff)

async def main():
    async with httpx.AsyncClient() as client:
        result = await fetch_with_retry(client, "https://api.example.com/data")
        print(result)

asyncio.run(main())

Batch with Partial Failures

async def main():
    urls = ["https://api.example.com/user/1", ...]

    async with httpx.AsyncClient() as client:
        tasks = [fetch_with_retry(client, url) for url in urls]

        # return_exceptions=True โ†’ failures are returned as exception objects
        # So one failure doesn't cancel the rest
        results = await asyncio.gather(*tasks, return_exceptions=True)

    # Process results
    for url, result in zip(urls, results):
        if isinstance(result, Exception):
            print(f"FAILED {url}: {result}")
        else:
            print(f"SUCCESS {url}: {result}")

Key Methods

asyncio.timeout(seconds)

Context manager. Cancels the block if it runs longer than `seconds`. Raises asyncio.TimeoutError.

gather(..., return_exceptions=True)

One failure doesn't cancel others. Failed tasks return exception objects in the results list.