Skip to content

← Concurrency with Threads and asyncio step 28 of 56

Medium Primitives

Fetch Many Pages with a TaskGroup

Write two functions that fetch many keys at the same time, with different failure policies. fetch_all is all or nothing. fetch_all_lenient returns partial results.

await fetch_all(fetch, ["a", "b", "a"])
# {"a": b"payload:a", "b": b"payload:b"}, and "a" is fetched once

await fetch_all_lenient(fetch, ["a", "b", "c"])
# if "b" fails: ({"a": b"payload:a", "c": b"payload:c"}, {"b": ValueError(...)})

Rules for both functions

async def fetch_all[T](
    fetch: Callable[[str], Awaitable[T]], keys: Sequence[str]
) -> dict[str, T]

async def fetch_all_lenient[T](
    fetch: Callable[[str], Awaitable[T]], keys: Sequence[str]
) -> tuple[dict[str, T], dict[str, BaseException]]
  • Fetch each distinct key once, all at the same time, in one asyncio.TaskGroup.
  • Build each returned dictionary in first-seen key order, with only the keys that belong in it.
  • fetch returns an Awaitable, but create_task needs a coroutine. Write a small async def wrapper that awaits fetch(key), and start the wrapper as the task. With the wrapper, an error from calling fetch(key) also stays inside the task.
  • Keep T in your types.
  • Empty keys returns {} from fetch_all and ({}, {}) from fetch_all_lenient.

fetch_all is all or nothing

  • If a fetch raises, the group cancels the other fetches, waits for them, and raises an ExceptionGroup. Let it propagate.
  • On success, read each value with task.result() after the async with block.
  • If a fetch raises CancelledError itself, its task ends cancelled and the group carries on. Reading that task's result raises CancelledError. Let it propagate rather than leave the key out.

fetch_all_lenient returns partial results

  • Every fetch runs to the end, even when another one fails.
  • Return (values, errors). values maps each key that succeeded to its value, and errors maps each key that failed to the exception it raised. An exception object that fetch returns is a value.
  • Catch BaseException in the wrapper, so that a CancelledError a fetch raises itself goes into errors too.
  • If the caller is cancelled, cancel the fetches, wait for them, and let the cancellation propagate instead of returning. A TaskGroup around the wrappers does this for you.

What the driver reports

solve builds a test fetch. The fetch for each key waits its hops count of loop turns, then returns b"payload:" + key, or raises ValueError(f"fetch {key} failed") if the key is in failing. A negative hop count makes the fetch raise CancelledError(f"fetch {key} cancelled") itself. For a repeated key, the driver uses the last hop count given for it.

  • values holds the values returned, and stays empty if the call raised.
  • errors shows each failure from fetch_all_lenient as "Type: message".
  • started lists the keys whose fetch began, in order. cancelled lists, in sorted order, the keys whose fetch saw a CancelledError.
  • outcome is "ok", "ExceptionGroup", or another exception's name. For a group, group_size counts its exceptions and group_types lists their sorted type names.
  • pending counts tasks still running after the call, apart from the driver. It must be 0.