Search

ExceptionGroup and except*(PEP-654) 파보기 Feat. TaskGroup

try:… except:… 에 잡히지 않는 에러

Python 3.11 의 asyncio.TaskGroup context manager를 사용하면 비동기 Task를 동시에 호출 할 수 있습니다. 그런데 기존 에러처리로는 잡히지 않는 ExceptionGroup 에러가 발생합니다.
3.11에 도입된 것인데, 마주칠 일이 없다가 이제서야 마주합니다. PEP 654 에 따르면, 도입 이유 1번으로 꼽는 것이 Concurrent errors 때문이라고 설명합니다.
비동기로 여러 태스크를 동시에 실행하다 중간에 일부 태스크에서 에러가 발생할 경우 TaskGroup 전체 Task를 종료하는 것이 아닌 ExceptionGroup 으로 개별 Task의 에러를 모아 raise 합니다.
일반적인 에러 발생, try - except
async def raise_type_error(): raise TypeError("This is TypeError") async def raise_value_error(): raise ValueError("This is ValueError") async def main1(): try: await raise_type_error() except (TypeError, ValueError) as e: print(e) async def main2(): try: await raise_value_error() except (TypeError, ValueError) as e: print(e) # except로 에러 핸들링이 됩니다. asyncio.run(main1()) >>> This is TypeError asyncio.run(main2()) >>> This is ValueError
Python
복사
asyncio.TaskGroup() 을 사용해서 Exception을 발생
async def main3(): try: async with asyncio.TaskGroup() as tg: task1 = tg.create_task( raise_type_error() ) task2 = tg.create_task( raise_value_error() ) except (TypeError, ValueError) as e: print(e) >>> # except로 에러 핸들링이 되지 않습니다. | ExceptionGroup: unhandled errors in a TaskGroup (2 sub-exceptions) +-+---------------- 1 ---------------- | Traceback (most recent call last): | File "/Users/rumbarum/Library/Application Support/JetBrains/PyCharmCE2023.1/scratches/scratch_5.py", line 14, in raise_type_error | raise TypeError("This is TypeError") | TypeError: This is TypeError +---------------- 2 ---------------- | Traceback (most recent call last): | File "/Users/rumbarum/Library/Application Support/JetBrains/PyCharmCE2023.1/scratches/scratch_5.py", line 18, in raise_value_error | raise ValueError("This is ValueError") | ValueError: This is ValueError +------------------------------------
Python
복사

except Exception(BaseException) 적용이 될까요?

async def main6(): try: async with asyncio.TaskGroup() as tg: task1 = tg.create_task( raise_type_error() ) task2 = tg.create_task( raise_value_error() ) except Exception as e: print(e) if __name__ == "__main__": asyncio.run(main6()) >>> unhandled errors in a TaskGroup (2 sub-exceptions)
Python
복사
적용이 되지 않습니다. 오로지 except GroupException(BaseGroupException)으로만 try except 적용이 됩니다.
async def main7(): try: async with asyncio.TaskGroup() as tg: task1 = tg.create_task( raise_type_error() ) task2 = tg.create_task( raise_value_error() ) except ExceptionGroup as e: for err in e.exceptions: print(err) >>> This is TypeError This is ValueError
Python
복사

ExeceptionGroup Handling

Catching Error

except*: 새로운 키워드 등장
try: ... except* SpamError: ... except* FooError as e: ... except* (BarError, BazError) as e: ...
Python
복사
기존 try-Exception 문에는 처리할 예외가 하나만 있으므로 최대 하나의 Except 절 본문( 예외와 일치하는 첫 번째 항목) 이 실행됩니다.
새로운 구문을 사용하면 Except* 절이 발생한 예외 그룹의 하위 그룹과 일치할 수 있으며, 나머지 부분은 다음 Except* 절과 일치됩니다. 즉, 단일 예외 그룹으로 인해 여러 개의 Except* 절이 실행될 수 있지만 이러한 각 절은 최대 한 번(그룹에서 일치하는 모든 예외에 대해) 실행되고 각 예외는 정확히 하나의 절(일치하는 첫 번째 절)에 의해 처리되거나 또는 마지막에 다시 전파됩니다. try-Exception* 블록에서 각 예외를 처리하는 방식은 그룹의 다른 예외와 독립적입니다.
Except* 절의 순서는 일반 try..out 처럼 중요합니다.
>>> try: ... raise ExceptionGroup("problem", [BlockingIOError()]) ... except* OSError as e: # Would catch the error ... print(repr(e)) ... except* BlockingIOError: # Would never run ... print('never') ... ExceptionGroup('problem', [BlockingIOError()])
Python
복사

