openlang

`except ValueError` stopped catching after the move to TaskGroup

Moved a small scoring script from asyncio.gather to asyncio.TaskGroup, and the fallback path stopped working. Before the change, an empty weights response raised ValueError in the fetch, the except caught it, and the score was computed with default weights. After the change the same failure goes straight past the handler.

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())

On 3.13 this never prints. The traceback ends with ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception), and the ValueError sits inside it.

So the exception did arrive. It arrived wrapped, even with only one task failing. Before touching the handler I want to understand whether a TaskGroup always wraps like this, or only when more than one task could fail.

The conversation continues 5

Follow the reasoning
  1. Permalink

    Always, as far as I can tell. TaskGroup raises a group whenever any member fails, a single failure included, so a plain except ValueError around it can't match. The tool for this is except*.

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

    That prints ExceptionGroup (ValueError('weights missing'),). The part that tripped me is the name. Inside except* the target is the subgroup that matched, never the bare ValueError, so str(eg) and attribute lookups behave differently from the old handler. Anything you used to read off the exception you now read off eg.exceptions[0], or loop over them.

    Also you can't mix except and except* in one try. I keep forgetting that one, and I'm not sure the error message would have told me why.

  2. Permalink

    That's also why the old version worked. gather without return_exceptions re-raises the first exception as it is, a bare ValueError, no group. So the switch changed the exception type, not just the scheduling. Caveat, gather also left the other task running when that happened.

  3. Permalink

    Switched to except* and the fallback runs again. One snag on the way. My old handler ended with return default_weights(), and that's a SyntaxError inside except*, along with break and continue. The rule makes sense once you see that a group can match several clauses, so a return from one of them would skip the others.

    The handler now sets a flag, and the fallback happens after the try block, which reads more plainly than before anyway. kpw's point about the type is the part I missed. I'd been treating the move as a scheduling change only.

  4. Permalink

    Curious what the group holds when both fetches fail, so two tasks that raise, one straight away and one after await asyncio.sleep(0.1). len(eg.exceptions) came out 1. The first failure cancels the group, and the sleeping task ends cancelled, which the group doesn't report as an error. Drop the sleep so both raise in their first step and it comes out 2.

    So the group holds the failures that landed before the cancel, not every fetch that would have failed. Fine for a fallback flag. Less fine if anyone starts logging the group as the list of everything that went wrong.

  5. Permalink

    From the toast side, after the switch any str(e) up the stack shows unhandled errors in a TaskGroup (1 sub-exception). Technically true! So the text that reaches the screen has to come from the inner exception.

Other sides of the same question

  1. `String::from_utf8` gives the bytes back when it fails

    A consuming API has to decide what happens to the value when it fails. String::from_utf8 takes the Vec<u8> by value, and on f…

    Replies 0rust
  2. `result.map_error` on every line of a use chain

    use with result.try is my favourite bit of Gleam and I'm starting to resent it a little. Every step calls a different module,…

    Replies 1gleam
  3. Where does the cursor stay after an incomplete record?

    Result tells the caller of a streaming parser whether a record came out. It says nothing about whether the parser moved a cur…