Repository navigation
Optionally prevent child tasks from being cancelled in asyncio.TaskGroup #101581
Description
Activity
- addedtype-featureA feature request or enhancementA feature request or enhancement
on Feb 5, 2023 Yeah, I think this is reasonable. We need to bikeshed on the name of the flag, and I would like to ensure that when the awaiting task (i.e., the
TaskGroupitself) is cancelled, all awaited tasks (i.e., all running tasks that were created by theTaskGroup) are cancelled. There are tests for this, look forr.cancel()in test_taskgroups.py, we should probably clone some of these and run them with the new flag as well.We need to bikeshed on the name of the flag
Yes, it's quite hard to figure out a name that's descriptive enough but reasonably short. May
abort_on_child_exception?I would like to ensure that when the awaiting task (i.e., the
TaskGroupitself) is cancelled, all awaited tasks (i.e., all running tasks that were created by theTaskGroup) are cancelled. There are tests for this, look forr.cancel()in test_taskgroups.py, we should probably clone some of these and run them with the new flag as well.In the local clone of my fork I've already defined new tests for the new intended behaviour and I've copied the ones you've pointed out (setting the new flag to prevent child tasks cancellation).
Should I open a PR now or it'd be better to wait for the flag name to have been found?
abort_on_child_exceptionstill feels too long. Maybeshield_errors=True(default False)? Orpropagate_errors=False(default True)? Orspread_errors=True(default False)? (You see a pattern emerge, I'd like a verb and a noun, and the noun should probably be "errors". :-)I consider
shield_errors(defaultFalse) to be the closest.propagate_errorsmay be misleading since exceptions still propagate after other child tasks have been completed.
I'd like to follow this one, if there are no objections toshield_errorsor better proposals.Maybe
postpone_errors(defaultFalse)?Oh!
defer_errors=True(default False).Reacted by Andrea Tedeschi, Oleg Iarygin, Bénédikt Tran and Jonas Lundholm BertelsenOh!
defer_errors=True(default False).Fine with me. We all agree on
defer_errors=True(defaultFalse)?Oh! defer_errors=True (default False).
SGTM
Reacted by Andrea TedeschiSo, since there is still more discussion on Discourse, I'd like to get a review from @achimnol, who (I hope) is collecting all the various possible solutions, e.g. Supervisor (also yours) and PersistentTaskGroup; there's a somewhat recent (post-US-PyCon) writeup in Discourse: https://discuss.python.org/t/asyncio-tasks-and-exception-handling-recommended-idioms/23806/9
Any updates on this? :-)
This is by far the cleanest solution I've found:
index 1633478d1c8..2f0afbbcd82 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -26,7 +26,7 @@ class TaskGroup: a task will cancel all remaining tasks and wait for them to exit. The exceptions are then combined and raised as an `ExceptionGroup`. """ - def __init__(self): + def __init__(self, *, defer_errors: bool = False): self._entered = False self._exiting = False self._aborting = False @@ -37,6 +37,7 @@ def __init__(self): self._errors = [] self._base_error = None self._on_completed_fut = None + self._defer_errors = defer_errors def __repr__(self): info = [''] @@ -259,7 +260,11 @@ def _on_task_done(self, task): }) return - if not self._aborting and not self._parent_cancel_requested: + if ( + not self._aborting + and not self._parent_cancel_requested + and not self._defer_errors + ): # If parent task *is not* being cancelled, it means that we want # to manually cancel it to abort whatever is being run right now # in the TaskGroup. But we want to mark parent task asIt will be possible to implement with
aiojobs.Scheduler, but apparently the current version has a bug: #104091 (comment)I don't thinkaiotools.Supervisorwill solve this. The exception_handler isn't meant to be a reliable callback for a task exception. I had the same problem withaiojobs.Scheduler.I tried very hard to get something working with aiotools, but the exception_handler doesn't count as retrieving the exceptions, so it gives "Future exception was never retrieved".
import asyncio from types import TracebackType import aiotools MULTIPLE_EXCEPTIONS_MESSAGE = "Multiple exceptions occurred" class WaitTaskGroup(aiotools.PersistentTaskGroup): _exceptions: list[BaseException] def __init__( self, *, name: str | None = None, ) -> None: async def exception_handler( exc_type: type[BaseException], # noqa: ARG001 exc_obj: BaseException, exc_tb: TracebackType, # noqa: ARG001 ) -> None: self._exceptions.append(exc_obj) super().__init__(name=name, exception_handler=exception_handler) async def __aenter__(self) -> "WaitTaskGroup": self._exceptions = [] return await super().__aenter__() async def __aexit__( self, exc_type: type[BaseException] | None, exc_obj: BaseException | None, exc_tb: TracebackType | None, ) -> bool | None: ret = await super().__aexit__(exc_type, exc_obj, exc_tb) # exception_handler is awaited in parent class if self._exceptions: for t in self._tasks: # this doesn't fix "Future exception was never retrieved" try: await t except BaseException: pass raise BaseExceptionGroup( MULTIPLE_EXCEPTIONS_MESSAGE, self._exceptions, ) return ret
You can see how annoying it is to deal with the unretrieved exceptions here:
import asyncio from types import TracebackType import aiotools MULTIPLE_EXCEPTIONS_MESSAGE = "Multiple exceptions occurred" async def get_string() -> str: return "hello" async def get_int() -> int: return 1 # async def raise_exception() -> typing.Never: # also works async def raise_exception(s: str, *, fail: bool = True) -> None: if fail: raise Exception(s) async def slow_print(s: str) -> None: await asyncio.sleep(1) print(s) async def test_persistent_task_group( *, fail: bool = True, ) -> tuple[str, int, None, None]: exceptions = [] async def exception_handler( exc_type: type[BaseException], # noqa: ARG001 exc_obj: BaseException, exc_tb: TracebackType, # noqa: ARG001 ) -> None: exceptions.append(exc_obj) async with aiotools.PersistentTaskGroup(exception_handler=exception_handler) as tg: t1 = tg.create_task(get_string()) t2 = tg.create_task(get_int()) t3 = tg.create_task(raise_exception("PersistentTaskGroup", fail=fail)) t4 = tg.create_task(slow_print("PersistentTaskGroup")) t5 = tg.create_task(raise_exception("PersistentTaskGroup2", fail=fail)) if exceptions: for t in (t1, t2, t3, t4, t5): # Tasks need to be awaited before ExceptionGroup is raised # see: https://github.com/aio-libs/aiojobs/blob/master/aiojobs/_scheduler.py#L241 try: await t except BaseException: pass raise BaseExceptionGroup( MULTIPLE_EXCEPTIONS_MESSAGE, exceptions, ) return t1.result(), t2.result(), t3.result(), t4.result()
I tried to raise the exceptions from
asyncio.gather(*task_tuple, return_exceptions=True)as aBaseGroupException, and preserve the type information with aTypeVarTuple, but tuple comprehension loses type info and we don't have type mapping:Ts = TypeVarTuple("Ts") def cast_not_exception[*Ts](*args: tuple[*Ts | BaseException]) -> tuple[*Ts]: return cast("tuple[*Ts]", args)
Overriding
_abortis suggested here, but affects cancels coming from the parent, not just siblings.import asyncio class UnabortableTaskGroup(asyncio.TaskGroup): _abort = lambda self: None async def raise_exception() -> None: await asyncio.sleep(0.1) raise Exception("cancel") async def slow_print(s: str) -> None: await asyncio.sleep(1) print(s) async def test_my_task_group() -> None: async with MyTaskGroup() as tg: tg.create_task(slow_print("MyTaskGroup")) async def test_unabortable_task_group() -> None: async with UnabortableTaskGroup() as tg: tg.create_task(slow_print("UnabortableTaskGroup")) async def test_cancel() -> None: async with asyncio.TaskGroup() as tg: # UnabortableTaskGroup is the only one that prints tg.create_task(test_my_task_group()) tg.create_task(test_unabortable_task_group()) tg.create_task(raise_exception())
- addedstdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directory
on Mar 29, 2025
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Feature or enhancement
Add a flag to
asyncio.TaskGroupto control whether, if a child task crashes, other should be cancelled or not.Pitch
Currently
asyncio.TaskGroupalways cancels all child tasks if one fails. Even if that's the most common use case, I'd like to be able to switch from the current behaviour to prevent child tasks cancellation when one failure occurs and raise anExceptionGroupwith all exceptions raised (only after other tasks completed).async withblock still have to cause child tasks to be canceled.SystemExitandKeyboardInterruptraised in a child task still cancel other tasks.Example usage:
Looking at
asyncio.TaskGroupsource code, it seems that it could be achieved by adding theabort_on_first_exception(or whatever it should be named) flag to the__init__method and then modify_on_task_doneas follow:If it's reasonable to have it in
asyncio, I'll be glad to submit a PR for it.Previous discussion
Post on discuss
Linked PRs
asyncio.TaskScopeand letasyncio.TaskGroupsubclass it #105011