Skip to content

Finding Never-Retrieved Task Exceptions

Task exception was never retrieved is asyncio telling you, after the fact, that some task failed and no code ever looked at the failure. It is emitted from the task's finaliser, so when you see it depends on garbage collection, and whether you see it depends on where your logs go. In a test, a failing task with no references was reported within one loop iteration; an identical task kept alive only by a reference cycle was not reported until gc.collect() ran — in a quiet service that can be minutes, and in a test suite it can land in a different test entirely. Routed through a custom exception handler with debug mode on, the report arrived as a structured context with the exception, the task, and a source_traceback showing exactly which line created it. This guide sets that up so the message is never missed and always points at a line of code.

Prerequisites

1. Know when the message fires

A task that finishes with an exception sets an internal flag meaning "this exception has not been looked at". Calling result(), exception(), or awaiting the task clears it. When the task object is destroyed with the flag still set, its finaliser calls the loop's exception handler with the message Task exception was never retrieved.

Destruction timing is the whole story:

  • No references: CPython frees the task as soon as the last reference goes, typically right after it finishes. Report: immediate.
  • Held in a reference cycle: reference counting cannot free it; the cyclic garbage collector must run first. Report: whenever the next collection of that generation happens.
  • Held forever (a module-level set, a cache): never destroyed until interpreter shutdown. Report: at exit, or never if the loop is already closed.
class Holder:
    pass


async def main() -> None:
    h = Holder()
    h.task = asyncio.create_task(boom())
    h.self = h                     # cycle: the task is only freed by the cyclic GC
    del h
    await asyncio.sleep(0.01)      # task has failed; nothing reported yet
    gc.collect()                   # now the message appears

Measured: one report before gc.collect() (from an unreferenced failing task earlier in the run) and two after.

Verify: reproduce the cycle case; the message only appears after an explicit gc.collect().

Same failure, three reporting times 3 lanes over time. Same failure, three reporting times no references fails reported in a cycle fails waits for the cyclic GC reported held forever fails silent at exit time → The report is tied to object destruction, so it says when the task died, not when it failed.

2. Route the report through your exception handler

By default the message goes to the asyncio logger at ERROR level. If your logging setup drops or samples that logger, or if the report happens during shutdown after handlers are gone, it vanishes. Install an exception handler and turn the report into a structured event and a metric:

import asyncio
import logging

log = logging.getLogger("app.asyncio")


def exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
    message = context.get("message", "")
    exc = context.get("exception")
    task = context.get("future")
    if message == "Task exception was never retrieved":
        metrics.inc("asyncio_unretrieved_task_errors_total",
                    kind=type(exc).__name__ if exc else "unknown")
    log.error(
        "%s (task=%s)", message,
        task.get_name() if isinstance(task, asyncio.Task) else None,
        exc_info=(type(exc), exc, exc.__traceback__) if exc else None,
        extra={"created_at": "".join(context["source_traceback"].format()[-3:])
               if "source_traceback" in context else None},
    )


async def main() -> None:
    asyncio.get_running_loop().set_exception_handler(exception_handler)

The context dict for this message carries message, exception, future (the task), and — with debug mode on — source_traceback, verified on 3.14. Counting by exception type quickly shows whether it is one recurring bug or many.

Verify: trigger one unretrieved failure; you see exactly one structured log record and the counter increments.

3. Get the creating line from source_traceback

The traceback attached to the exception shows where the task failed. What you need to fix the bug is where it was created — the create_task call whose result nobody checked. In debug mode every task records its creation stack as source_traceback:

PYTHONASYNCIODEBUG=1 python -m myservice
Task exception was never retrieved
future: <Task finished name='Task-2' coro=<boom() done, defined at app.py:3>
         exception=KeyError('missing') created at app.py:9>
source_traceback: Object created at (most recent call last):
  File "app.py", line 13, in <module>
  File "app.py", line 9, in main
    asyncio.create_task(boom())

The created at app.py:9 in the task's repr is the line to fix. Because debug mode is expensive, use it in CI and staging; in production, a custom task factory that records the caller's file and line costs well under a microsecond per task and gives the same answer, as shown in logging task lifecycles with a custom task factory.

