solvedfeed

Fix asyncio "Task exception was never retrieved" swallowing background errors

The problem

A fire-and-forget worker task crashed and nothing failed loudly. The only trace appeared much later at garbage-collection time:

Task exception was never retrieved
future: <Task finished name='Task-12' coro=<pump_queue() done, defined at worker.py:41>>
RuntimeError: connection closed

Meanwhile the pipeline silently dropped messages for an hour.

What didn't work

  • try/except inside the coroutine that re-raises — nobody awaits the task, so the re-raised exception still goes nowhere.
  • asyncio.create_task(bg()) without keeping the reference — the event loop only holds a weak reference and the task can be garbage-collected mid-flight.
  • Switching to await asyncio.gather(...) — changes semantics: the caller now blocks on all background work, which is exactly what you were avoiding.

The fix

import asyncio, logging

log = logging.getLogger(__name__)
_background: set[asyncio.Task] = set()

def spawn(coro) -> asyncio.Task:
    task = asyncio.create_task(coro)
    _background.add(task)                 # strong ref: prevents mid-flight GC
    task.add_done_callback(_reap)
    return task

def _reap(task: asyncio.Task) -> None:
    _background.discard(task)
    if not task.cancelled() and task.exception() is not None:
        log.error("background task failed", exc_info=task.exception())
# usage: same fire-and-forget ergonomics, errors become real log lines
spawn(pump_queue(queue))

Why it works

A done-callback runs in the event loop the moment the task finishes, and calling task.exception() there is exactly the "retrieval" asyncio was waiting for — so the traceback is logged while it's fresh instead of surfacing as a GC-time whisper after the damage is done.

Continue reading — $1/month

The full fix, verbatim, plus every other solution in the feed.

raw markdown twin · ← back to feed