Unhandled Exceptions in Python Asyncio Tasks
When an unhandled exception occurs inside a detached Python
asyncio task, the task immediately halts execution without
crashing the overall event loop or terminating the main program.
Instead, the exception is caught internally and stored within the task
object itself. The error remains silent until the task object is
garbage-collected, inspected manually, or caught by an event loop
exception handler, which can lead to hard-to-detect bugs if not properly
managed.
The Immediate Lifecycle of a Failed Task
When code running inside a coroutine scheduled with
asyncio.create_task() raises an uncaught exception, the
event loop catches it and marks the task as "done." The coroutine
finishes execution prematurely, but the exception is not immediately
re-raised in the main thread or context.
Because the task is detached—meaning it is not awaited using
await task or gathered via
asyncio.gather()—the caller does not receive the exception
at the moment of failure. The event loop continues running other
scheduled tasks normally, unaware that a critical failure may have
silently taken place.
The "Task Exception Was Never Retrieved" Warning
Python avoids complete silence by tying the error report to the
task's garbage collection lifecycle. When a task finishes with an
unhandled exception, asyncio flags that the exception was
never retrieved.
When the asyncio.Task object eventually falls out of
scope and is collected by Python’s garbage collector, its destructor
(__del__) checks this flag. If the exception was never
consumed, asyncio logs an error directly to
sys.stderr:
Task exception was never retrieved
future: <Task finished name='Task-1' coro=<background_worker() done> exception=ValueError('Something went wrong')>
Traceback (most recent call last):
...
ValueError: Something went wrong
Potential Dangers of Detached Failures
- Delayed Feedback: The traceback only prints when garbage collection runs. If a global set or long-lived data structure holds a reference to the task, the warning may not appear until the entire application shuts down.
- State Inconsistency: If a background task updates a database, maintains a heartbeat, or processes a queue, silent termination can leave the broader system in a broken or zombie state without any immediate notification.
- Suppression Under Load: Under certain memory pressures or interpreter shutdown conditions, finalizers might not run cleanly, resulting in the traceback never being displayed at all.
How to Catch and Handle Detached Task Exceptions
To prevent unhandled exceptions in detached tasks from passing silently, several built-in mechanisms should be used:
- Custom Loop Exception Handlers: You can set a
global handler on the event loop via
loop.set_exception_handler(handler). This catches exceptions that would otherwise produce the "never retrieved" log, allowing you to forward errors to monitoring services like Sentry or Datadog. - Done Callbacks: Attach a callback to the task using
task.add_done_callback(). In the callback, callingtask.exception()retrieves the error, preventing the unhandled log message and allowing custom cleanup or recovery logic. - Python 3.11+ TaskGroups: Modern
asyncioapplications useasyncio.TaskGroup()instead of manually detaching tasks withcreate_task(). Task groups ensure that if any child task raises an unhandled exception, the rest of the group is cancelled and the exception is immediately re-raised to the enclosing context as anExceptionGroup.