Reraise ExceptionGroup

raise vs raise e

raise: 기존 ExceptionGroup(eg)에 동일한 구조로 병합됩니다.
>>> try: ... try: ... raise ExceptionGroup( ... "eg", ... [ ... ValueError(1), ... TypeError(2), ... OSError(3), ... ExceptionGroup( ... "nested", ... [OSError(4), TypeError(5), ValueError(6)]) ... ] ... ) ... except* ValueError as e: ... print(f'*ValueError: {e!r}') ... raise ... except* OSError as e: ... print(f'*OSError: {e!r}') ... except ExceptionGroup as e: ... print(repr(e)) ... *ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])]) *OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])]) ExceptionGroup('eg', [ValueError(1), TypeError(2), ExceptionGroup('nested', [TypeError(5), ValueError(6)])]) >>>
Python
복사
raise e: 기존 ExceptionGroup과 같은 text를 가진 다른 ExceptionGroup을 raise 합니다. (기존 ExceptionGroup에서는 분리되었습니다.)
>>> try: ... raise ExceptionGroup( ... "eg", ... [ ... ValueError(1), ... TypeError(2), ... OSError(3), ... ExceptionGroup( ... "nested", ... [OSError(4), TypeError(5), ValueError(6)]) ... ] ... ) ... except* ValueError as e: ... print(f'*ValueError: {e!r}') ... raise e ... except* OSError as e: ... print(f'*OSError: {e!r}') ... raise ... *ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])]) *OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])]) | ExceptionGroup: (2 sub-exceptions) +-+---------------- 1 ---------------- | Exception Group Traceback (most recent call last): | File "<stdin>", line 15, in <module> | File "<stdin>", line 2, in <module> | ExceptionGroup: eg (2 sub-exceptions) +-+---------------- 1 ---------------- | ValueError: 1 +---------------- 2 ---------------- | ExceptionGroup: nested (1 sub-exception) +-+---------------- 1 ---------------- | ValueError: 6 +------------------------------------ +---------------- 2 ---------------- | Exception Group Traceback (most recent call last): | File "<stdin>", line 2, in <module> | ExceptionGroup: eg (3 sub-exceptions) +-+---------------- 1 ---------------- | TypeError: 2 +---------------- 2 ---------------- | OSError: 3 +---------------- 3 ---------------- | ExceptionGroup: nested (2 sub-exceptions) +-+---------------- 1 ---------------- | OSError: 4 +---------------- 2 ---------------- | TypeError: 5 +------------------------------------ >>>
Python
복사

Raise Naked Exception(non-exception-group exception) from ExceptionGroup

PEP-654에서는 ExceptionGroup 이 아닌 Exception을 Naked Exception으로 정의합니다.
Naked Exception을 raise 하는 3가지 방법을 비교해봅시다.
1.
raise NakedException from e
2.
raise NakedException from None
3.
raise NakedException
raise NakedException from e
Naked Exception이 전파되고, ExceptionGroup이 Traceback에 표현 됩니다.
>>> try: ... raise TypeError('bad type') ... except* TypeError as e: ... raise ValueError('bad value') from e ... | ExceptionGroup: (1 sub-exception) +-+---------------- 1 ---------------- | Traceback (most recent call last): | File "<stdin>", line 2, in <module> | TypeError: bad type +------------------------------------ The above exception was the direct cause of the following exception: Traceback (most recent call last): File "<stdin>", line 4, in <module> ValueError: bad value >>>
Python
복사
raise NakedException from None
Naked Exception이 전파되고, ExceptionGroup이 Traceback에 나타나지 않습니다.
>>> try: ... raise TypeError(1) ... except* TypeError: ... raise ValueError(2) from None # <- not caught in the next clause ... except* ValueError: ... print('never') ... Traceback (most recent call last): File "<stdin>", line 4, in <module> ValueError: 2 >>>
Python
복사
raise NakedException
기존 EG에 NakedException 이 머지 됩니다.
>>> try: ... raise ExceptionGroup("eg", [ValueError('a')]) ... except* ValueError: ... raise KeyError('x') ... | ExceptionGroup: (1 sub-exception) +-+---------------- 1 ---------------- | Exception Group Traceback (most recent call last): | File "<stdin>", line 2, in <module> | ExceptionGroup: eg (1 sub-exception) +-+---------------- 1 ---------------- | ValueError: a +------------------------------------ | | During handling of the above exception, another exception occurred: | | Traceback (most recent call last): | File "<stdin>", line 4, in <module> | KeyError: 'x' +------------------------------------ >>>
Python
복사

