AI EngineeringZero to ProductionHome·About·Contact
Appendix · Advanced AI Engineering · Part 3

High-Throughput Async & Streaming Pipelines

LLM work is I/O-bound — you spend almost all your time waiting for the API. asyncio lets a single thread juggle thousands of in-flight requests while they wait, and stream tokens back as they arrive. This part builds async from the event loop up, then applies it to the two things AI systems need most: massively concurrent calls and token streaming.

⏱️ ~2 hours🎯 Advanced → Expert⚡ throughputrunnable

Learning objectives

  • Explain the event loop and how await yields control while waiting.
  • Run many coroutines concurrently with gather and TaskGroup.
  • Bound concurrency with a semaphore to respect rate limits (backpressure).
  • Apply timeouts and handle cancellation cleanly.
  • Consume a streaming LLM response with async for.
  • Build an async producer/consumer pipeline with a queue.

Why async for AI systems motivation

Say each API call takes 2 seconds. Evaluating 500 golden cases sequentially is ~17 minutes; with async concurrency (respecting rate limits) it's under a minute. Async also powers streaming — showing tokens as the model produces them, which transforms perceived latency in a chat UI. Same CPU, dramatically better throughput and UX, because the thread does useful work instead of blocking on the network.

async vs threads (recap from A2)Both handle I/O concurrency. Threads are simpler to bolt onto existing sync code; async scales higher (thousands of concurrent tasks in one thread, no per-thread stack) and gives explicit control over cancellation, timeouts, and streaming. For a service making huge numbers of LLM calls, async is the standard.

1 · The event loop — the core idea intermediate

An event loop runs one task until it hits an await on something not-yet-ready (a network response). At that point the task yields, and the loop runs other ready tasks. When the awaited result arrives, the loop resumes the first task. One thread, no blocking — cooperative multitasking.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the shape of an async program
pythonimport asyncio

async def main():           # a coroutine function
    print("start")
    await asyncio.sleep(1)   # yields control for 1s instead of blocking
    print("done")

asyncio.run(main())          # create loop, run main() to completion, close loop
▶ How this works

This is the smallest complete async program — the shape every later example follows. async code doesn't run by itself; something has to start the event loop (the scheduler that runs your tasks and decides who waits and who runs). asyncio.run(...) is that starter.

  1. import asyncio brings in Python's built-in async toolkit — the event loop, sleep, gather, and the rest.
  2. async def main(): defines a coroutine function. The word async is what makes it special: it is allowed to contain await, and calling it hands back a coroutine to run later rather than running now.
  3. await asyncio.sleep(1) is the key line. Instead of freezing the program for a second, await yields control back to the event loop, which is free to run other tasks meanwhile. When the second is up, the loop resumes right here.
  4. asyncio.run(main()) creates the event loop, runs main() until it finishes, then closes the loop. This is the one place a normal (non-async) program crosses into async.

What the output means: Prints start, waits about one second, then prints done. In a real program that one second of waiting is where dozens of other tasks could be making progress.

Try this: Change asyncio.sleep(1) to time.sleep(1) (after import time). It still works here, but time.sleep blocks the whole loop — the mistake the next warning box is about.

The cardinal rule of asyncNever call a blocking function inside a coroutine — time.sleep(), requests.get(), heavy CPU loops — it freezes the entire event loop and every other task with it. Use async equivalents (asyncio.sleep, httpx/aiohttp, the async SDK) or push blocking/CPU work to a thread/process pool with asyncio.to_thread(...).

2 · Coroutines & await intermediate → advanced

An async def function returns a coroutine — it doesn't run until awaited or scheduled as a task. await means "pause here until this finishes, letting others run." Calling a coroutine without awaiting it does nothing (a classic bug).

Try it — mock async LLM call
pythonimport asyncio

async def complete(prompt):
    await asyncio.sleep(0.5)          # pretend network latency
    return f"answer to: {prompt}"