Verify: the report in your logs contains a created at location that points into your code, not into asyncio.

From a dropped failure to the line that created it A flow of 4 stages. From a dropped failure to the line that created it task fails, nobody awaits flag stays set task destroyed finaliser fires exception handler log, count, source_traceback fix the create_task line supervise or await The failure traceback says what broke; the creation traceback says who forgot to look.

4. Force collection at the boundaries that matter

Because cyclic garbage holds reports back, force a collection where you want them attributed: at the end of each test, and periodically in long-running services.

# conftest.py
import gc
import pytest


@pytest.fixture(autouse=True)
def collect_after_test():
    yield
    gc.collect()               # flush pending finalisers so reports land in THIS test

Combined with the asyncio-logger fixture from enabling asyncio debug mode in tests and CI, this makes an unretrieved exception fail the test that caused it rather than a random later one.

In services, a gc.collect() every few minutes from a background task is usually harmless and makes reports timely; measure its pause first if the heap is large. Better still is fixing the cycle: tasks stored on objects that also reference themselves — a common shape in classes that keep self._task and pass bound methods as callbacks — are the usual source.

Verify: the cycle reproduction from section 1 now fails its own test instead of a later one.

How to fix an unretrieved task exception A decision on Who should have seen the failure with 3 outcomes. How to fix an unretrieved task exception Who should have seen the failure? a caller needs the result await the task or gather it failure should stop the work TaskGroup siblings cancelled nobody, it is background work supervised spawn done-callback reports The message is a symptom; the fix is giving the failure an owner.

5. Fix the cause, not the message

Silencing the report — calling task.exception() in a blanket done-callback and discarding the result — makes the log go quiet and leaves the bug. Each report needs one of three fixes:

# 1. someone needs the result: await it
result = await asyncio.create_task(fetch())

# 2. failure should cancel related work: structured concurrency
async with asyncio.TaskGroup() as tg:
    tg.create_task(fetch_a())
    tg.create_task(fetch_b())

# 3. genuinely background: supervise and report on completion
spawn(send_audit_event(event), name=f"audit:{event.id}")     # logs failures at once

The spawn helper from handling exceptions in fire-and-forget tasks reports failures the moment they happen, with the task's name, so the finaliser path is never reached.

Verify: after the fix, the counter from section 2 stays at zero across a full test run and a day of production traffic.

Verification

Unretrieved exceptions are under control when:

  • Every report reaches your logging pipeline through a custom exception handler.
  • Reports name the creating line, via debug mode in CI or a task factory in production.
  • Tests flush finalisers so reports fail the test that caused them.
  • The production counter is zero, and any non-zero value is investigated as a bug.

Diagnostic Hook: alert on any increase of asyncio_unretrieved_task_errors_total. It is a low-volume, high-signal metric: every increment is a failure that no code handled. Break it down by exception type and by creation site when available — a single site responsible for most increments is usually one missing await.

Pitfalls & edge cases

  • Blanket done-callbacks that call exception() silence every report, including real bugs.
  • Reports during interpreter shutdown may be lost if logging handlers are already closed; flush and close the loop before logging shuts down.
  • Tasks stored in long-lived registries are never destroyed, so never reported; remove them on completion.
  • Futures, not just tasks. Bare futures have the same mechanism and report as "Future exception was never retrieved".

Frequently Asked Questions

What does 'Task exception was never retrieved' mean?

A task finished with an exception and was garbage-collected without any code awaiting it or calling result() or exception(). asyncio reports it from the task's finaliser so the failure is not completely lost.

Why does 'Task exception was never retrieved' appear long after the error?

The report is made when the task object is destroyed. If the task is part of a reference cycle it waits for the cyclic garbage collector; if it is held in a long-lived container it may not be reported until interpreter exit.

How do I find which code created the failing task?

Run with PYTHONASYNCIODEBUG=1. Debug mode records each task's creation stack, which appears as source_traceback in the exception handler context and as 'created at' in the task's repr.

How do I stop 'Task exception was never retrieved' messages?

Give each failure an owner: await the task, run it in a TaskGroup, or supervise it with a done-callback that logs failures immediately. Do not silence the message by retrieving exceptions blindly.