Skip to content
aviral gupta

// A3.5 · ~40 min · Advanced

TaskGroup, timeouts and cancellation

After this lesson you run related tasks in a TaskGroup that cancels the rest when one fails, handle the resulting ExceptionGroup, put time limits on waits and let cancellation pass through your clean-up code.

Lesson 5 of 6 in A3 Concurrency

You will be able to

  • Run related tasks in a TaskGroup and read their results
  • Handle the ExceptionGroup a failing TaskGroup raises, with except*
  • Limit waits with asyncio.timeout and clean up correctly when a task is cancelled
  • Cap concurrency with asyncio.Semaphore and signal between tasks with asyncio.Event
  1. Warm-up · Activity 1 of 9

    Warm-up from the last lesson: bad raises while slow is still waiting inside gather. What does this print?

    import asyncio
    
    
    async def slow():
        await asyncio.sleep(0.01)
        print("slow finished", end="; ")
    
    
    async def bad():
        raise ValueError("bad input")
    
    
    async def main():
        try:
            await asyncio.gather(slow(), bad())
        except ValueError as error:
            print("caught:", error, end="; ")
        await asyncio.sleep(0.02)
        print("end")
    
    
    asyncio.run(main())
  2. Predict · Activity 2 of 9

    Predict before you read on. The same two coroutines now run in a TaskGroup. What does this print?

    import asyncio
    
    
    async def slow():
        try:
            await asyncio.sleep(1)
            print("slow finished")
        except asyncio.CancelledError:
            print("slow cancelled", end="; ")
            raise
    
    
    async def bad():
        await asyncio.sleep(0)
        raise ValueError("bad input")
    
    
    async def main():
        try:
            async with asyncio.TaskGroup() as tg:
                tg.create_task(slow())
                tg.create_task(bad())
        except* ValueError as group:
            print("caught:", group.exceptions)
    
    
    asyncio.run(main())
  3. Practice · Activity 3 of 9

    Fill in the asyncio class whose async with block waits for every task created through it.

    import asyncio
    
    
    async def work(n):
        await asyncio.sleep(0)
        return n * 10
    
    
    async def main():
        async with asyncio.____() as tg:
            task = tg.create_task(work(4))
        print(task.result())
    
    
    asyncio.run(main())
    async with asyncio.() as tg:
  4. Practice · Activity 4 of 9

    The sleep takes far longer than the time limit. What does this print?

    import asyncio
    
    
    async def main():
        try:
            async with asyncio.timeout(0.01):
                await asyncio.sleep(1)
                print("slept")
        except TimeoutError:
            print("timed out", end="; ")
        print("carry on")
    
    
    asyncio.run(main())
  5. Practice · Activity 5 of 9

    Match each tool to what it does.

  6. Practice · Activity 6 of 9

    Five tasks share one Semaphore(2). peak records the most jobs inside it at the same moment. What does this print?

    import asyncio
    
    running = 0
    peak = 0
    
    
    async def job(sem):
        global running, peak
        async with sem:
            running += 1
            peak = max(peak, running)
            await asyncio.sleep(0.01)
            running -= 1
    
    
    async def main():
        sem = asyncio.Semaphore(2)
        async with asyncio.TaskGroup() as tg:
            for _ in range(5):
                tg.create_task(job(sem))
        print(peak, sem.locked())
    
    
    asyncio.run(main())
  7. Practice · Activity 7 of 9

    Two tasks wait for the event. Fill in the Event method that wakes them both.

    import asyncio
    
    
    async def waiter(event, log):
        await event.wait()
        log.append("woke")
    
    
    async def main():
        event = asyncio.Event()
        log = []
        tasks = [asyncio.create_task(waiter(event, log)) for _ in range(2)]
        await asyncio.sleep(0)  # both waiters are now waiting
        event.____()
    event.()
  8. Brain teaser · Activity 8 of 9

    Teaser. There is an except TimeoutError inside the block and one outside it. What does this print?

    import asyncio
    
    
    async def main():
        try:
            async with asyncio.timeout(0.01):
                try:
                    await asyncio.sleep(1)
                except TimeoutError:
                    print("inner", end="; ")
        except TimeoutError:
            print("outer")
    
    
    asyncio.run(main())
  9. Apply · Activity 9 of 9

    Mini task, on your own machine. fetch(name, delay) awaits asyncio.sleep(delay) and returns name.upper(). Write fetch_or_none(name, delay, limit), which returns None when fetch takes longer than limit seconds. In main, run it in a TaskGroup for ("home", 0.01), ("slow", 1) and ("blog", 0.02) with a limit of 0.05 s and print the results in that order.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A group of downloads, a failure and a time limit