async def main():
    result = await complete("hello")   # await = run & get the value
    print(result)
    coro = complete("oops")             # NOT awaited -> never runs (warning)
    coro.close()

asyncio.run(main())
▶ How this works

This shows the difference between defining async work, awaiting it, and the classic beginner bug: creating a coroutine but forgetting to await it, so it never runs.

  1. async def complete(prompt): is a fake LLM call. The await asyncio.sleep(0.5) stands in for the half-second you'd really spend waiting on the network.
  2. result = await complete("hello") is the normal path: await runs the coroutine, pauses main while it waits, and hands back the returned string.
  3. coro = complete("oops") only creates a coroutine object — it does not run. Without an await in front, the work inside never happens. Python would warn "coroutine was never awaited"; coro.close() just tidies it up to silence that warning.

What the output means: Prints answer to: hello. The "oops" call produces nothing because it was never awaited — that silence is the bug.

Try this: Delete the coro.close() line and run it — Python prints a RuntimeWarning: coroutine 'complete' was never awaited. Recognising that warning saves hours later.

3 · Running things concurrently — gather & TaskGroup advanced

The payoff: launch many coroutines and await them together. asyncio.gather collects results in order; asyncio.TaskGroup (Python 3.11+) is the modern, safer form with structured concurrency — if one task fails, siblings are cancelled and errors propagate cleanly.

Try it — 100 calls concurrently
pythonimport asyncio, time

async def complete(i):
    await asyncio.sleep(1)
    return i * 2

async def main():
    # gather: all at once, results in submission order
    results = await asyncio.gather(*[complete(i) for i in range(100)])
    print(len(results))              # 100, in ~1 second (not 100s)

    # TaskGroup (3.11+): structured — cleaner errors & cancellation
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(complete(i)) for i in range(100)]
    results = [t.result() for t in tasks]   # all done when the block exits

asyncio.run(main())
▶ How this works

Here is the payoff of async: running many calls at the same time. One hundred calls that each wait one second finish in about one second total, not one hundred, because all the waiting overlaps. Two ways to do it are shown.

  1. [complete(i) for i in range(100)] builds a list of 100 coroutines (still not running yet). The * in gather(*[...]) spreads that list out as separate arguments.
  2. await asyncio.gather(...) launches all 100 at once and waits for every one to finish, returning their results as a list in the order you submitted them — handy when you need to line results back up with inputs.
  3. async with asyncio.TaskGroup() as tg: is the newer (Python 3.11+) way. tg.create_task(...) schedules each call; when the async with block exits, all tasks are guaranteed done. If one fails, the group cancels the rest and reports the error cleanly — safer than gather for production.
  4. [t.result() for t in tasks] then pulls each finished task's return value.

What the output means: len(results) is 100, and it appears after roughly one second — proof the calls overlapped instead of running one after another.

Try this: Wrap one complete call so it raises an error, and compare: gather surfaces just that one exception, while TaskGroup cancels the siblings and bundles the errors. That difference is why TaskGroup is preferred.

🔗 Used in the courseBatch evaluation (Ch 5) and any multi-document RAG fan-out become minutes-to-seconds with gather/TaskGroup. The Anthropic SDK ships an AsyncAnthropic client whose calls you await exactly like complete above.

4 · Backpressure — bounding concurrency with a semaphore expert intermediate

Firing 10,000 requests at once will blow past API rate limits and exhaust memory. A semaphore caps how many run simultaneously — the essential "backpressure" control. This is the single most important production async pattern.

Try it — cap at N concurrent calls
pythonimport asyncio

async def bounded_map(coro_fn, items, limit=10):
    sem = asyncio.Semaphore(limit)        # at most `limit` inside at once
    async def worker(item):
        async with sem:                   # acquire; blocks if `limit` are running
            return await coro_fn(item)
    return await asyncio.gather(*[worker(i) for i in items])