except* 에서 Naked Exception raise 하게되면? 싱글 Exception 으로 상위 전파가 될까요?

가능은 합니다. 다만 상황에 따라 전파되는 Exception의 종류가 달라집니다.
Exception 한개만 raise 될 경우 단일 에러로 위로 전파되고, except Exception으로 처리가 가능합니다.
async def main8(): try: try: raise ExceptionGroup( "msg", [ ValueError('a'), TypeError('b'), TypeError('c'), KeyError('e') ] ) except* ValueError as e: print(f'got some ValueErrors: {e!r}') raise ValueError from None except* TypeError as e: print(f'got some TypeErrors: {e!r}') except* KeyError as e: print(f'got some KeyErrors: {e!r}') except ExceptionGroup as e: print(f'propagated: {e!r}') except ValueError as ve: print(f"ValueError: {ve!r}") if __name__ == "__main__": asyncio.run(main8() >>> got some ValueErrors: ExceptionGroup('msg', [ValueError('a')]) got some TypeErrors: ExceptionGroup('msg', [TypeError('b'), TypeError('c')]) got some KeyErrors: ExceptionGroup('msg', [KeyError('e')]) + Exception Group Traceback (most recent call last): | File "/Users/rumbarum/Library/Application Support/JetBrains/PyCharmCE2023.1/scratches/scratch_5.py", line 136, in main8 | raise ExceptionGroup("msg", | ExceptionGroup: msg (1 sub-exception) +-+---------------- 1 ---------------- | ValueError: a +------------------------------------ The above exception was the direct cause of the following exception: Traceback (most recent call last): ... ValueError ValueError: ValueError()
Python
복사
여러개의 Naked Exception 전파될 경우 ExceptionGroup으로 묶입니다. 이는 except ExceptionGroup으로 처리를 해야 합니다.
async def main8(): try: try: raise ExceptionGroup("msg", [ValueError('a'), TypeError('b'), TypeError('c'), KeyError('e')]) except* ValueError as e: print(f'got some ValueErrors: {e!r}') raise ValueError from e except* TypeError as e: print(f'got some TypeErrors: {e!r}') raise TypeError from None except* KeyError as e: print(f'got some KeyErrors: {e!r}') raise KeyError from e except ExceptionGroup as e: print(f'propagated: {e!r}') if __name__ == "__main__": asyncio.run(main8()) >>> got some ValueErrors: ExceptionGroup('msg', [ValueError('a')]) got some TypeErrors: ExceptionGroup('msg', [TypeError('b'), TypeError('c')]) got some KeyErrors: ExceptionGroup('msg', [KeyError('e')]) propagated: ExceptionGroup('', [ValueError(), TypeError(), KeyError()])
Python
복사

사용시 주의 사항

'break', 'continue' and 'return' cannot appear in an except* block
except*를 탈출하는 키워드는 사용이 불가합니다.
try: ... except* SomeError as e: ... return a, b # <= SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
Python
복사
except* 에 걸리는 에러 한번만 처리하는 것이 아닙니다. 다른 except* 를 다 거쳐갑니다.
>>> try: ... try: ... raise ExceptionGroup( ... "msg", [ ... ValueError('a'), TypeError('b'), ... TypeError('c'), KeyError('e') ... ] ... ) ... except* ValueError as e: ... print(f'got some ValueErrors: {e!r}') ... except* TypeError as e: ... print(f'got some TypeErrors: {e!r}') ... except ExceptionGroup as e: ... print(f'propagated: {e!r}') ... got some ValueErrors: ExceptionGroup('msg', [ValueError('a')]) got some TypeErrors: ExceptionGroup('msg', [TypeError('b'), TypeError('c')]) propagated: ExceptionGroup('msg', [KeyError('e')]) >>>
Python
복사
except와 except* 혼용 불가합니다.
try: ... except ValueError: pass except* CancelledError: # <- SyntaxError: pass # combining ``except`` and ``except*`` # is prohibited
Python
복사
except* ExceptionGroup 불가합니다.
try: ... except ExceptionGroup: # <- This works pass try: ... except* ExceptionGroup: # <- Runtime error pass try: ... except* (TypeError, ExceptionGroup): # <- Runtime error pass
Python
복사
empty except* 불가합니다.
try: ... except*: # <- SyntaxError pass
Python
복사

ExceptionGroup 사용 전략

ExceptionGroup을 사용하는 상황은 크게 2가지로 나눠볼 수 있습니다. 상황별 대응 전략을 정리합니다.
단일 종류 Exception들이 ExceptionGroup으로 묶임
except* 으로 Exception을 받아서 NakedException으로 진행하면 기존 try-except 처리와 같은 구조로 만들 수 있습니다.
동일 Task를 여러개 돌리더라도, 서로 다른 에러가 발생 할 수 있음을 생각하면 이와 같은 접근은 사실상 어려울 걸로 보입니다.
여러 Task 중 먼저 발생한 에러가 다른 Task의 동작을 멈추기 때문에 비슷한 시간이 걸리는 작업이라면 동일 코드에서 발생하는 에러가 달라질 수 있습니다.
다종 Exception이 ExceptionGroup으로 묶임
except* 으로 Exception을 받아서 NakedException을 raise 하더라도 다시 ExceptionGroup이 만들어 지기 때문에 ExceptionGroup이 예상 되는 곳에서는 except ExceptionGroup에 대한 처리 로직을 추가 해야 합니다.
생각해볼것
TaskGroup 사용시 TaskGroup 스코프 안에서 handling 하는 것이 제일 좋아보입니다.
이유는 Exception 처리 안되면, 다른 Task가 Cancel되는데 비슷한 시간이 걸리는 Task를 동작시키면 어느것이 먼저 끝나냐에 따라 결과가 달라집니다.
그리고 에러 로직과 별개의 Cancelled Error가 추가됩니다.
"""Asynchronous context manager for managing groups of tasks. Example use: async with asyncio.TaskGroup() as group: task1 = group.create_task(some_coroutine(...)) task2 = group.create_task(other_coroutine(...)) print("Both tasks have completed now.") All tasks are awaited when the context manager exits. Any exceptions other than `asyncio.CancelledError` raised within a task will cancel all remaining tasks and wait for them to exit. The exceptions are then combined and raised as an `ExceptionGroup`. """
Python
복사
대응
공통 Exception
하나라도 에러나면 안되는 경우
try: async with asyncio.TaskGroup() as tg: task1 = tg.create_task(coro1()) task2 = tg.create_task(coro2()) task1_result = task1.result() task2_result = task2.result() except ExceptionGroup as eg: # EG에서 우선 순위 에러별로 처리하는 방법도 있습니다. for err in eg.exceptions: print(f"{err|r}")
Python
복사
에러에 따른 액션 필요한 경우
except* 순서로 조절
1.
except* 만으로는 대응액션이 불가능합니다. (에러가 발생했다는 기록, 에러를 저장객체에 저장만 가능함)
2.
except* 에 안걸리는 것이 있을 경우 다시 ExceptionGroup이 전파됩니다.
try: async with asyncio.TaskGroup() as tg: task1 = tg.create_task(coro1()) task2 = tg.create_task(coro2()) task1_result = task1.result() task2_result = task2.result() except* ValueError as ve: ... except* KeyError as ve: ... except* Exception as ve: ...
Python
복사
코루틴에서 Error를 raise가 아닌, return, 해당 Error 대응
저는 아래와 같은 방식으로 진행을 했습니다. task1의 에러로 task2가 종료되면, task2가 정상인지 아닌지 확인이 안되기 때문에 에러가 나도 진행하고 에러를 리턴하고 리턴된 에러를 가지고 작업을 진행했습니다.
async with asyncio.TaskGroup() as tg: task1 = tg.create_task(coro1()) task2 = tg.create_task(coro2()) task1_result, task1_err = task1.result() task2_result, task2_err = task2.result() if task1_err is not None: ... if task2_err is not None: ...
Python
복사
Reference.