fetch pretends to download a page by sleeping; a page that is not in DELAYS raises KeyError, and a cancelled fetch reports it and re-raises. fetch_group runs its pages in a TaskGroup. main runs three cases: every page arrives, one page is missing, and a slow page meets a 0.1 s timeout. Save it as main.py and run python main.py (python3 main.py on macOS and Linux) on your own machine.

main.py

import asyncio

# Seconds each page takes to "download"; a page not listed here does not exist.
DELAYS = {"home": 0.01, "about": 0.02, "blog": 0.03, "archive": 1.0}


async def fetch(name: str) -> str:
    """Pretend to download a page. A missing page raises KeyError."""
    try:
        await asyncio.sleep(DELAYS[name])
    except asyncio.CancelledError:
        print(f"  {name}: cancelled")
        raise  # clean-up done: let the cancellation continue
    return name.upper()


async def fetch_group(names: list[str]) -> list[str]:
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(name)) for name in names]
    # Leaving the block means every task has finished.
    return [task.result() for task in tasks]


async def main() -> None:
    print("all pages:", await fetch_group(["home", "about", "blog"]))

    print("one page missing:")
    try:
        await fetch_group(["about", "missing"])
    except* KeyError as group:
        print("  failed:", group.exceptions)

    print("a page that is too slow:")
    try:
        async with asyncio.timeout(0.1):
            await fetch_group(["home", "archive"])
    except TimeoutError:
        print("  timed out after 0.1 s")


if __name__ == "__main__":
    asyncio.run(main())

Run it with

python main.py

Output

all pages: ['HOME', 'ABOUT', 'BLOG']
one page missing:
  about: cancelled
  failed: (KeyError('missing'),)
a page that is too slow:
  archive: cancelled
  timed out after 0.1 s
  • The missing page failed at once, so the group cancelled about while it was still sleeping.
  • except* KeyError received an ExceptionGroup; group.exceptions holds the single KeyError.
  • home finished within 0.1 s; archive was still sleeping, so the timeout cancelled it, and the group passed the cancellation on.
  • fetch catches CancelledError only to report it and then raises it again, so the TaskGroup and the timeout keep working.

Exercises

Exercise 1 of 3

All or nothing

fetch_all uses gather, so when one fetch fails the others keep running and a bare ValueError comes out. Rewrite it with an asyncio.TaskGroup: one task per name, a dict from each name to its text in the order of names, and on a failure the other fetches cancelled and an ExceptionGroup raised. Run the tests on your own machine.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Open async with asyncio.TaskGroup() as tg: and create the tasks inside it with tg.create_task(fetch(name)).

  2. Hint 2

    A dict comprehension {name: tg.create_task(fetch(name)) for name in names} keeps each task with its name.

  3. Hint 3

    After the block, every task is done: build the result with task.result(). The TaskGroup itself does the cancelling and raises the ExceptionGroup.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import asyncio
from collections.abc import Callable, Coroutine
from typing import Any


async def fetch_all(names: list[str], fetch: Callable[[str], Coroutine[Any, Any, str]]) -> dict[str, str]:
    """Fetch every name in a TaskGroup; map each name to its text, in the order of names.

    If a fetch fails, the others are cancelled and an ExceptionGroup is raised.
    """
    async with asyncio.TaskGroup() as tg:
        tasks = {name: tg.create_task(fetch(name)) for name in names}
    return {name: task.result() for name, task in tasks.items()}
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import asyncio
from collections.abc import Callable, Coroutine
from typing import Any