async def embed(text):
    await asyncio.sleep(0.2); return len(text)

# 1000 items but never more than 10 in flight -> respects rate limits
# asyncio.run(bounded_map(embed, ["doc"] * 1000, limit=10))
▶ How this works

Launching 1000 calls at once (as in §3) would blow past the API's rate limit and your memory. A semaphore is a counter that only lets limit tasks run at a time; the rest wait their turn. This "backpressure" is the single most important production async pattern.

  1. sem = asyncio.Semaphore(limit) creates a gate that allows limit holders (default 10) inside simultaneously.
  2. async with sem: is how a worker acquires a slot. If limit workers already hold the gate, this line pauses until one of them exits the block and frees a slot — automatically throttling everything.
  3. return await coro_fn(item) does the real work only while inside the gate, so no more than limit real calls are ever in flight.
  4. return await asyncio.gather(*[worker(i) for i in items]) still submits all the items — but because each goes through the semaphore, only limit run at once.

What the output means: Nothing prints (the run line is commented out), but the design guarantees: 1000 items submitted, never more than 10 hitting the API at any instant.

Try this: Change limit to 1 and it becomes fully sequential; raise it and throughput climbs — until you hit the provider's rate limit. Tuning that number is real production work.

Combine with a rate limiterA semaphore caps concurrency (how many at once); a token-bucket rate limiter caps rate (how many per second). Production clients use both — plus retry with backoff on 429s (see A6 and the rate limiter in P6). Together they keep you under the provider's limits without manual tuning.

5 · Timeouts & cancellation advanced

A hung request must not hang your whole system. asyncio.timeout (3.11+) or wait_for bounds how long you'll wait, cancelling the task if it overruns. Cancellation raises CancelledError inside the task so it can clean up.

Try it — bound a slow call
pythonimport asyncio

async def slow():
    await asyncio.sleep(10)
    return "done"

async def main():
    try:
        async with asyncio.timeout(2):      # give it 2 seconds
            await slow()
    except TimeoutError:
        print("gave up after 2s")          # task is cancelled for us

asyncio.run(main())
▶ How this works

A single request that hangs forever must not freeze your whole service. A timeout puts a ceiling on how long you'll wait; if the work overruns, async cancels it for you.

  1. async def slow(): simulates a stuck call by sleeping ten seconds.
  2. async with asyncio.timeout(2): (Python 3.11+) starts a two-second clock around everything inside the block. If the block isn't done in time, the task is cancelled.
  3. except TimeoutError: catches that expiry so you can react — here it just prints a message. Without the try/except, the timeout would bubble up as an error.

What the output means: After about two seconds (not ten), it prints gave up after 2s — the slow() call was cancelled the moment the clock ran out.

Try this: Lower asyncio.sleep(10) to 1 so the work finishes before the 2-second limit — now the except never fires. That's how you confirm the timeout only triggers on genuine overruns.

6 · Streaming LLM tokens with async for advanced

Streaming turns a long wait into a live feel: the model sends tokens as it generates them, and you render each as it arrives. An async generator (async def + yield) models the stream; you consume it with async for.

Try it — a mock token stream
pythonimport asyncio

async def stream_completion(prompt):        # async generator
    for tok in ["The", " answer", " is", " 42."]:
        await asyncio.sleep(0.1)             # each token arrives over the wire
        yield tok

async def main():
    parts = []
    async for token in stream_completion("q"):   # consume as it streams
        print(token, end="", flush=True)         # render immediately
        parts.append(token)
    return "".join(parts)

asyncio.run(main())
▶ How this works

Streaming makes a slow answer feel fast: instead of waiting for the whole reply, you show each piece (token) the instant the model produces it. An async generator models the stream, and async for consumes it.

  1. async def stream_completion(prompt): plus yield tok makes an async generator — a coroutine that hands back values one at a time instead of returning once. Each await asyncio.sleep(0.1) mimics the delay between tokens on the network.
  2. async for token in stream_completion("q"): is the streaming loop: it pulls the next token as soon as it's ready and runs the loop body immediately — no waiting for the full message.
  3. print(token, end="", flush=True) renders each token right away. flush=True forces it to the screen instantly so the user sees text appear live; parts.append(token) also collects them to rebuild the full answer at the end.

