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; eachclient.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) raisesRuntimeError;awaitis 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, sozipstill 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 semwraps only the request. The slot is released the moment the response lands — beforeappend— 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?
2. With return_exceptions=True, gather is given 200 tasks and 3 of them raise. What does it return?
3. Model generation typically takes 40 seconds. Which timeout budget must cover that?
4. What does wrapping each request in `async with asyncio.Semaphore(8)` guarantee?
Flashcards
Question
Why must timeouts be set per phase instead of one number?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.