← Debugging Guides

阅读中文版本

Python asyncio — gather() Cancels Sibling Tasks on First Failure

Understand why asyncio.gather aborts remaining awaitables when one raises, keep sibling work alive with return_exceptions, and handle ExceptionGroup-style fan-out failures.

intermediatedebuggingpythonasyncioconcurrency

Symptoms

  • One failed sub-task in a gather() fan-out leaves all sibling results missing, even ones that were nearly complete
  • CancelledError appears in sibling task logs even though their own logic never failed
  • A batch of 10 jobs reports a single error and 9 cancelled with zero completed results delivered

Possible causes

  • Default gather() semantics propagate the first exception immediately and cancel the not-yet-finished awaitables
  • Siblings were independent by design, so losing them turns a partial failure into a total one
  • Nested gathers multiply the effect: one leaf failure cancels a whole subtree of otherwise healthy work
  • Code reads results = await gather(...) assuming all-or-nothing is impossible, then discards the partial data it needed

Diagnosis steps

Confirm the cancellation semantics in a minimal repro

python -c "import asyncio async def ok(): await asyncio.sleep(0.1); return 'ok' async def boom(): await asyncio.sleep(0.01); raise ValueError('x') async def main():
  try:
    await asyncio.gather(ok(), boom())
  except ValueError:
    pass
asyncio.run(main())" 2>&1 | head -5

Interpretation

The ok() task never returns its value: the ValueError propagates first and the sibling is cancelled — the default gather contract made visible.

Audit gather call sites for independence

grep -Rn "asyncio.gather\|asyncio.gather(" src/ | head -20

Interpretation

Every fan-out where tasks do not depend on each other is a candidate for return_exceptions=True.

Inspect failure fan-in logs

grep -R "CancelledError" logs/ | tail -20

Interpretation

CancelledError entries synchronized with the primary failure timestamp reveal the cancellation cascade rather than independent bugs.

Root cause

asyncio.gather by default raises the first exception it receives and cancels the remaining awaitables. For independent tasks this converts a partial failure into a total one: work that was already nearly done is actively cancelled and its results are lost, while the caller receives only the single exception.

Fix

Pass return_exceptions=True when siblings are independent, then partition results into values and exceptions. When a group genuinely needs all-or-nothing, use asyncio.TaskGroup for structured cancellation with explicit cleanup, and keep per-task timeouts so one slow sibling cannot hold the group.

Risk note

return_exceptions=True also captures CancelledError, which can hide shutdown signals. Distinguish cancellation from business failure when partitioning results.

Verify the fix

Inject a failure into one fan-out branch and assert siblings still deliver results, the exception is reported per task, and no CancelledError appears in sibling logs.

Symptom walkthrough

The minimal reproduction is five lines and settles the semantics question instantly:

import asyncio

async def ok():
    await asyncio.sleep(0.1)
    return 'ok'

async def boom():
    await asyncio.sleep(0.01)
    raise ValueError('x')

async def main():
    result = await asyncio.gather(ok(), boom())

asyncio.run(main())

boom() raises first, gather propagates the ValueError, and ok() — 90% of the way to done — is cancelled. In production the fingerprint is sibling logs full of CancelledError at exactly the primary failure timestamp, plus a caller that received one error and zero partial results.

Fix in practice

For independent tasks, opt into full results and partition them:

results = await asyncio.gather(
    *coros,
    return_exceptions=True,
)

values, failures = [], []
for item in results:
    if isinstance(item, BaseException):
        if isinstance(item, asyncio.CancelledError):
            raise item          # never swallow shutdown signals
        failures.append(item)
    else:
        values.append(item)

The CancelledError re-raise matters: return_exceptions=True captures cancellation like any exception, and swallowing it would break cooperative shutdown. When the group truly is all-or-nothing, prefer structure over accident:

async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(coro()) for coro in coros]
# raises ExceptionGroup with every failure, cancels cleanly

TaskGroup makes the all-or-nothing choice explicit and reports every failure instead of whichever arrived first.

Verification

  Build a fan-out harness: five tasks, one configured to fail at a chosen delay. With the fix, the run must deliver four values plus one exception object, and grep of sibling logs must show zero CancelledError entries. Vary the failing delay across runs to confirm independence from failure order. For the TaskGroup variant, assert the raised ExceptionGroup contains exactly the injected failures and that all non-failing tasks completed before the group exited — the explicit complement of the gather behavior you just replaced.