What the output means: You see The answer is 42. appear a word at a time rather than all at once — exactly the typing effect of a chat UI.

Try this: Raise the sleep to 0.5 to exaggerate the streaming, then set it to 0 so it prints instantly — same code, different perceived speed. Real Anthropic SDK streaming uses this identical async for shape.

🔗 Used in the courseThis is exactly the streaming loop over the Anthropic SDK's async stream from Ch 1 — real code iterates stream events and accumulates text deltas. The skill guidance also recommends streaming for any long/high-max_tokens request to avoid request timeouts. Same async for shape.

7 · Async producer/consumer pipeline expert intermediate

For continuous work — ingesting a document stream, processing a task feed — an asyncio.Queue decouples producers from consumers, giving natural backpressure (a bounded queue makes fast producers wait for slow consumers).

Try it — bounded queue pipeline
pythonimport asyncio

async def producer(q, n):
    for i in range(n):
        await q.put(i)               # blocks if queue is full -> backpressure
    for _ in range(3):
        await q.put(None)            # one sentinel per consumer to stop it

async def consumer(q, out):
    while True:
        item = await q.get()
        if item is None:
            break
        await asyncio.sleep(0.05)      # process (e.g. embed) the item
        out.append(item * 2)

async def main():
    q = asyncio.Queue(maxsize=10)      # bounded = backpressure
    out = []
    async with asyncio.TaskGroup() as tg:
        tg.create_task(producer(q, 50))
        for _ in range(3):
            tg.create_task(consumer(q, out))   # 3 workers drain concurrently
    print(len(out))                  # 50

asyncio.run(main())
▶ How this works

For never-ending work (a feed of documents or tasks), a queue lets fast producers and slow consumers run independently. A bounded queue also gives free backpressure: if consumers fall behind, producers are forced to wait.

  1. async def producer(q, n): puts n items on the queue with await q.put(i). If the queue is full, put pauses until a consumer removes something — that's the backpressure.
  2. After the real items, it puts three None values — one sentinel ('stop now') for each of the three consumers, so every worker knows when to quit.
  3. async def consumer(q, out): loops forever with await q.get(), breaks when it sees the None sentinel, and otherwise 'processes' the item (the sleep stands in for real work like embedding).
  4. q = asyncio.Queue(maxsize=10) bounds the queue. The TaskGroup runs one producer and three consumers together; the block exits only when all are finished.

What the output means: Prints 50 — every one of the 50 produced items was drained and processed by the three consumers working in parallel.

Try this: Set maxsize=1 and watch throughput drop — the producer now stalls constantly. Then forget to send enough sentinels (range(2) instead of 3) and one consumer hangs forever waiting for a stop signal.

Exercises advanced

Practice
  1. Rewrite the A2 thread-pool fetch as async with gather; then add a semaphore capping it at 5.
  2. Add a per-call 3-second asyncio.timeout and collect which items timed out vs succeeded.
  3. Make bounded_map return results paired with their input, tolerating individual failures (return exceptions).
  4. Turn the mock token stream into one that also yields a final "usage" summary event at the end.
  5. Add a second stage to the queue pipeline (embed → index) so items flow through two consumer pools.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

Make 1000 API calls without hitting rate limits

Cap concurrency with a semaphore; gather results. The canonical async fan-out.

pythonimport asyncio
async def call(x): await asyncio.sleep(0.2); return x*2
async def bounded(items, limit=10):
    sem = asyncio.Semaphore(limit)
    async def w(x):
        async with sem: return await call(x)
    return await asyncio.gather(*[w(i) for i in items])
