11.6. Exceptions

Exceptions inside an asyncio script behave almost the same as in regular Python – they propagate up the call chain until something catches them. Almost because tasks are running in parallel, so the path “up” is not the path that created the task. This page covers where exceptions go in each of the common shapes.

11.6.1. Inside one coroutine

A try/except inside a coroutine catches exceptions raised by anything it awaits, in the usual way:

async def fetch_with_retry(url):
    for attempt in range(3):
        try:
            return await fetch(url)
        except OSError as e:
            last_error = e
    raise last_error

Nothing asyncio-specific here – the except clause sees exceptions raised inside fetch as if it had been a regular function call.

11.6.2. In a task the application is awaiting

When a coroutine running as a Task raises, the exception is stored on the task. The next time something awaits that task, the exception is re-raised at the await:

task = asyncio.create_task(may_fail())
try:
    result = await task
except OSError:
    log("may_fail failed")

The same applies to asyncio.gather(). The default behaviour – one child raises, others get cancelled, the exception propagates out of the gather – comes from this mechanism.

11.6.3. In a task nobody awaits

A task that nobody ever awaits is the case that needs attention. The exception still happens; the loop notices the task finished with an unhandled exception; but there is no await for it to surface at. The default behaviour is to print a traceback through sys.stderr and continue running – which is fine for an unattended diagnostic, but a poor fit for an application that wanted to know.

The right fix is usually to await the task. Either directly, by remembering the handle and awaiting it during shutdown, or implicitly through gather() or wait_for(). The Timeouts and cancellation page’s “shutting an application down” pattern catches this case for the long-lived background tasks a typical script spawns.

11.6.4. Custom exception handler

When silent traceback-and-continue is not enough, the loop exposes a hook – Loop.set_exception_handler – the application can override to do something else:

def handler(loop, context):
    print("asyncio:", context.get("message"))
    if "exception" in context:
        sys.print_exception(context["exception"])

loop = asyncio.get_event_loop()
loop.set_exception_handler(handler)

The context argument is a dict with keys 'message', 'exception', and 'future'. The exception may be missing on certain warning-style events, which is why the example uses .get().

Typical uses are to log the failure to flash, blink an error LED, or escalate to a watchdog reboot. The loop control page covers the full surface of loop hooks.

11.6.5. KeyboardInterrupt

When a script is stopped from the outside – usually by the IDE asking it to halt – the request arrives inside the script as a KeyboardInterrupt. Inside asyncio.run() it propagates the way any other unhandled exception would: main is cancelled, every task the loop is tracking gets cancelled too, and the KeyboardInterrupt is re-raised out of asyncio.run(). finally clauses run on the way out, so the same cleanup pattern from the cancellation page is what handles it.