How exceptions behave in asyncio, how cancellation and timeouts work, and how to handle multiple concurrent
failures with ExceptionGroup and except* (3.11+). Examples use LLM fan-out, streaming and embedding jobs.
Contents
- 1. Where exceptions go in asyncio
- 2. Cancellation
- 3. Timeouts
- 4. TaskGroup
- 5. ExceptionGroup and except*
- 6. gather, wait, as_completed
- 7. Bounded fan-out with partial failure
- 8. Async generators and streaming
- 9. Background tasks and unobserved exceptions
- 10. Threads and processes
- 11. Shutdown and signals
- 12. 3.9/3.10 compatibility
1. Where exceptions go in asyncio
| Construct | Exception surfaces |
|---|---|
await coro() | raised at the await, like a normal call |
task = asyncio.create_task(coro()) | stored in the task; raised when you await task or call task.result() |
| task never awaited | logged as "Task exception was never retrieved" at garbage collection; otherwise lost |
asyncio.gather(a, b) | first exception propagates; others keep running (not cancelled) |
asyncio.gather(..., return_exceptions=True) | exceptions returned as list values |
asyncio.TaskGroup (3.11) | first failure cancels siblings; all failures raised together as ExceptionGroup |
loop.run_in_executor / asyncio.to_thread | raised at the await |
callbacks (call_soon, add_done_callback) | passed to the loop's exception handler (logged) |
2. Cancellation
task.cancel() raises asyncio.CancelledError at the task's current await. Since 3.8 it derives from
BaseException, so except Exception does not catch it. Sources of cancellation: explicit cancel(),
asyncio.timeout()/wait_for, a failing sibling in a TaskGroup, client disconnect in ASGI servers, and
shutdown of asyncio.run().
Rules:
- Catch
CancelledErroronly to clean up; alwaysraiseat the end. - Prefer
finally/async withfor cleanup; they run on cancellation too. - Never
awaitlong operations in cleanup without a bound; a second cancel can interrupt it. Shield short critical writes if required. - Do not convert
CancelledErrorinto another exception or a return value.
async def generate_and_store(prompt: str, conv_id: str) -> str:
parts = []
try:
async for delta in llm.stream(prompt):
parts.append(delta)
except asyncio.CancelledError:
# user hit "stop": persist the partial answer, then let cancellation continue
await asyncio.shield(store.save_partial(conv_id, "".join(parts)))
raise
text = "".join(parts)
await store.save(conv_id, text)
return textasyncio.shield(aw) protects the inner awaitable from being cancelled, but the outer await still raises
CancelledError, which is exactly what we want here.
Uncancel semantics (3.11+): structured primitives (TaskGroup, asyncio.timeout) use task.cancelling() and
task.uncancel() to tell their own cancellations from external ones. If you catch CancelledError and
deliberately suppress it (rare, e.g. a supervisor), call asyncio.current_task().uncancel() so enclosing
timeouts keep working.
3. Timeouts
# 3.11+
async def chat_with_deadline(messages: list, seconds: float = 30.0) -> str:
try:
async with asyncio.timeout(seconds):
return await llm.chat(messages)
except TimeoutError as err:
raise ProviderTimeoutError(f"no response in {seconds}s", provider="openai", timeout=seconds) from err| API | Version | Raises | Notes |
|---|---|---|---|
asyncio.timeout(s) | 3.11 | TimeoutError | context manager; asyncio.timeout_at(when); reschedulable via cm.reschedule() |
asyncio.wait_for(aw, s) | all | asyncio.TimeoutError (= TimeoutError in 3.11+) | cancels the inner task and waits for it |
asyncio.wait(tasks, timeout=s) | all | nothing | returns (done, pending); you cancel pending yourself |
httpx.AsyncClient(timeout=httpx.Timeout(10, connect=3)) | - | httpx.ConnectTimeout, ReadTimeout, ... | prefer client-level timeouts for I/O |
SDK timeout= (openai/anthropic) | - | APITimeoutError | SDKs also retry internally (max_retries) |
Layer timeouts: per-attempt timeout (client) < per-call deadline including retries (asyncio.timeout) <
request timeout (server). Streaming needs an idle timeout between chunks too:
async def stream_with_idle_timeout(agen, idle: float = 15.0):
it = agen.__aiter__()
while True:
try:
chunk = await asyncio.wait_for(it.__anext__(), idle)
except StopAsyncIteration:
return
except asyncio.TimeoutError as err:
raise StreamInterruptedError(f"no tokens for {idle}s", provider="x") from err
yield chunk4. TaskGroup
async def embed_corpus(batches: list) -> list:
async with asyncio.TaskGroup() as tg: # 3.11+
tasks = [tg.create_task(embedder.embed(b)) for b in batches]
return [vec for t in tasks for vec in t.result()]Semantics:
- When any task raises (non-
CancelledError), the group cancels all remaining tasks and the body. - After all tasks finish, the group raises
ExceptionGroupcontaining every non-cancellation failure. - Even a single failure arrives wrapped:
ExceptionGroup("unhandled errors in a TaskGroup", [err]). KeyboardInterrupt/SystemExitin a child still cancel the siblings, but are then re-raised as themselves instead of a group.
TaskGroup is all-or-nothing. For partial success, catch inside each task:
async def embed_one_batch(i: int, batch: list, results: dict, failures: dict) -> None:
try:
results[i] = await embedder.embed(batch)
except ProviderError as err: # expected per-batch failure: record, do not cancel siblings
failures[i] = err
async def embed_tolerant(batches: list):
results, failures = {}, {}
async with asyncio.TaskGroup() as tg:
for i, b in enumerate(batches):
tg.create_task(embed_one_batch(i, b, results, failures))
return results, failures5. ExceptionGroup and except*
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(search_vectors(query))
tg.create_task(search_keywords(query))
tg.create_task(fetch_user_profile(user_id))
except* VectorStoreError as eg:
logger.warning("vector search failed: %s", [str(e) for e in eg.exceptions])
raise RetrievalError("retrieval degraded") from eg
except* (TimeoutError, ProviderTimeoutError) as eg:
raise ProviderTimeoutError(f"{len(eg.exceptions)} lookups timed out", provider="mixed") from eg| Behaviour | Detail |
|---|---|
| Matching | each except* clause receives the sub-group of matching leaves (eg is always a group) |
| Multiple clauses | every matching clause runs (unlike except, which runs at most one) |
| Unmatched leaves | re-raised automatically as a group after all clauses run |
| Naked exceptions | except* ValueError also catches a bare ValueError, wrapped into a group |
| Forbidden | except* ExceptionGroup, mixing except and except* in one try, break/continue/return inside except* |
| Raising inside | a new exception raised in an except* clause is chained and grouped with the unhandled rest |
Working with groups directly:
eg = ExceptionGroup("batch failed", [RateLimitError("429", provider="a"), ValueError("bad chunk")])
rate_limited, rest = eg.split(RateLimitError) # (matching group or None, rest or None)
only_values = eg.subgroup(ValueError) # matching group or None
def iter_leaves(group):
"""Flatten nested exception groups into leaf exceptions."""
for exc in group.exceptions:
if isinstance(exc, BaseExceptionGroup):
yield from iter_leaves(exc)
else:
yield exc
leaves = list(iter_leaves(eg))split/subgroup also accept a predicate: eg.split(lambda e: getattr(e, "retryable", False)).
Subclass ExceptionGroup and override derive() if your group carries extra attributes, so split keeps them.
Raise your own group when you collect independent failures (validation of many items, closing many clients):
def validate_tools(tools: list) -> None:
errors = []
for t in tools:
try:
validate_tool_schema(t)
except ValueError as err:
err.add_note(f"tool={t.get('name')!r}")
errors.append(err)
if errors:
raise ExceptionGroup(f"{len(errors)} invalid tool schemas", errors)6. gather, wait, as_completed
results = await asyncio.gather(*(classify(doc) for doc in docs), return_exceptions=True)
ok = [r for r in results if not isinstance(r, BaseException)]
failed = [(d, r) for d, r in zip(docs, results) if isinstance(r, BaseException)]
for doc, err in failed:
logger.warning("classify failed for %s", doc.id, exc_info=err)| Tool | On failure | Pick when |
|---|---|---|
TaskGroup | cancel siblings, raise group | all results required |
gather() | raise first, others keep running unobserved | legacy code; prefer TaskGroup |
gather(return_exceptions=True) | exceptions as values (also CancelledError!) | partial success |
wait(FIRST_EXCEPTION) | returns early with done/pending | custom policies |
as_completed() | each await raises its task's error | stream results as they finish |
With return_exceptions=True, check isinstance(r, BaseException) (not Exception), otherwise a cancelled
child's CancelledError is treated as a result.
7. Bounded fan-out with partial failure
async def summarise_all(docs: list, concurrency: int = 8) -> dict:
sem = asyncio.Semaphore(concurrency)
results: dict = {}
errors: dict = {}
async def one(doc) -> None:
async with sem:
try:
results[doc.id] = await with_retry(lambda: llm.summarise(doc.text))
except (ProviderError, StructuredOutputError) as err:
errors[doc.id] = err
async with asyncio.TaskGroup() as tg:
for d in docs:
tg.create_task(one(d))
if errors and not results:
raise ExceptionGroup("every summary failed", list(errors.values()))
return {"results": results, "failed": {k: repr(v) for k, v in errors.items()}}Expected, per-item errors are caught inside the task; unexpected ones (bugs) still cancel the group and surface.
8. Async generators and streaming
- An exception raised inside the generator propagates to the consumer's
async for. - If the consumer stops early (
break, exception, cancellation), the generator receivesGeneratorExitat its currentyieldwhen closed. Do notyieldin response; just clean up. - Async generators are closed by the event loop's asyncgen hooks later unless you close them; use
contextlib.aclosing()(3.10) to close deterministically.
from contextlib import aclosing
async def first_sentence(prompt: str) -> str:
buf = ""
async with aclosing(llm.stream(prompt)) as stream:
async for delta in stream:
buf += delta
if "." in buf:
break # stream is closed now, HTTP connection released
return buf.split(".")[0] + "."Server-side streaming (FastAPI StreamingResponse/SSE): after the first chunk, the status code is sent, so a
mid-stream failure must be reported in-band:
async def sse_events(prompt: str):
try:
async for delta in llm.stream(prompt):
yield f"data: {json.dumps({'delta': delta})}\n\n"
except ProviderError as err:
logger.exception("stream failed")
yield f"event: error\ndata: {json.dumps({'error': err.code, 'retryable': err.retryable})}\n\n"
yield "data: [DONE]\n\n"CancelledError (client disconnected) is not caught there and propagates, which is correct.
9. Background tasks and unobserved exceptions
background: set = set()
def spawn(coro, *, name: str) -> asyncio.Task:
task = asyncio.create_task(coro, name=name)
background.add(task) # keep a strong reference: tasks can be GC'd otherwise
task.add_done_callback(_on_done)
return task
def _on_done(task: asyncio.Task) -> None:
background.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error("background task %s failed", task.get_name(), exc_info=exc)Install a loop-level handler as a last resort:
def loop_exception_handler(loop, context):
exc = context.get("exception")
logger.error("asyncio: %s", context.get("message"), exc_info=exc)
asyncio.get_running_loop().set_exception_handler(loop_exception_handler)10. Threads and processes
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(ocr_page, p): p for p in pages}
for fut in as_completed(futures):
page = futures[fut]
try:
text = fut.result() # re-raises the worker's exception here
except OCRError as err:
logger.warning("page %s failed: %s", page, err)| Context | Uncaught exception |
|---|---|
threading.Thread target | printed via threading.excepthook; the thread dies, the main thread never knows |
Future.result() | re-raised in the caller with the worker traceback attached |
ProcessPoolExecutor | exception is pickled back; unpicklable exceptions cause BrokenProcessPool/errors - implement __reduce__ |
| worker process killed (OOM killer) | BrokenProcessPool for every pending future |
asyncio.to_thread | raised at the await |
Set threading.excepthook and sys.excepthook to route crashes into logging in long-running services.
11. Shutdown and signals
async def main() -> None:
server = await start_server()
try:
await server.serve_forever()
finally:
await vector_store.close()
await llm_client.aclose()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(130) # asyncio.run already cancelled main(); finally blocks ranSince 3.11, asyncio.run turns SIGINT into cancellation of the main task, so finally blocks and
except CancelledError: ...; raise cleanups run; the KeyboardInterrupt surfaces after cleanup.
12. 3.9/3.10 compatibility
| Feature | 3.9/3.10 alternative |
|---|---|
ExceptionGroup, except* | pip install exceptiongroup backport: with catch({ValueError: handler}): |
asyncio.TaskGroup | anyio.create_task_group() (raises exception groups via the backport) |
asyncio.timeout() | asyncio.wait_for() or async_timeout.timeout() |
add_note | set __notes__ manually (printed only by 3.11+ / backport) |
TimeoutError catching asyncio timeouts | catch asyncio.TimeoutError (subclass of Exception before 3.11) |
try:
from builtins import ExceptionGroup # 3.11+
except ImportError: # pragma: no cover
from exceptiongroup import ExceptionGroup # type: ignore[no-redef]Catching asyncio timeouts portably: except (asyncio.TimeoutError, TimeoutError): works on every version.
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.
python-idiomatic-code-reviewer
Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.
python-type-hints-annotator
Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.