▶ How this works

The classic interview question: make 1000 API calls without hitting rate limits. The expected answer is this exact pattern — a semaphore to cap concurrency, then gather to collect results. It's §3 and §4 compressed into the shape interviewers look for.

  1. call(x) is the stand-in API call (waits, then returns x*2).
  2. sem = asyncio.Semaphore(limit) caps how many calls run at once; the inner w(x) wraps each call in async with sem: so it must claim a slot first.
  3. await asyncio.gather(*[w(i) for i in items]) submits every item but lets only limit proceed at a time — safe fan-out over a huge list.

Try this: Say the two knobs out loud in an interview: the semaphore bounds concurrency, and you'd pair it with retry-and-backoff on HTTP 429 to fully respect rate limits. Naming both is what earns the check-mark.

Consume a token stream with async for

Render tokens as they arrive; accumulate for the final text.

pythonimport asyncio

async def stream(prompt):
    for tok in ["The", " answer", " is", " 42."]:
        await asyncio.sleep(0.05); yield tok
async def main():
    parts = []
    async for t in stream("q"):
        print(t, end="", flush=True); parts.append(t)
    return "".join(parts)
▶ How this works

The streaming interview question: consume a token stream and render it live. This is §6 boiled down — an async generator plus an async for loop.

  1. async def stream(prompt): with yield tok is the async generator producing tokens with a tiny delay each.
  2. async for t in stream("q"): pulls each token the moment it's ready and prints it with flush=True so it shows immediately.
  3. parts.append(t) then "".join(parts) reassembles the full answer, so you get both the live typing effect and the complete final string.

Try this: The interview point to make: streaming improves perceived latency (first token appears fast) even though total time is the same — and the consuming code is just an async for loop.

Checkpoint advanced

  • Explain the event loop and why blocking calls are forbidden in coroutines.
  • Run many coroutines with gather/TaskGroup and know the difference.
  • Bound concurrency with a semaphore and add timeouts + cancellation.
  • Consume a token stream with async for and build a backpressured queue pipeline.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Await two coroutines concurrentlyBeginner

Context: asyncio.gather schedules coroutines to run concurrently on the event loop, so total wall time is the slowest task, not the sum — the foundation of every async fan-out.

Your task: Use asyncio.gather to run two asyncio.sleep-based coroutines concurrently and show total time is the max, not the sum.

Requirements:

  • Define two coroutines that each await asyncio.sleep for different durations
  • Run them concurrently with asyncio.gather
  • Measure wall time and show it's ~the longer sleep, not the sum
  • Collect both results

💡 Hint: Concurrency here means the two sleeps overlap on the loop, so elapsed time tracks the longest one.

Show solution

gather schedules coroutines to run concurrently on the event loop; total wall time is the slowest, not the sum.

import asyncio, time

async def task(name, secs):
    await asyncio.sleep(secs)
    return f"{name} done"

async def main():
    t = time.perf_counter()
    res = await asyncio.gather(task("a", 0.2), task("b", 0.3))
    print(res, "in %.2fs" % (time.perf_counter()-t))   # ~0.30s, not 0.50

asyncio.run(main())
Exercise 2 · Bound concurrency with a semaphoreIntermediate

Context: A semaphore caps how many coroutines are in the critical section at once — the standard backpressure knob for ‘don't hit the API with 10k requests.’

Your task: Run 10 tasks but allow at most 3 in flight using asyncio.Semaphore, and print the peak concurrency observed.

Requirements:

  • Launch 10 tasks concurrently
  • Guard the in-flight section with an asyncio.Semaphore(3)
  • Track and print the peak simultaneous count
  • Observed peak concurrency is 3, never more

💡 Hint: Acquire the semaphore around the work and record the max simultaneous holders — it should never exceed the limit.

Show solution

A semaphore caps how many coroutines are inside the critical section at once — the standard backpressure knob for ‘don't hit the API with 10k requests’.

