The bug was a request that returned successfully while the work it started kept running, failed, and logged an exception nobody was listening for. The cause was one line — a bare create_task with no reference kept — which is the most common structural defect in async Python codebases and the one that produces the least legible symptoms.
Structured concurrency is the fix, and the idea is small: a task cannot outlive the scope that started it. If you open a block that spawns work, that block does not exit until every child has finished, been cancelled, or raised. Concurrency gets the same containment guarantees that a try/finally gives resources.
Why loose tasks go wrong
A task created and not awaited has three failure modes, and they compound. The event loop holds only a weak reference, so it can be garbage collected mid-flight and simply stop. Its exception goes nowhere until the process exits and prints 'Task exception was never retrieved'. And shutdown does not know about it, so a deploy kills it halfway through whatever it was writing.
# Leaks: no reference, no error propagation, no shutdown awareness.
async def handle_bad(req):
asyncio.create_task(audit_log(req)) # may vanish; failures are silent
return await respond(req)
# Contained: the block cannot exit until every child settles, and a failure
# in any child cancels the siblings and propagates to the caller.
async def handle_good(req):
async with asyncio.TaskGroup() as tg:
audit = tg.create_task(audit_log(req))
enrich = tg.create_task(enrich_request(req))
primary = tg.create_task(respond(req))
return primary.result()
# Timeouts belong to the scope, not to each individual call.
async def handle_with_deadline(req):
async with asyncio.timeout(2.5): # cancels everything inside on expiry
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_profile(req))
tg.create_task(fetch_entitlements(req))The error semantics are the part worth dwelling on. If one child fails, the group cancels its siblings and raises an ExceptionGroup containing everything that went wrong. You get all the failures, not the first one that happened to surface, and you never get a half-finished fan-out quietly continuing in the background.
Cancellation is cooperative, so write for it
Cancellation in asyncio works by raising at the next await point. Code that catches broad exceptions will swallow it — a bare except Exception around a network call now suppresses the cancellation that was trying to shut your task down, and the task becomes unkillable. Catch narrowly, and when you must run cleanup, shield only the cleanup and keep it brief.
- Never call create_task outside a group or a tracked set; unowned tasks are unowned failures.
- Do not catch bare Exception around awaits in library code. CancelledError is control flow, not an error.
- Put timeouts on scopes rather than individual calls, so a deadline covers the whole operation.
- For genuine background work, use one long-lived supervised worker with a queue, not a task per request.
- On shutdown: stop accepting, cancel the supervisor, then await the groups. Draining is a sequence, not a sleep.
If you cannot name the scope that owns a task, nobody owns it — including the code that is supposed to shut it down.
Converting a mid-sized service to this style is usually a day of work and mostly deletions. What you notice afterwards is not throughput. It is that failures surface at the call site instead of in a log nobody reads, and that deploys stop producing a small mystery burst of half-completed operations.