Skip to content
beginner Phase 2 · Python for AI Engineering

Async HTTP with Python

Call LLM APIs concurrently using asyncio, httpx, and proper timeout handling.

50m
0 problems
Topic Progress 0%

The Batch That Took 41 Minutes

The nightly digest job pushes 200 chunks through a summarization endpoint. Each call takes about 12 seconds — generation is slow — and the job ran sequentially: request, wait, request, wait. Two hundred times twelve is forty minutes of a worker idling between responses, every night. The fix is not a faster model; it is overlapping the waits.

The sequential baseline

import httpx, os, time

URL = "https://api.openai.com/v1/chat/completions"
HEADERS = {"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}
prompts = [f"Summarize chunk {i}" for i in range(200)]

def payload(p: str) -> dict:
    return {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": p}]}

def summarize_all() -> list[str]:
    out = []
    with httpx.Client(timeout=60.0) as client:
        for p in prompts:
            r = client.post(URL, headers=HEADERS, json=payload(p))
            out.append(r.json()["choices"][0]["message"]["content"])
    return out

start = time.perf_counter()
summarize_all()
print(f"{time.perf_counter() - start:.1f}s")

Output: 2398.6s — forty minutes, nearly all of it waiting on a socket.

Overlap the waits

import asyncio, httpx, os, time

async def summarize_all() -> list[str]:
    async with httpx.AsyncClient(timeout=60.0) as client:
        tasks = [
            client.post(URL, headers=HEADERS, json=payload(p))
            for p in prompts
        ]
        responses = await asyncio.gather(*tasks)
    return [r.json()["choices"][0]["message"]["content"] for r in responses]

start = time.perf_counter()
results = asyncio.run(summarize_all())
print(f"{time.perf_counter() - start:.1f}s", len(results))

Output: 14.2s 200

Four lines do the real work:

  • AsyncClient(...) — pooling lives on the client. A client per request would renegotiate TLS two hundred times and erase most of the gain.
  • tasks = [... for p in prompts] — building the list sends nothing yet; each client.post(...) produces an awaitable that is still cold.
  • await asyncio.gather(*tasks) — starts all of them on one event loop. While one response streams in, the loop yields to others; the idle waits are what actually overlap.
  • asyncio.run(...) — owns the event loop for the whole script. Calling it inside a running loop (Jupyter, an async framework) raises RuntimeError; await is the form to use there.

Wall time fell from 2398.6s to 14.2s because two hundred network waits now overlap instead of queueing. Speed introduced two problems of its own — nothing bounds the burst, and a single hung connection can stall the batch — and the httpx version this runs on is pinned in Environments and Dependency Hygiene. The payload shapes being indexed come from Python Data Structures for API Work.

Timeouts and Partial Failures

The 14.2-second run above hides two failures that show up the first time a provider has a bad minute. One hung TCP connection parks its task indefinitely, and gather propagates the first exception while the other 199 results — summaries you paid for — are never collected.

Give each phase of the request its own budget

httpx ships a flat 5-second default applied to connect, read, write, and pool alike. Five seconds is fine for a handshake and fatal for generation, which routinely runs longer. Specify them separately:

import httpx

timeout = httpx.Timeout(
    connect=5.0,    # TCP + TLS handshake: fail fast, the address is wrong or down
    read=60.0,      # generation: this is the budget the model actually needs
    write=10.0,     # uploading the prompt
    pool=5.0,       # waiting for a pooled connection slot
)

async with httpx.AsyncClient(timeout=timeout) as client:
    ...

A read that exceeds its budget raises httpx.ReadTimeout. That is one failed request, not a reason to abort the batch.

Keep the batch alive when one request dies

responses = await asyncio.gather(*tasks, return_exceptions=True)

ok, failed = [], []
for prompt, r in zip(prompts, responses):
    if isinstance(r, Exception):
        failed.append((prompt, type(r).__name__))
    else:
        ok.append(r.json()["choices"][0]["message"]["content"])

print(f"ok={len(ok)} failed={len(failed)}")
print(failed[:2])

Output:

ok=197 failed=3
[('Summarize chunk 88', 'httpx.ReadTimeout'), ('Summarize chunk 151', 'httpx.ConnectTimeout')]
  • return_exceptions=True — exceptions become values in the results list, at the position of their task, so zip still pairs each prompt with its outcome. Without it, one failure discards a batch that already succeeded.
  • isinstance(r, Exception) — the list is heterogeneous now: payloads and exceptions side by side. Type-check before indexing into a response.

Handling follows the classification, not the habit:

Failure Cause Treatment
ConnectTimeout network or endpoint down retry with jittered backoff
ReadTimeout generation exceeded its budget raise read, retry once
HTTP 429 rate or quota limit honor retry-after, retry later
HTTP 401 missing or revoked key fix configuration, never retry

A timeout without a budget is an outage discovered late. The connect/read split decides whether you learn about a dead endpoint in five seconds or discover a stalled generation sixty seconds in.

Bounded Concurrency with a Semaphore

Firing all 200 requests at once worked in the demo and returned eight 429s in production. Providers meter requests per minute and tokens per minute; a burst over either limit gets throttled, and every rejected request is work your pipeline must schedule again. Concurrency needs a ceiling.

Cap what is in flight

import asyncio, httpx

TIMEOUT = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)

async def summarize_all(limit: int = 8) -> tuple[list[str], list]:
    sem = asyncio.Semaphore(limit)
    ok, errors = [], []

    async def one(client: httpx.AsyncClient, p: str) -> None:
        async with sem:                     # only `limit` tasks hold this at once
            try:
                r = await client.post(URL, headers=HEADERS, json=payload(p))
                r.raise_for_status()
                ok.append(r.json()["choices"][0]["message"]["content"])
            except httpx.HTTPError as exc:
                errors.append((p, type(exc).__name__))

    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        await asyncio.gather(*(one(client, p) for p in prompts))

    return ok, errors

ok, errors = asyncio.run(summarize_all())
print(len(ok), len(errors))

Output: 200 0

Two details carry the design:

  • async with sem wraps only the request. The slot is released the moment the response lands — before append — so the queue keeps moving instead of waiting on bookkeeping.
  • The semaphore is created outside the per-task coroutine: one instance for the whole batch. Declaring it inside one() would give every task its own private permit and bound nothing.

What the ceiling costs and buys

Measured on the same 200-chunk batch:

In-flight limit Wall time 429 responses
200 (unbounded) 14.2s 8
32 19.6s 0
8 46.1s 0
1 2398.6s 0

The flow:

200 prompts
     │
     ▼
┌────────────────┐  acquire   ┌─────────────────┐
│ asyncio.gather │───────────▶│  Semaphore(8)   │
│ task factory   │◀───────────│  8 permits      │
└────────────────┘  release   └────────┬────────┘
                                       │ 8 requests in flight
                                       ▼
                             provider API (rate quota)
                                       │
                                       ▼
                                ok[] / errors[]

Semaphore(8) is the throughput knob: raise it until the 429 column stops being zero, then stop. Throughput that ignores the quota line trades a thirty-second saving for a retry storm — and the retry policy that handles those responses when they do appear is Rate Limiting and Retries.

Quiz

1. Why is one AsyncClient reused across the whole batch instead of creating a client per request?

Question 1 options

2. With return_exceptions=True, gather is given 200 tasks and 3 of them raise. What does it return?

Question 2 options

3. Model generation typically takes 40 seconds. Which timeout budget must cover that?

Question 3 options

4. What does wrapping each request in `async with asyncio.Semaphore(8)` guarantee?

Question 4 options

Flashcards

Question

Why must timeouts be set per phase instead of one number?

Answer

httpx applies a flat default to connect, read, write, and pool alike. A dead endpoint should fail in ~5s (connect) while generation gets 60s (read) — one number cannot serve both.

Question

What does gather(..., return_exceptions=True) change?

Answer

Exceptions become values in the results list at their task's position, so one failure no longer discards the batch and each prompt still maps to its outcome.

Question

Why one AsyncClient for the whole batch?

Answer

The client owns connection pooling; reusing it means TLS and TCP setup happen once, and per-request clients would re-handshake 200 times.

Question

How do you choose the semaphore limit?

Answer

Raise it until 429 responses stop appearing, then stop. Measured: unbounded gave 8 rejections at 14.2s; limit 8 gave 0 rejections at 46.1s.

Question

ReadTimeout versus 429 — what is the difference in handling?

Answer

ReadTimeout means generation exceeded its budget: raise the read limit or retry once. A 429 means a quota: honor retry-after and retry later. Different causes, different fixes.

Revision Notes

Key Takeaways

  • 1. Overlapping waits, not faster code, is the speedup: 200 sequential 12s calls drop from 40 minutes to seconds when gathered
  • 2. Set timeouts per phase — httpx's flat default is too short for generation and too generous for a handshake
  • 3. return_exceptions=True plus a type check keeps one failed request from throwing away a completed batch
  • 4. A semaphore bounds burst concurrency; throughput that ignores the quota line trades seconds saved for a retry storm

Interview Tips

  • • Walk the failure classification on a whiteboard: ConnectTimeout retries with backoff, ReadTimeout raises its budget, 429 honors retry-after, 401 fixes config and never retries
  • • Explain why building task coroutines sends nothing yet — awaiting gather is what schedules them — interviewers probe whether you understand the event loop rather than the syntax
  • • Be ready to draw the semaphore flow: tasks queue, eight hold permits, requests hit the provider, permits release on response
  • • If asked about throughput, quote the tradeoff with numbers and say the ceiling is set by the quota, not by the machine

Cheat Sheet

Async HTTP with Python — Cheat Sheet

Working shape of a bounded batch

TIMEOUT = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)