import asyncio

async def worker(i, sem, state):
    async with sem:
        state["cur"] += 1
        state["peak"] = max(state["peak"], state["cur"])
        await asyncio.sleep(0.05)
        state["cur"] -= 1
        return i

async def main():
    sem = asyncio.Semaphore(3)
    state = {"cur": 0, "peak": 0}
    await asyncio.gather(*(worker(i, sem, state) for i in range(10)))
    print("peak concurrency:", state["peak"])   # 3

asyncio.run(main())
Exercise 3 · Timeout + cancellationAdvanced

Context: asyncio.wait_for cancels the awaited coroutine when the deadline passes, and the coroutine sees CancelledError at its next await point — the basis of timeouts and clean cancellation.

Your task: Wrap a slow coroutine in asyncio.wait_for with a timeout, handle the TimeoutError, and prove the coroutine was cancelled.

Requirements:

  • Wrap a slow coroutine in asyncio.wait_for with a short timeout
  • Catch the resulting asyncio.TimeoutError
  • The slow coroutine observes CancelledError at its await point
  • Prove cancellation actually happened (e.g. a flag set in the except branch)

💡 Hint: The cancelled coroutine can catch CancelledError to record that it was stopped — then it should re-raise, not swallow it.

Show solution

wait_for cancels the awaited coroutine when the deadline passes; the coroutine sees CancelledError at its next await point.

import asyncio

cancelled = {"hit": False}

async def slow():
    try:
        await asyncio.sleep(1.0)
        return "finished"
    except asyncio.CancelledError:
        cancelled["hit"] = True
        raise

async def main():
    try:
        await asyncio.wait_for(slow(), timeout=0.1)
    except asyncio.TimeoutError:
        print("timed out; cancelled =", cancelled["hit"])   # True

asyncio.run(main())
Exercise 4 · Stream tokens with an async generatorExpert

Context: An LLM token stream is an async generator; async for consumes each token as it arrives so you can render before generation finishes — the pattern for low time-to-first-token UX.

Your task: Model an LLM token stream: write an async generator yielding tokens, consume it with async for accumulating the text, and show tokens arrive incrementally.

Requirements:

  • Write an async generator that yields tokens one at a time (an await between yields simulates the network)
  • Consume it with async for
  • Show each token is received incrementally, not all at once
  • Accumulate the tokens into the final text

💡 Hint: Yielding with an await between tokens mimics network deltas — the consumer sees them stream in one at a time.

Show solution

Streaming is an async generator; async for consumes each token as it arrives so you can render before generation finishes — the pattern for low time-to-first-token UX.

import asyncio

async def stream_tokens(prompt):
    # real client yields deltas from the network; here we simulate
    for tok in ["The", " GIL", " serial", "izes", " bytecode", "."]:
        await asyncio.sleep(0.02)
        yield tok

async def main():
    out = []
    async for tok in stream_tokens("explain the GIL"):
        out.append(tok)
        print("recv:", repr(tok))     # arrives one at a time
    print("final:", "".join(out))

asyncio.run(main())
Exercise 5 · Async producer/consumer with a bounded queueProfessional

Context: A bounded asyncio.Queue makes put block when full, naturally throttling a fast producer to consumer speed — and a sentinel signals clean shutdown instead of hanging.

Your task: Build a producer/consumer pipeline with asyncio.Queue(maxsize=...) so a fast producer can't outrun slow consumers, using a sentinel to shut down cleanly.

Requirements:

  • Use a bounded queue so put blocks when full (backpressure)
  • Run a producer and multiple consumers concurrently
  • Use a sentinel value to signal completion so consumers exit rather than hang
  • Ensure every consumer can see the shutdown signal
  • Collect the expected results

💡 Hint: The bounded queue throttles the producer; re-enqueueing the sentinel lets sibling consumers each observe the shutdown.

Show solution

