How cancellation really works, how timeouts are built on it, how to clean up correctly, and how to handle errors from many concurrent tasks (partial failure, ExceptionGroup, retries, graceful shutdown).
Contents
- How cancellation works
- Rules for handling CancelledError
- Swallowed cancellation breaks timeouts
- Reaping a task you cancelled
- Timeouts: asyncio.timeout vs wait_for
- Layered timeouts for LLM calls
- shield
- Cleanup on cancel
- Errors from many tasks
- ExceptionGroup and except*
- Retries with backoff and jitter
- Circuit breaker
- Graceful shutdown, signals and Ctrl+C
- Unhandled errors in callbacks
How cancellation works
task.cancel(msg=None) (msg since 3.9) does not stop anything immediately. It schedules a
CancelledError to be thrown into the coroutine at its next suspension point (the await it is currently
parked on, or the next one). The coroutine can run except/finally code, including more awaits, and then
the task ends cancelled if the error propagates out.
CancelledErrorsubclassesBaseExceptionsince 3.8, soexcept Exceptiondoes not catch it.- Code with no
awaitcannot be cancelled: a CPU loop or a blocking call runs to completion first. task.cancelling()(3.11) returns how many cancel requests are pending;task.uncancel()(3.11) decrements that count. TaskGroup andasyncio.timeoutuse this to tell their own cancellations from outside ones.- Cancelling a task that awaits another task cancels the inner one too (the cancel is forwarded to the awaited future), unless it is shielded.
- Cancelling an
await asyncio.to_thread(...)abandons the result; the thread keeps running.
import asyncio
async def generate(prompt: str) -> str:
try:
await asyncio.sleep(10) # waiting on the model
return "done"
except asyncio.CancelledError:
print("cleanup: releasing GPU slot")
raise
finally:
print("finally runs too")
async def main() -> None:
task = asyncio.create_task(generate("hi"))
await asyncio.sleep(0.01)
task.cancel("user pressed stop")
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())
# Output:
# cleanup: releasing GPU slot
# finally runs too
# cancelled: TrueRules for handling CancelledError
- Prefer
try/finally(orasync with) for cleanup; it runs on cancellation without catching anything. - If you catch
CancelledError(to log, to send a "stopped" event), end the handler with a bareraise. - Never write
except BaseException:or bareexcept:without re-raising in async code (AIO014). - Do not convert cancellation into a return value. Callers (TaskGroup, timeout, shutdown) rely on it propagating.
- If you deliberately absorb a cancellation (rare: a supervisor that restarts a worker), call
asyncio.current_task().uncancel()(3.11+) so enclosing timeouts and task groups keep working.
Swallowed cancellation breaks timeouts
import asyncio
async def bad_call() -> str:
try:
await asyncio.sleep(1)
return "answer"
except asyncio.CancelledError: # swallowed
return "partial"
async def good_call() -> str:
try:
await asyncio.sleep(1)
return "answer"
except asyncio.CancelledError:
raise
async def main() -> None:
for fn in (bad_call, good_call):
try:
async with asyncio.timeout(0.05):
result = await fn()
print(fn.__name__, "returned", result, "- timeout did not fire")
except TimeoutError:
print(fn.__name__, "timed out as expected")
asyncio.run(main())
# Output:
# bad_call returned partial - timeout did not fire
# good_call timed out as expectedThe bad version turns a 50 ms deadline into "returns garbage, and the caller never learns it timed out". Inside a TaskGroup the same bug makes shutdown hang, because the group waits for a child that refuses to stop.
Reaping a task you cancelled
The common shutdown idiom is fine and the linter allows it:
monitor.cancel()
try:
await monitor
except asyncio.CancelledError:
passCaveat: if the current task is cancelled while it waits here, that cancellation is swallowed too. On 3.11+ the precise version checks whether the cancel was meant for us:
monitor.cancel()
try:
await monitor
except asyncio.CancelledError:
current = asyncio.current_task()
if current is not None and current.cancelling():
raise # we are being cancelled as well: propagateTimeouts: asyncio.timeout vs wait_for
async with asyncio.timeout(s) (3.11+) | await asyncio.wait_for(aw, s) | |
|---|---|---|
| Scope | any block, many awaits | one awaitable |
| Extra task | no | wraps coroutines in a task on 3.9-3.11 |
| Reschedule deadline | cm.reschedule(new_deadline), cm.when() | no |
| Absolute deadline | asyncio.timeout_at(loop.time() + s) | no |
| Raises | TimeoutError | TimeoutError (asyncio.TimeoutError alias since 3.11) |
| No timeout | asyncio.timeout(None) | timeout=None |
On 3.12+ wait_for is implemented with asyncio.timeout, which fixed older edge cases where a result
arriving exactly at the deadline could be lost or a cancellation swallowed. On 3.9/3.10, before 3.11's
asyncio.TimeoutError is TimeoutError, catch asyncio.TimeoutError.
import asyncio
async def retrieve() -> list[str]:
await asyncio.sleep(0.01)
return ["chunk-1", "chunk-2"]
async def generate(chunks: list[str]) -> str:
await asyncio.sleep(0.5)
return "answer"
async def main() -> None:
try:
async with asyncio.timeout(0.1) as cm:
chunks = await retrieve()
print("retrieved", len(chunks), "deadline set:", cm.when() is not None)
await generate(chunks)
except TimeoutError:
print("whole RAG request exceeded 100 ms; expired =", cm.expired())
asyncio.run(main())
# Output:
# retrieved 2 deadline set: True
# whole RAG request exceeded 100 ms; expired = TrueLayered timeouts for LLM calls
Use three layers, each catching a different failure:
| Layer | Example | Catches |
|---|---|---|
| Client connect/read timeouts | httpx.Timeout(60, connect=5) | dead hosts, stalled sockets |
| Per-attempt deadline | async with asyncio.timeout(90): around one call | slow generations |
| Whole-operation budget | asyncio.timeout(300) around retries + fallbacks | retry storms exceeding the user's patience |
For streaming, a total deadline is wrong for long answers; use an idle timeout per chunk instead:
async def with_idle_timeout(stream, idle: float):
it = stream.__aiter__()
while True:
try:
async with asyncio.timeout(idle):
chunk = await it.__anext__()
except StopAsyncIteration:
return
yield chunkshield
asyncio.shield(aw) returns an awaitable that, when cancelled, does not cancel aw. The caller still gets
CancelledError; the shielded work continues in the background.
import asyncio
async def write_batch(batch: list[str], log: list[str]) -> None:
await asyncio.sleep(0.05) # vector-store upsert
log.append(f"wrote {len(batch)}")
async def handler(log: list[str]) -> None:
write = asyncio.ensure_future(write_batch(["a", "b"], log))
try:
await asyncio.shield(write)
except asyncio.CancelledError:
await write # let the write finish, then propagate
raise
async def main() -> None:
log: list[str] = []
task = asyncio.create_task(handler(log))
await asyncio.sleep(0.01)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
print(log)
asyncio.run(main())
# Output: ['wrote 2']Rules: keep a reference to the inner task (above: write), otherwise it can be garbage-collected after the
caller is cancelled; shield short, critical sections only (commit, ack); never shield whole request handlers
(shutdown will wait forever).
Cleanup on cancel
async def stream_answer(client, prompt: str):
stream = await client.open_stream(prompt)
try:
async for event in stream:
yield event
finally:
# Runs on normal end, error, consumer break (with aclosing), and cancellation.
# A second cancel during this await would interrupt it; keep it short or shield it.
await asyncio.shield(stream.aclose())Ordering: except CancelledError runs before finally; cleanup awaits run with the task still marked
"cancelling", which is fine. Avoid starting new long operations during cleanup. In TaskGroups, cleanup of
siblings happens before the group raises, so the ExceptionGroup arrives after all children finished.
Errors from many tasks
| Goal | Pattern |
|---|---|
| All must succeed, fail fast | TaskGroup (3.11) or gather_all_or_cancel helper (3.9/3.10) |
| Best effort, report failures | gather(..., return_exceptions=True) + split by isinstance(r, BaseException) |
| Best effort with per-item retry | wrap each item: async def safe(x): try: return Ok(await f(x)) except Exception as e: return Err(x, e) |
| Stop after N failures | worker pool with a shared failure counter; cancel workers when it trips |
| Log failures as they happen | as_completed loop with try/except per result |
Best-effort embedding of a batch, recording failures without losing the successful vectors:
import asyncio
async def embed(doc: str) -> list[float]:
await asyncio.sleep(0)
if not doc:
raise ValueError("empty document")
return [float(len(doc))]
async def main() -> None:
docs = ["alpha", "", "gamma"]
results = await asyncio.gather(*(embed(d) for d in docs), return_exceptions=True)
vectors = {i: r for i, r in enumerate(results) if not isinstance(r, BaseException)}
failures = {i: repr(r) for i, r in enumerate(results) if isinstance(r, BaseException)}
print(vectors)
print(failures)
asyncio.run(main())
# Output:
# {0: [5.0], 2: [5.0]}
# {1: "ValueError('empty document')"}ExceptionGroup and except*
A TaskGroup raises ExceptionGroup (or BaseExceptionGroup) containing every child failure. except*
(3.11) matches by type inside the group; each clause gets a sub-group; unmatched exceptions re-raise.
import asyncio
class RateLimited(Exception):
pass
async def call(i: int) -> int:
await asyncio.sleep(0.01)
if i == 1:
raise RateLimited("429 on call 1")
if i == 2:
raise TimeoutError("call 2 timed out")
return i
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
for i in range(4):
tg.create_task(call(i))
except* RateLimited as eg:
print("rate limited:", [str(e) for e in eg.exceptions])
except* TimeoutError as eg:
print("timeouts:", [str(e) for e in eg.exceptions])
asyncio.run(main())
# Output:
# rate limited: ['429 on call 1']
# timeouts: ['call 2 timed out']Notes: both failing tasks hit at the same time here so both are in the group; a task that was cancelled
because a sibling failed does not appear. eg.split(TypeOrPredicate) and eg.subgroup(...) work outside
except*. Pre-3.11: the exceptiongroup backport provides ExceptionGroup and a catch() context manager.
except* cannot be mixed with plain except in one try, and break/continue/return inside an
except* clause is a SyntaxError.
Retries with backoff and jitter
import asyncio
import email.utils
import random
import time
from typing import Awaitable, Callable, Optional, TypeVar
T = TypeVar("T")
class TransientError(Exception):
def __init__(self, msg: str, retry_after: Optional[float] = None) -> None:
super().__init__(msg)
self.retry_after = retry_after
def parse_retry_after(value: Optional[str]) -> Optional[float]:
"""Retry-After is either delta-seconds or an HTTP date."""
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
dt = email.utils.parsedate_to_datetime(value)
return max(0.0, dt.timestamp() - time.time())
async def with_retry(call: Callable[[], Awaitable[T]], *, attempts: int = 5, base: float = 0.5,
cap: float = 30.0, budget: float = 120.0) -> T:
start = time.monotonic()
for attempt in range(attempts):
try:
return await call()
except TransientError as err:
if attempt == attempts - 1:
raise
delay = err.retry_after if err.retry_after is not None else random.uniform(0, min(cap, base * 2 ** attempt))
if time.monotonic() - start + delay > budget:
raise
await asyncio.sleep(delay)
raise AssertionError("unreachable")
async def main() -> None:
calls = {"n": 0}
async def flaky() -> str:
calls["n"] += 1
if calls["n"] < 3:
raise TransientError("503 overloaded", retry_after=0.01)
return "ok"
print(await with_retry(flaky), "after", calls["n"], "calls")
print(parse_retry_after("2"))
asyncio.run(main())
# Output:
# ok after 3 calls
# 2.0- Full jitter (
uniform(0, min(cap, base * 2**n))) spreads synchronized clients; plain exponential backoff makes 500 workers retry in lockstep. - Retry: 408, 409 (some APIs), 429, 500, 502, 503, 504, 529 (overloaded), connect/read timeouts, connection resets. Do not retry: 400, 401, 403, 404, 422, context-length errors, content-policy refusals.
- Retries multiply load: with 3 retries a provider outage quadruples traffic. Combine with a circuit breaker and a shared rate limiter so retries also wait for capacity.
- Only retry idempotent operations, or make them idempotent (idempotency keys for writes and tool calls).
tenacityprovides the same as decorators (@retry(wait=wait_random_exponential(max=60), stop=stop_after_attempt(6), retry=retry_if_exception_type(...))) and supportsasync def.
Circuit breaker
When a dependency is down, fail fast instead of making every request wait for its timeout:
import asyncio
class CircuitOpen(Exception):
pass
class CircuitBreaker:
def __init__(self, threshold: int = 5, reset_after: float = 30.0) -> None:
self.threshold, self.reset_after = threshold, reset_after
self.failures, self.opened_at = 0, None
async def call(self, fn):
loop = asyncio.get_running_loop()
if self.opened_at is not None:
if loop.time() - self.opened_at < self.reset_after:
raise CircuitOpen("vector store unavailable; using keyword fallback")
self.opened_at = None # half-open: let one call through
try:
result = await fn()
except Exception:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = loop.time()
raise
self.failures = 0
return resultGraceful shutdown, signals and Ctrl+C
asyncio.run(3.11+, viaRunner) installs a SIGINT handler in the main thread that cancels the main task, so Ctrl+C runs yourfinallyblocks and cleanup awaits; a second Ctrl+C raisesKeyboardInterruptimmediately. On 3.9/3.10KeyboardInterruptis raised wherever the loop happens to be.- On exit
asyncio.runcancels all remaining tasks and waits for them, then finalizes async generators and shuts down the default executor (waits for runningto_threadcalls). - Containers send SIGTERM. Handle it on Unix with
loop.add_signal_handler:
import asyncio
import signal
async def serve_forever(stop: asyncio.Event) -> None:
while not stop.is_set():
await asyncio.sleep(0.1) # stand-in for consuming a job queue
async def main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set) # Unix only
worker = asyncio.create_task(serve_forever(stop))
await stop.wait()
# Drain: stop taking new jobs, give in-flight work a deadline, then cancel.
try:
async with asyncio.timeout(25): # below the orchestrator's kill grace period
await worker
except TimeoutError:
worker.cancel()Shutdown checklist: stop accepting work; let in-flight work finish with a deadline; flush buffers (usage logs, vector-store batches); cancel and await background tasks; close clients and pools; exit non-zero if work was dropped. Uvicorn handles SIGTERM itself and then runs your lifespan shutdown.
Unhandled errors in callbacks
Exceptions in call_soon callbacks, in protocol callbacks, and in tasks that are garbage-collected with an
unretrieved exception go to the loop's exception handler, which logs to the asyncio logger. Route them to
your logging/alerting:
import asyncio
import logging
logger = logging.getLogger("app")
def handle_loop_error(loop: asyncio.AbstractEventLoop, context: dict) -> None:
exc = context.get("exception")
logger.error("asyncio: %s", context.get("message"), exc_info=exc,
extra={"task": repr(context.get("task") or context.get("future"))})
async def main() -> None:
asyncio.get_running_loop().set_exception_handler(handle_loop_error)This is a safety net, not a strategy: own your tasks so exceptions are retrieved by someone who can act.
python-async-deadlock-debugger
Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.
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-exception-handling-designer
Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.