async def fetch_all(names: list[str], fetch: Callable[[str], Coroutine[Any, Any, str]]) -> dict[str, str]:
    """Fetch every name in a TaskGroup; map each name to its text, in the order of names.

    If a fetch fails, the others are cancelled and an ExceptionGroup is raised.
    """
    # gather leaves the other fetches running when one fails.
    # Create one task per name in an asyncio.TaskGroup instead.
    texts = await asyncio.gather(*(fetch(name) for name in names))
    return dict(zip(names, texts))

test_main.py

import asyncio

from main import fetch_all


def test_results():
    """Every name maps to its text, in the order of names"""

    async def fake_fetch(name):
        await asyncio.sleep(0.02 if name == "slow" else 0)
        return name.upper()

    got = asyncio.run(fetch_all(["slow", "fast"], fake_fetch))
    assert list(got.items()) == [("slow", "SLOW"), ("fast", "FAST")], f"fetch_all returned {got!r}"


def test_failure_is_a_group():
    """A failing fetch surfaces as an ExceptionGroup holding its ValueError"""

    async def fake_fetch(name):
        await asyncio.sleep(0)
        if name == "bad":
            raise ValueError("bad page")
        return name

    try:
        asyncio.run(fetch_all(["ok", "bad"], fake_fetch))
        error = None
    except Exception as caught:
        error = caught
    assert isinstance(error, ExceptionGroup), f"fetch_all raised {error!r}, not an ExceptionGroup"
    assert [str(e) for e in error.exceptions] == ["bad page"], f"the group holds {error.exceptions!r}"


def test_others_cancelled():
    """When one fetch fails, the slow one is cancelled before fetch_all raises"""
    cancelled = []

    async def fake_fetch(name):
        if name == "bad":
            await asyncio.sleep(0)
            raise ValueError("bad page")
        try:
            await asyncio.sleep(1)
        except asyncio.CancelledError:
            cancelled.append(name)
            raise
        return name

    async def run():
        try:
            await fetch_all(["slow", "bad"], fake_fetch)
        except Exception:
            pass
        return list(cancelled)

    seen = asyncio.run(run())
    assert seen == ["slow"], f"cancelled when fetch_all raised: {seen!r}; use a TaskGroup"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Catch the timeout in the right place

fetch_each runs every fetch in a TaskGroup, each under its own asyncio.timeout(limit), and should give "timeout" for a page that takes too long. But a slow page makes the whole group fail with an ExceptionGroup holding a TimeoutError. Fix fetch_one so that a slow page gives "timeout" and the other pages still arrive. Run the tests on your own machine.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Inside the async with asyncio.timeout block, a timeout shows up as CancelledError at the await.

  2. Hint 2

    The timeout context manager raises TimeoutError as the block exits, so only code outside the block can catch it.

  3. Hint 3

    Put try: around the whole async with asyncio.timeout(limit): block, and return "timeout" in except TimeoutError:.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import asyncio
from collections.abc import Awaitable, Callable


async def fetch_one(fetch: Callable[[str], Awaitable[str]], name: str, limit: float) -> str:
    """Return fetch(name), or "timeout" if it takes longer than limit seconds."""
    try:
        async with asyncio.timeout(limit):
            return await fetch(name)
    except TimeoutError:  # raised as the async with block exits
        return "timeout"


async def fetch_each(names: list[str], fetch: Callable[[str], Awaitable[str]], limit: float) -> list[str]:
    """Fetch every name in a TaskGroup, each with its own time limit; results in the order of names."""
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_one(fetch, name, limit)) for name in names]
    return [task.result() for task in tasks]
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import asyncio
from collections.abc import Awaitable, Callable


async def fetch_one(fetch: Callable[[str], Awaitable[str]], name: str, limit: float) -> str:
    """Return fetch(name), or "timeout" if it takes longer than limit seconds."""
    async with asyncio.timeout(limit):
        try:
            return await fetch(name)
        except TimeoutError:  # never true here: inside the block the fetch sees CancelledError
            return "timeout"