A bounded queue makes put block when full, so the producer is naturally throttled to consumer speed. Sentinels signal completion so consumers exit instead of hanging.

import asyncio

async def producer(q, n):
    for i in range(n):
        await q.put(i)          # blocks when queue is full -> backpressure
    await q.put(None)           # sentinel

async def consumer(q, out):
    while True:
        item = await q.get()
        if item is None:
            await q.put(None)   # let siblings see the sentinel too
            return
        await asyncio.sleep(0.01)
        out.append(item * item)

async def main():
    q = asyncio.Queue(maxsize=2)
    out = []
    await asyncio.gather(producer(q, 6), consumer(q, out), consumer(q, out))
    print(sorted(out))          # [0, 1, 4, 9, 16, 25]

asyncio.run(main())
Exercise 6 · Resilient concurrent LLM fan-outIndustry scenario

Context: At scale, a batch's job is to degrade gracefully: bound concurrency, time out stragglers, retry transients with backoff, and return partial results so one bad call doesn't sink the batch — the shape of every production LLM fan-out.

Your task: Design the function an agent uses to call an LLM over 1000 inputs with bounded concurrency, per-call timeout, retries on failure, and partial-result collection.

Requirements:

  • A semaphore bounds in-flight calls (protects the provider and your rate limit)
  • Each call has its own timeout
  • Failures are retried a few times with backoff, then recorded rather than raised
  • Results come back per input as (input, ok, value) so a few failures don't lose the successes
  • Label the actual network call (needs an API key to run for real)
  • State the lesson: degrade gracefully — bound, time out, retry, return partials

💡 Hint: Isolate each call so its failure is captured as a result, not an exception that aborts the whole gather.

Show solution

Design: a semaphore caps in-flight calls (protect the provider + your rate limit); each call has its own timeout; failures are retried a few times then recorded as an error rather than raising; results come back as (input, ok, value) so a few failures don't lose the 996 successes.

import asyncio, random

async def call_llm(text):
    # real: await client.messages.create(...)  -- needs an API key to run
    await asyncio.sleep(0.01)
    if random.random() < 0.1:
        raise RuntimeError("transient 503")
    return text.upper()

async def one(text, sem, retries=3, timeout=2.0):
    async with sem:                       # bound concurrency
        for attempt in range(retries):
            try:
                val = await asyncio.wait_for(call_llm(text), timeout)
                return (text, True, val)
            except (asyncio.TimeoutError, RuntimeError):
                await asyncio.sleep(0.02 * 2**attempt)   # backoff
        return (text, False, "failed after retries")

async def fan_out(inputs, concurrency=50):
    sem = asyncio.Semaphore(concurrency)
    return await asyncio.gather(*(one(t, sem) for t in inputs))

async def main():
    res = await fan_out([f"row{i}" for i in range(20)], concurrency=5)
    ok = sum(1 for _, good, _ in res if good)
    print(f"{ok}/{len(res)} succeeded; failures isolated")

asyncio.run(main())

Lesson: at scale the batch's job is to degrade gracefully — bound concurrency to stay under limits, time out stragglers, retry transients with backoff, and return partial results so the caller decides what to do with the few failures.

Knowledge check check yourself

✓ Knowledge check

The lesson calls bounding concurrency with a semaphore "the single most important production async pattern." What failure does it prevent that a plain asyncio.gather over 10,000 items would cause?

Show answer
gather launches everything at once, blowing past the provider's rate limits and exhausting memory; a semaphore caps how many coroutines run simultaneously (backpressure), so only N calls are ever in flight while the rest wait their turn.
✓ Knowledge check

The "cardinal rule" forbids calling blocking functions like time.sleep() or requests.get() inside a coroutine. Why is that so damaging in an event loop?

Show answer
A blocking call freezes the single event-loop thread, so every other in-flight task stalls with it; you must use async equivalents (asyncio.sleep, an async HTTP client) or offload the blocking/CPU work with asyncio.to_thread.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in