async def run(prompts: list[str], limit: int = 8) -> tuple[list[str], list]:
    sem = asyncio.Semaphore(limit)          # one instance for the whole batch
    ok, errors = [], []

    async def one(client: httpx.AsyncClient, p: str) -> None:
        async with sem:                     # ceiling on in-flight requests
            try:
                r = await client.post(URL, headers=HEADERS, json=payload(p))
                r.raise_for_status()
                ok.append(r.json()["choices"][0]["message"]["content"])
            except httpx.HTTPError as exc:
                errors.append((p, type(exc).__name__))

    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        await asyncio.gather(*(one(client, p) for p in prompts))
    return ok, errors

Timeouts: httpx's flat default (5s on every phase) is too short for generation. connect short and failing fast, read equal to the generation budget.

Partial failure: gather(..., return_exceptions=True) puts exceptions into the results list at their task's position — type-check before indexing.

Failure Action
ConnectTimeout retry with jittered backoff
ReadTimeout raise the read budget, retry once
HTTP 429 honor retry-after, retry later
HTTP 401 fix configuration, never retry

Throughput knob: raise the semaphore limit until the 429 count stops being zero, then stop. Measured: unbounded → 14.2s with 8 rejections; limit 8 → 46.1s with none.

Rules: one AsyncClient per process (pooled connections), asyncio.run() once per script (never inside a running loop), requests overlap only while awaited tasks are pending.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading