In asyncio, what reports an exception from a task nobody ever awaited?
answer
- The failure waits inside the task
- Nobody asked for the result
- Reported only when it is collected
- A handler that belongs to one loop
- Two arguments: the loop and a context dict
basics
~20 sThe event loop's exception handler. A failing asyncio.Task stores its exception; if nothing retrieves it, the loop calls its handler with 'Task exception was never retrieved' when the task object is collected. Install your own with the loop's set_exception_handler method.
solid answer
~50 sAn exception inside a coroutine driven by an `asyncio.Task` never reaches `sys.excepthook` — the task catches it and stores it as its result. If some code awaits the task or calls its `exception()` method, that is where the failure appears. If nothing ever does, the loop reports it only when the task object is garbage-collected, by calling its exception handler with a context dictionary whose `message` is `Task exception was never retrieved` and whose `exception` key holds the error. The default handler logs it through the `asyncio` logger at error level. Override it with the running loop's `set_exception_handler` method, passing a callable that takes `(loop, context)`; it is **per loop**, so install it inside the coroutine via `asyncio.get_running_loop()`, because `asyncio.run` builds a fresh loop each call. Do not rely on it as the primary report: it is collection-timed, so keep strong references to tasks and consume their results.
code
python · 15 linesimport asyncio
def handler(loop, context):
print("loop reported:", context["message"], repr(context.get("exception")))
async def boom():
raise RuntimeError("rollback failed for account 42")
async def main():
asyncio.get_running_loop().set_exception_handler(handler)
task = asyncio.create_task(boom())
del task
await asyncio.sleep(0.1)
asyncio.run(main())go deeper
Know that an exception inside an asyncio task does not stop the program or print like a normal crash: it is kept on the task, and you see it when you await the task or ask for its result.
Explain the storage-and-retrieval model, the 'Task exception was never retrieved' report at collection time, and that the loop's exception handler takes a loop plus a context dictionary rather than the three-argument main-thread signature.
Show you would not depend on it: strong references to tasks, results actually retrieved, structured groups where possible, and the handler wired into the same sink as the other last-chance hooks with per-loop installation done correctly.
Own the standard: what an async service must guarantee about failure visibility, whether teams may create bare tasks at all, and how error reporting for async work is made uniform across every entry point the organisation runs.
### Where the exception actually goes A coroutine object does nothing on its own; something must drive it. When `asyncio.create_task` wraps one in an `asyncio.Task`, the task steps the coroutine, and if the coroutine raises, the task **captures** the exception and completes in the failed state. Nothing propagates: the loop keeps running, `sys.excepthook` is never involved, and the error sits inside the task waiting for someone to ask for it. Awaiting the task, or calling its `result()` or `exception()` method, are the ways of asking. If nobody asks, CPython still does not want to lose the error. The task's finalizer notices it holds an unretrieved exception and calls the loop's `call_exception_handler` with a context dictionary containing at least: * `message` — `"Task exception was never retrieved"`, * `exception` — the exception instance, * `future` or `task` — the object involved. The default handler formats that and logs it to the `asyncio` logger at error level. Two consequences follow immediately. First, the report arrives **whenever the object is collected**, not when the failure happened — potentially much later, potentially never if the process exits first. Second, if the application configured logging so that library loggers are quiet, the report is dropped entirely. ### Installing your own handler The handler is a property of a loop, not of the process. You set it by calling the running loop's `set_exception_handler` method with a callable of two arguments: ```python import asyncio def handler(loop, context): exc = context.get("exception") print(context["message"], repr(exc)) async def main(): asyncio.get_running_loop().set_exception_handler(handler) ... asyncio.run(main()) ``` Because `asyncio.run` creates a new loop for each call and closes it afterwards, a handler installed once at import time attaches to nothing. Install it as the first statement of your entry coroutine, or use `asyncio.Runner` (**3.11**) with a `loop_factory` so the handler is in place before any user code runs. Passing `None` restores the default handler; the default is also directly callable through the loop's `default_exception_handler` method, which is the polite way to keep standard output while adding your own reporting. The same handler receives more than unretrieved task exceptions: failures inside callbacks scheduled with the loop's `call_soon`, transport and protocol errors, and other places where the loop has an error but no caller to give it to. It is asyncio's analogue of `sys.unraisablehook` — the channel for failures with nobody to propagate to. ### Why not to depend on it A payment reconciliation job that fans out one task per account is the classic shape of this bug. A partial-failure rollback raises inside one task, the job never awaits that task — or awaits it in a `gather` whose result it discards — and the process exits 0. The stderr log gains one `Task exception was never retrieved` line, or none at all if the task was still referenced at shutdown, and the discrepancy is found three weeks later during the release train. The robust practices are structural, not hook-based: * **Keep a strong reference to every task you create.** The loop does not hold one for you after completion; a task whose only reference you dropped can be collected mid-flight, which is the well-known "task disappeared" surprise. Store them in a set and discard on done. * **Retrieve every result.** `await`, `gather`, or a done callback that calls the task's `exception()` method — anything that reads the outcome turns an invisible failure into a real one. * **Prefer structured concurrency.** `asyncio.TaskGroup` (**3.11**) waits for its children and re-raises their failures as an `ExceptionGroup`, so "nobody retrieved it" stops being possible for tasks inside the group. Use the loop exception handler as the **safety net** underneath those — wired to the same sink as `sys.excepthook`, `threading.excepthook` and `sys.unraisablehook`, so that whatever slips past structured handling is still recorded rather than printed into the void. ### The four-hook picture An async service that wants no silent failures installs all four: the main-thread hook for the process's own crash, the threading hook for anything run in an executor thread that is not a future-based worker, the unraisable hook for finalizers, and the loop handler for the async side. Each is a separate installation, and the loop one must be repeated per loop.
- Why must the handler be installed on the running loop rather than once at import time?The handler is state on a loop object, and `asyncio.run` creates a fresh loop per call and closes it at the end, so an import-time installation attaches to no loop that ever runs your code. Install it as the first statement of the entry coroutine using `asyncio.get_running_loop()`, or build the loop yourself through `asyncio.Runner` with a `loop_factory` so the handler exists before any task starts.
- What else besides an unretrieved task exception reaches that handler?Anything the loop cannot hand to a caller: an exception raised by a callback scheduled with the loop's `call_soon`, transport and protocol errors, and some shutdown-path failures. The `context` dictionary always carries a `message`, usually an `exception`, and often the `future`, `task`, `handle`, `transport` or `protocol` involved, which is what makes a single handler a usable last-resort sink.
- How do you stop failures from depending on garbage-collection timing in the first place?Retrieve every result. Keep created tasks in a set so they are not collected mid-flight, add a done callback that reads the task's `exception()` method, or use `asyncio.TaskGroup` (3.11), which waits for its children and re-raises their errors as an `ExceptionGroup`. The loop handler then becomes a safety net for the leftovers rather than the primary reporting path.
saying these in an interview costs you the question
- Expects an exception in a task to reach sys.excepthook
- Thinks one failing task stops the whole event loop
- Installs the handler at import time, before any loop exists
- Gives the handler the three-argument sys.excepthook signature
- Treats the handler as reliable and immediate rather than collection-timed
- Creates tasks without keeping a reference or reading the result