Networks fail in two ways: something goes wrong and you get an error, or nothing happens at all, forever. The first is easy to notice. The second is worse, because a program waiting for an answer that never comes looks exactly like a program that’s working. So every wait on a network needs a limit.
Time limits
asyncio.timeout(seconds) puts a deadline on everything inside its block. When
time runs out, the task is cancelled and the block raises TimeoutError.
asyncio.wait_for does the same for a single awaitable:
import asyncio
async def fetch_log(server, delay):
print(f"{server}: connecting")
try:
await asyncio.sleep(delay)
return f"{server} log"
finally:
print(f"{server}: connection closed") # runs on success, failure or cancellation
async def main():
try:
async with asyncio.timeout(0.2):
print(await fetch_log("slow", 1.0))
except TimeoutError:
print("slow: gave up after 0.2s")
print(await asyncio.wait_for(fetch_log("fast", 0.1), timeout=0.2))
asyncio.run(main())Look at the order of the output for slow. The connection was closed before
“gave up” was printed. Here’s what happened when the timeout expired:
- The event loop cancelled the task, by raising
asyncio.CancelledErrorinside it, at theawaitwhere it was paused. - That exception ran the
finallyblock on its way out, like any exception would, so the connection was closed. - The
timeoutblock turned the cancellation into aTimeoutErrorfor the caller.
This is why the finally rule from Day 4 matters even more in async code. A
coroutine can be stopped at any await, and cleanup that isn’t in a finally
(or a with) just doesn’t happen.
Never swallow CancelledError
CancelledError is how asyncio stops a task. If a coroutine catches it and
carries on, the timeout silently stops working, and so does shutting the
program down. Clean up in finally, not in except CancelledError. Since
Python 3.8 CancelledError isn’t an Exception subclass, so a plain
except Exception won’t catch it by accident.
When one of several fails
With three sources, one of them failing is normal. What should happen to the
other two? asyncio offers three different answers:
import asyncio
async def fetch_log(server, delay, fails=False):
await asyncio.sleep(delay)
if fails:
raise ConnectionError(f"{server}: connection refused")
return f"{server} log"
async def main():
print("gather, return_exceptions=True:")
results = await asyncio.gather(fetch_log("auth", 0.2), fetch_log("db", 0.1, fails=True),
return_exceptions=True)
for result in results:
print(" ", repr(result))
print("gather, default:")
try:
await asyncio.gather(fetch_log("auth", 0.2), fetch_log("db", 0.1, fails=True))
except ConnectionError as error:
print(" ", repr(error), "- and auth's result is gone")
print("TaskGroup:")
try:
async with asyncio.TaskGroup() as group:
auth = group.create_task(fetch_log("auth", 0.2))
group.create_task(fetch_log("db", 0.1, fails=True))
except* ConnectionError as group_error:
print(" ", group_error.exceptions)
print(" auth was cancelled:", auth.cancelled())
asyncio.run(main())| Approach | When one fails |
|---|---|
gather(..., return_exceptions=True) | Every result comes back, with the exception object in the failed one’s place. You check each result yourself. |
gather(...) | The first exception is raised straight away, instead of the combined results. The others keep running. Their results are only reachable if you kept the tasks yourself. |
TaskGroup | The others are cancelled, and all the failures are raised together as an ExceptionGroup. |
ExceptionGroup holds several exceptions at once, because in concurrent code
more than one thing can fail at the same time. except* catches the matching
exceptions inside a group. except* ConnectionError handles every
ConnectionError, and anything else in the group carries on up.
TaskGroup’s all-or-nothing rule is the right default when the tasks depend on
each other: if one part of a job fails, the rest is wasted work. Whether it’s
right for your tasks is a decision, as the next bug hunt shows.
Bug hunt: one server down, no report at all
import asyncio
DELAYS = {"auth": 0.3, "payments": 0.2, "search": 0.1}
DOWN = {"payments"} # today, payments is down
async def fetch_log(server):
await asyncio.sleep(DELAYS[server])
if server in DOWN:
raise ConnectionError(f"{server}: connection refused")
return [f"2026-09-01 09:00:00 ERROR {server}: x=1"] * 2
async def collect(servers):
async with asyncio.TaskGroup() as group:
tasks = {server: group.create_task(fetch_log(server)) for server in servers}
return {server: task.result() for server, task in tasks.items()}
logs = asyncio.run(collect(DELAYS))
for server, lines in logs.items():
print(f"{server}: {len(lines)} lines")Reproduce. One server out of three is down, and the program crashes with an
ExceptionGroup. No report at all, not even for the two servers that worked.
Inspect. What happened to each task? Replace the last three lines with a version that catches the group and looks at every task:
async def inspect():
tasks = {}
try:
async with asyncio.TaskGroup() as group:
tasks = {server: group.create_task(fetch_log(server)) for server in DELAYS}
except* ConnectionError:
pass
for server, task in tasks.items():
state = "cancelled" if task.cancelled() else ("failed" if task.exception() else "done")
print(server, state)
asyncio.run(inspect())
search was done, payments failed, and auth was cancelled while it was
still downloading.
Your turn, before you open this. Change collect so the two working servers are reported and payments shows as failed, and write a test that pins it. Then open the reveal and read it either way: it explains which failures should cancel the other fetches, and which shouldn’t.
The diagnosis and the fix (read it either way)
Narrow. That’s the TaskGroup contract working as designed: one failure
cancels the rest. But these three fetches don’t depend on each other. A report
covering two servers, with the third marked as down, is far more useful than no
report. The code chose all-or-nothing without anyone deciding it should.
Pin it.
def test_one_server_down_does_not_lose_the_others(self):
results = asyncio.run(collect(DELAYS))
self.assertEqual([r.status for r in results], ["ok", "failed", "ok"])Fix. Decide what a failure means for one source, and handle it there,
inside each task, so nothing escapes to cancel the others. Replace everything
from collect down with this:
from dataclasses import dataclass
@dataclass(frozen=True)
class Result:
server: str
status: str # "ok" or "failed"
lines: tuple[str, ...] = ()
error: str = ""
async def fetch_safely(server):
try:
return Result(server, "ok", tuple(await fetch_log(server)))
except ConnectionError as error:
return Result(server, "failed", error=str(error))
async def collect(servers):
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch_safely(server)) for server in servers]
return [task.result() for task in tasks]
for result in asyncio.run(collect(DELAYS)):
print(f"{result.server}: {result.status}, {len(result.lines)} lines {result.error}")fetch_safely catches only ConnectionError, the failure you expect from a
network. Each expected outcome, success or failure, is now data the report can
show, so nothing escapes to cancel the other fetches.
collect keeps the TaskGroup, on purpose. A bug, like a KeyError or a
TypeError, isn’t caught by fetch_safely. It escapes from its task, and the
TaskGroup cancels the other fetches, waits for them to clean up, and raises
the bug in an ExceptionGroup. That’s the contract chosen for this collector:
an expected failure is one server’s result, and an unexpected one ends the whole
collection. Other programs choose differently. A long-running server might log
a bug and keep serving everyone else. The point is to choose on purpose.
gather would raise the bug too, but leave the other fetches running in the
background. A program that carries on after catching the error would still have
them running.
Your turn
A fetch with a deadline
Write fetch_safely(server, timeout). It returns a Result whose status is
"ok", "failed" (for a ConnectionError) or "timeout" (for taking longer
than timeout seconds). Any other exception should escape. And the slow
source’s connection must be closed when it’s abandoned. The test checks the
closed list.
import asyncio
import sys
import unittest
from dataclasses import dataclass
closed = [] # servers whose connection was closed
@dataclass(frozen=True)
class Result:
server: str
status: str # "ok", "failed" or "timeout"
lines: tuple[str, ...] = ()
error: str = ""
async def fetch_log(server):
try:
delay = {"fast": 0.05, "slow": 1.0, "down": 0.05, "buggy": 0.05}[server]
await asyncio.sleep(delay)
if server == "down":
raise ConnectionError("connection refused")
if server == "buggy":
raise KeyError("a bug, not a network problem")
return [f"{server}: line {i}" for i in range(3)]
finally:
closed.append(server)
async def fetch_safely(server, timeout):
raise NotImplementedError("your code here")
class TestFetchSafely(unittest.TestCase):
def test_ok(self):
result = asyncio.run(fetch_safely("fast", timeout=0.5))
self.assertEqual((result.status, len(result.lines)), ("ok", 3))
def test_failed(self):
result = asyncio.run(fetch_safely("down", timeout=0.5))
self.assertEqual((result.status, result.error), ("failed", "connection refused"))
def test_timeout_closes_the_connection(self):
closed.clear()
result = asyncio.run(fetch_safely("slow", timeout=0.1))
self.assertEqual((result.status, closed), ("timeout", ["slow"]))
def test_bugs_are_not_hidden(self):
with self.assertRaises(KeyError):
asyncio.run(fetch_safely("buggy", timeout=0.5))
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestFetchSafely)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Hint 1: the idea
async with asyncio.timeout(seconds): around the fetch turns “too slow” into a
TimeoutError, and a ConnectionError arrives on its own. Catch exactly those
two and turn each into a Result — and catch nothing else, so an unexpected
bug still escapes. You don’t need to close anything yourself: fetch_log has
its own finally.
Hint 2: a first step
Get the “ok” test passing first: lines = await fetch_log(server) and return a
Result with status "ok". Then wrap the await in the timeout block, and add
the two except clauses one at a time, running the tests after each.
Show one solution
async def fetch_safely(server, timeout):
try:
async with asyncio.timeout(timeout):
lines = await fetch_log(server)
except TimeoutError:
return Result(server, "timeout", error=f"no answer after {timeout}s")
except ConnectionError as error:
return Result(server, "failed", error=str(error))
return Result(server, "ok", tuple(lines))The timeout cancels fetch_log at its await asyncio.sleep, and its finally
records the closed connection before TimeoutError reaches fetch_safely. You
didn’t have to write any cleanup here, because the code that opened the
connection is the code that closes it.
Only the two expected failures are caught. KeyError passes straight through
both except clauses, so the bug surfaces instead of being reported as “failed”.
The result’s lines is a tuple rather than a list so that Result, which is
frozen, is completely unchangeable. A frozen dataclass holding a list can still
have that list modified.
What you can now do
- Put a deadline on any await with
asyncio.timeoutorasyncio.wait_for. - Explain cancellation, and make cleanup happen with
finally. - Choose between
gather,gather(return_exceptions=True)andTaskGroupaccording to what one failure should mean. - Handle expected failures per task, as data, while letting bugs escape.
Next: the Day 6 checkpoint, four sources at once, using everything from today.