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/exceptinside 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.