async def fetch_each(names: list[str], fetch: Callable[[str], Awaitable[str]], limit: float) -> list[str]:
    """Fetch every name in a TaskGroup, each with its own time limit; results in the order of names."""
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_one(fetch, name, limit)) for name in names]
    return [task.result() for task in tasks]

test_main.py

import asyncio

from main import fetch_each

DELAYS = {"home": 0, "blog": 0.01, "archive": 1}


async def fake_fetch(name):
    await asyncio.sleep(DELAYS[name])
    return name.upper()


def test_all_in_time():
    """Pages that arrive in time give their text"""
    got = asyncio.run(fetch_each(["home", "blog"], fake_fetch, 0.5))
    assert got == ["HOME", "BLOG"], f"fetch_each returned {got!r}"


def test_slow_page_times_out():
    """A page slower than the limit gives "timeout", and the others still arrive"""
    try:
        got = asyncio.run(fetch_each(["home", "archive", "blog"], fake_fetch, 0.05))
    except Exception as error:
        got = error
    assert got == ["HOME", "timeout", "BLOG"], f"fetch_each gave {got!r}: where is the TimeoutError raised?"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

No more than limit at once

fetch_limited starts every fetch at once, which could overload a server. Change it so that at most limit fetches run at the same time, with one asyncio.Semaphore shared by all of them. The results stay in the order of names. On your own machine, python main.py shows the fetches starting two at a time, and python learnrun.py test runs the tests.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Create slots = asyncio.Semaphore(limit) once, in fetch_limited, before the TaskGroup.

  2. Hint 2

    In fetch_one, put the await inside async with slots:, so a fetch holds a slot while it runs.

  3. Hint 3

    A Semaphore created inside fetch_one would be a new one for every fetch and would limit nothing.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import asyncio
from collections.abc import Awaitable, Callable


async def fetch_limited(names: list[str], fetch: Callable[[str], Awaitable[str]], limit: int) -> list[str]:
    """Fetch every name in a TaskGroup, at most limit at a time; results in the order of names."""
    slots = asyncio.Semaphore(limit)  # one semaphore, shared by every fetch

    async def fetch_one(name: str) -> str:
        async with slots:
            return await fetch(name)

    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_one(name)) for name in names]
    return [task.result() for task in tasks]


async def demo_fetch(name: str) -> str:
    print("start", name)
    await asyncio.sleep(0.1)
    print("end", name)
    return name.upper()


if __name__ == "__main__":
    print(asyncio.run(fetch_limited(["home", "about", "blog", "shop"], demo_fetch, 2)))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import asyncio
from collections.abc import Awaitable, Callable


async def fetch_limited(names: list[str], fetch: Callable[[str], Awaitable[str]], limit: int) -> list[str]:
    """Fetch every name in a TaskGroup, at most limit at a time; results in the order of names."""
    # TODO: every fetch starts at once. Create one asyncio.Semaphore(limit)
    # here and hold it in fetch_one around the await.

    async def fetch_one(name: str) -> str:
        return await fetch(name)

    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_one(name)) for name in names]
    return [task.result() for task in tasks]


async def demo_fetch(name: str) -> str:
    print("start", name)
    await asyncio.sleep(0.1)
    print("end", name)
    return name.upper()


if __name__ == "__main__":
    print(asyncio.run(fetch_limited(["home", "about", "blog", "shop"], demo_fetch, 2)))

test_main.py

import asyncio

from main import fetch_limited


def counting_fetch():
    """A fake fetch that records how many calls are waiting at the same time."""
    state = {"running": 0, "peak": 0}

    async def fetch(name):
        state["running"] += 1
        state["peak"] = max(state["peak"], state["running"])
        await asyncio.sleep(0.01)
        state["running"] -= 1
        return name.upper()

    return fetch, state


def test_results_in_order():
    """Every name gives its text, in the order of names"""
    fetch, _ = counting_fetch()
    got = asyncio.run(fetch_limited(["home", "about", "blog"], fetch, 2))
    assert got == ["HOME", "ABOUT", "BLOG"], f"fetch_limited returned {got!r}"


