openlang

TaskGroup으로 옮긴 뒤 `except ValueError`가 잡지 못한다

작은 점수 계산 스크립트를 asyncio.gather에서 asyncio.TaskGroup으로 옮겼더니 대체 경로가 동작하지 않아요. 바꾸기 전에는 가중치 응답이 비어 있으면 가져오는 쪽에서 ValueError가 났고, except가 그걸 잡아서 기본 가중치로 점수를 계산했어요. 바꾼 뒤에는 같은 실패가 핸들러를 그냥 지나가요.

import asyncio

async def fetch_weights():
    raise ValueError("weights missing")

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch_weights())
    except ValueError:
        print("fallback weights")

asyncio.run(main())

3.13에서 이건 아무것도 출력하지 않아요. 트레이스백은 ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)로 끝나고, ValueError는 그 안에 들어 있어요.

그러니까 예외는 도착했어요. 실패한 태스크가 하나뿐인데도 감싸진 채로요. 핸들러를 고치기 전에 알고 싶은 건, TaskGroup이 항상 이렇게 감싸는지, 아니면 실패할 수 있는 태스크가 둘 이상일 때만 그런지예요.

이어지는 대화 5

서로의 근거를 따라
  1. 고유 링크

    제가 아는 한 항상 그래요. TaskGroup은 멤버가 하나라도 실패하면, 실패가 하나뿐이어도 그룹을 던져요. 그래서 바깥의 평범한 except ValueError는 맞을 수가 없어요. 이럴 때 쓰는 게 except*예요.

    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fetch_weights())
    except* ValueError as eg:
        print(type(eg).__name__, eg.exceptions)

    출력은 ExceptionGroup (ValueError('weights missing'),)예요. 제가 걸려 넘어진 건 이름 쪽이었어요. except* 안에서 대상은 매칭된 하위 그룹이지 맨 ValueError가 아니라서, str(eg)나 속성 조회가 예전 핸들러와 다르게 동작해요. 예외에서 읽던 건 이제 eg.exceptions[0]에서 읽거나 돌면서 읽어야 해요.

    그리고 한 try 안에 except와 except*를 섞을 수 없어요. 저는 이걸 자꾸 잊는데, 오류 메시지가 이유까지 알려 줬을지는 잘 모르겠어요.

  2. 고유 링크

    예전 버전이 동작한 이유도 그거야. return_exceptions 없는 gather는 첫 예외를 그대로 다시 던져. 그룹 없이 맨 ValueError로. 그러니까 바뀐 건 스케줄링만이 아니라 예외 타입이야. 단서 하나, 그때 gather는 다른 태스크를 계속 돌게 내버려 뒀어.

  3. 고유 링크

    except*로 바꾸니 대체 경로가 다시 돌아요. 가는 길에 걸린 게 하나 있었어요. 예전 핸들러가 return default_weights()로 끝났는데, except* 안에서는 break, continue와 함께 그게 SyntaxError예요. 한 그룹이 여러 절에 매칭될 수 있다는 걸 생각하면 납득이 가요. 한 절에서 return하면 나머지 절을 건너뛰게 되니까요.

    이제 핸들러는 플래그만 세우고, 대체 계산은 try 블록 뒤에서 해요. 오히려 전보다 읽기 편해요. 타입에 대한 kpw 말이 제가 놓친 부분이에요. 이 이동을 스케줄링 변경으로만 보고 있었거든요.

  4. 고유 링크

    둘 다 실패하면 그룹에 뭐가 들어 있을지 궁금해서 태스크 두 개를 만들어 봤어요. 하나는 바로 던지고, 하나는 await asyncio.sleep(0.1) 뒤에 던져요. len(eg.exceptions)는 1이 나왔어요. 첫 실패가 그룹을 취소하고, 자고 있던 태스크는 취소된 채로 끝나는데 그룹은 그걸 오류로 보고하지 않아요. sleep을 빼서 둘 다 첫 단계에서 던지게 하면 2가 나와요.

    그러니까 그룹에 든 건 취소보다 먼저 도착한 실패들이지, 실패했을 모든 fetch가 아니에요. 대체 경로 플래그로는 괜찮아요. 누가 그룹을 잘못된 것 전체의 목록이라고 로그에 남기기 시작하면 덜 괜찮고요.

  5. 고유 링크

    토스트 쪽에서 보면, 바꾼 뒤로는 스택 위 어디서든 str(e)를 하면 unhandled errors in a TaskGroup (1 sub-exception)이 떠요. 틀린 말은 아니죠! 그러니 화면까지 가는 문장은 안쪽 예외에서 나와야 해요.

같은 질문의 다른 갈래

  1. `String::from_utf8`는 실패하면 바이트를 돌려준다

    값을 소비하는 API는 실패했을 때 그 값이 어떻게 되는지 정해야 합니다. String::from_utf8는 Vec<u8>를 값으로 받고, 실패하면 오류가 그것을 소유합니다. 출력은 bad byte at 2, 4 bytes…

    답글 0rust
  2. use 체인 줄마다 붙는 `result.map_error`

    use와 result.try 조합은 Gleam에서 제일 좋아하는 부분인데, 요즘 조금 미워지기 시작했어요. 단계마다 다른 모듈을 부르고 모듈마다 자기 오류 타입이 있으니, 줄마다 result.map_error를 달고 다녀요.…

    답글 1gleam
  3. 불완전한 레코드 뒤에 커서는 어디에 남나요?

    스트리밍 파서의 호출자는 Result로 레코드가 나왔는지 알 수 있습니다. 하지만 파서가 커서를 옮겼는지, 임시 저장소를 건드렸는지, 입력을 그대로 두었는지는 알 수 없습니다. 레코드가 덜 들어왔을 때 중요한 것은 바로 그…