def test_limit_is_kept():
    """With limit 2, never more than 2 fetches run at once"""
    fetch, state = counting_fetch()
    asyncio.run(fetch_limited(["a", "b", "c", "d", "e"], fetch, 2))
    assert state["peak"] == 2, f"{state['peak']} fetches ran at once, expected at most 2: hold one shared Semaphore around each fetch"


def test_limit_is_used():
    """With limit 3, three fetches do run at the same time"""
    fetch, state = counting_fetch()
    asyncio.run(fetch_limited(["a", "b", "c", "d", "e"], fetch, 3))
    assert state["peak"] == 3, f"{state['peak']} fetches ran at once, expected 3: create the Semaphore once, not in every fetch"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Catching a TaskGroup failure with a plain except

import asyncio


async def bad() -> None:
    raise ValueError("bad input")


async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(bad())
    except ValueError as error:
        print("caught:", error)


asyncio.run(main())

What Python prints

ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)

Why, and the fix

A TaskGroup always raises an ExceptionGroup, even for a single failure, and except ValueError does not match a group. Write except* ValueError as group: and read the errors from group.exceptions.

Adding a task after the group has finished

import asyncio


async def work(n: int) -> int:
    await asyncio.sleep(0)
    return n * 10


async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        first = tg.create_task(work(1))
    second = tg.create_task(work(2))
    print(first.result(), await second)


asyncio.run(main())

What Python prints

RuntimeError: TaskGroup <TaskGroup entered> is finished

Why, and the fix

Once the async with block has exited, the group accepts no new tasks. Create every task inside the block, indented under it, or open a new TaskGroup for later work.

Using asyncio.timeout with plain with

import asyncio


async def main() -> None:
    with asyncio.timeout(0.1):
        await asyncio.sleep(0.01)
    print("done")


asyncio.run(main())

What Python prints

TypeError: 'asyncio.timeouts.Timeout' object does not support the context manager protocol

Why, and the fix

asyncio.timeout returns an asynchronous context manager, and so does asyncio.TaskGroup: both need async with, inside a coroutine. The error message itself suggests the fix.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

TaskGroup: no task outlives the block

async with asyncio.TaskGroup() as tg: opens a group; tg.create_task(coro()) adds a task to it. Leaving the async with block waits for every task in the group, including tasks added while waiting, so after the block each task.result() is ready. Once the block has exited, the group accepts no new tasks. Compared with create_task and gather from the last lesson, a TaskGroup ties the tasks to one block of code: none is left running or forgotten when the block ends.

One failure cancels the rest

The first time a task in the group fails with an exception other than CancelledError, the group cancels its remaining tasks, and the body of the async with too if it is still running. When all tasks have finished, the failures are combined in an ExceptionGroup, which the block raises. A plain except ValueError does not match an ExceptionGroup. Use except* ValueError as group: it matches the ValueErrors inside the group, and group.exceptions holds them. Several except* clauses can each handle their own type.

Timeouts are cancellations

async with asyncio.timeout(0.5): limits the waiting inside the block. When the deadline passes, the current task is cancelled: the await in progress raises CancelledError, and the context manager turns it into TimeoutError as the block exits, so you catch TimeoutError outside the block. Cancellation, from task.cancel(), a timeout or a TaskGroup, arrives as CancelledError at the next await. Clean up in finally, or catch CancelledError and raise it again: it subclasses BaseException, so except Exception does not swallow it by accident.

Semaphores cap, events signal

asyncio.Semaphore(3) lets at most three tasks through at once. async with sem: acquires it on entry and releases it on exit: each acquire lowers an internal counter, each release raises it, and a task that finds it at zero waits until another task releases. Create one semaphore and share it; a semaphore per task limits nothing. asyncio.Event() lets tasks wait for news: await event.wait() blocks until another task calls event.set(), which wakes every waiter at once. If the event is already set, wait() returns True at once; event.is_set() checks without waiting.

Sources

Last reviewed September 29, 2026