Checkpoint: Four Sources at Once

The Course
•
September 18, 2026
•
7 min read

Time to put the day together. Four servers hold logs. One is quick, one is a little slower, one refuses the connection, and one simply never answers. The analyzer needs whatever it can get, in about the time of a single fetch, and a clear line saying what happened to each server.

Day 6 checkpoint

Exercise

Collect, count, report

sources.py is the simulated network. Don’t change it. fetch_log(server) behaves like a real download, and records each closed connection in sources.closed.

In collector.py, write:

  • fetch_safely(server, timeout), as in the last lesson: a Result with status "ok", "failed" or "timeout", and bugs allowed to escape.
  • collect(servers, timeout): every server’s Result, fetched at the same time, in the order given.
  • error_counts(results): {server: number of ERROR lines}, for the servers that answered.
  • report(results): one line of text per server, in the format test_report shows.

Until collect works, the whole TestCollect class fails at once, in setUpClass. It runs collect a single time, and every test in the class checks that one result.

"""Day 6 checkpoint: collect logs from several servers at once. The tests are the spec.""" import asyncio from dataclasses import dataclass from sources import fetch_log @dataclass(frozen=True) class Result: server: str status: str # "ok", "failed" or "timeout" lines: tuple[str, ...] = () error: str = "" async def fetch_safely(server, timeout): """One server's Result. Expected network failures become data; bugs escape.""" raise NotImplementedError("your code here") async def collect(servers, timeout): """A Result for every server, fetched all at once, in the order given.""" raise NotImplementedError("your code here") def error_counts(results): """{server: number of ERROR lines} for every server that answered.""" raise NotImplementedError("your code here") def report(results): """One line of text per server, as the tests show.""" raise NotImplementedError("your code here") import asyncio import unittest import sources from collector import collect, error_counts, report SERVERS = ["auth", "payments", "search", "db"] class TestCollect(unittest.TestCase): @classmethod def setUpClass(cls): # collect once, then check it several ways sources.closed.clear() sources.most_in_flight = 0 cls.results = asyncio.run(collect(SERVERS, timeout=0.3)) def test_every_server_has_a_result_in_order(self): self.assertEqual([(r.server, r.status) for r in self.results], [("auth", "ok"), ("payments", "timeout"), ("search", "failed"), ("db", "ok")]) def test_servers_are_fetched_at_the_same_time(self): # One after another, only one fetch would ever be in flight. self.assertEqual(sources.most_in_flight, len(SERVERS)) def test_every_connection_is_closed(self): self.assertEqual(sorted(sources.closed), sorted(SERVERS)) def test_failures_say_why(self): by_server = {r.server: r for r in self.results} self.assertEqual(by_server["search"].error, "connection reset by peer") self.assertEqual(by_server["payments"].error, "no answer after 0.3s") def test_error_counts(self): self.assertEqual(error_counts(self.results), {"auth": 1, "db": 2}) def test_report(self): self.assertEqual(report(self.results), [ "auth ok 3 lines", "payments timeout no answer after 0.3s", "search failed connection reset by peer", "db ok 3 lines", ]) class TestBugs(unittest.TestCase): def test_a_bug_escapes_and_stops_the_other_fetches(self): async def attempt(): sources.closed.clear() escaped = False try: await collect(["payments", "no-such-server"], timeout=10) except* KeyError: # a bug, not a network failure escaped = True return escaped, sorted(sources.closed) # checked before asyncio.run tidies up # payments takes 5 seconds, so it must already have been cancelled, and closed. self.assertEqual(asyncio.run(attempt()), (True, ["no-such-server", "payments"])) """Four simulated log servers. Don't change this file: it's the network.""" import asyncio closed = [] # servers whose connection has been closed, in order in_flight = 0 # fetches started and not yet finished most_in_flight = 0 # the most that were ever in flight at once SERVERS = { "auth": {"delay": 0.10, "lines": [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:19:30 ERROR auth: user=grace msg=\"token expired\"", "2026-09-01 09:20:11 INFO auth: user=alan action=logout", ]}, "payments": {"delay": 5.00, "lines": []}, # hangs: never answers in time "search": {"delay": 0.05, "error": "connection reset by peer"}, "db": {"delay": 0.20, "lines": [ "2026-09-01 09:15:30 ERROR db: msg=\"replica lag\"", "2026-09-01 09:16:00 WARNING db: slow_query=2.4s", "2026-09-01 09:17:45 ERROR db: msg=\"deadlock\"", ]}, } async def fetch_log(server): """Download one server's log lines. Slow servers are slow; broken ones raise.""" global in_flight, most_in_flight in_flight += 1 most_in_flight = max(most_in_flight, in_flight) try: spec = SERVERS[server] await asyncio.sleep(spec["delay"]) if "error" in spec: raise ConnectionError(spec["error"]) return list(spec["lines"]) finally: in_flight -= 1 closed.append(server) import sys import unittest import test_collector suite = unittest.defaultTestLoader.loadTestsFromModule(test_collector) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Two of the four are this week’s lessons, reused. fetch_safely is the last lesson’s function. collect runs one fetch_safely per server at the same time, in the given order — which is gather — and because fetch_safely never raises for the expected failures, one bad server can’t sink the rest. The other two are ordinary Python over a list of Results.

Hint 2: a first step

Bring your fetch_safely across, then write collect as a single gather over fetch_safely(server, timeout) for each server. Once TestCollect stops failing in setUpClass, its individual tests tell you exactly what error_counts and report must produce.

Show one solution
async def fetch_safely(server, timeout):
    """One server's Result. Expected network failures become data; bugs escape."""
    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))


async def collect(servers, timeout):
    """A Result for every server, fetched all at once, in the order given.

    Expected failures are Results. A bug escapes, and cancels the other fetches.
    """
    async with asyncio.TaskGroup() as group:
        tasks = [group.create_task(fetch_safely(server, timeout)) for server in servers]
    return [task.result() for task in tasks]


def error_counts(results):
    """{server: number of ERROR lines} for every server that answered."""
    return {r.server: sum(1 for line in r.lines if line.split()[2] == "ERROR")
            for r in results if r.status == "ok"}


def report(results):
    """One line of text per server, as the tests show."""
    return [f"{r.server:<10}{r.status:<10}{f'{len(r.lines)} lines' if r.status == 'ok' else r.error}"
            for r in results]

The whole collection takes as long as the timeout, 0.3 seconds, however many servers there are. The servers are all fetched at once, and the slowest possible answer is “no answer after 0.3s”.

Each part stays small because each part has one job. fetch_safely decides what a failure means. collect decides when things run. The last two functions don’t know anything about asyncio at all: they take plain results and return plain data. That’s the same split as rules and program on Day 1.

Why a TaskGroup rather than gather? Both run the fetches at once, and both let a bug escape. The difference is what happens to the other fetches. The last test asks for a server that doesn’t exist alongside payments, which takes five seconds. Then it checks, straight away, that payments was cancelled and its connection closed. With gather, the KeyError is raised, but payments is still running in the background. With a TaskGroup, it’s cancelled before collect raises. The bug arrives inside an ExceptionGroup, which is why the test catches it with except* KeyError.

test_servers_are_fetched_at_the_same_time doesn’t time anything. sources.py counts the fetches in progress, and the test checks that all four were in progress at once. It checks overlap rather than elapsed speed. (The suite still has real timeouts in it, payments against timeout=0.3 for one, so it isn’t completely independent of how busy the computer is. It just doesn’t ask for speed.)

The Day 6 reference solution

Today was standalone, so this is the complete collector rather than the analyzer. main.py fetches all four servers, prints the report, then runs the tests.

Day 6 reference: four sources at once Python
"""Day 6: fetch four servers' logs at once, with a time limit, and report on each.""" import asyncio import sys import time import unittest import test_collector from collector import collect, error_counts, report start = time.perf_counter() results = asyncio.run(collect(["auth", "payments", "search", "db"], timeout=0.3)) print(f"Collected in {time.perf_counter() - start:.1f}s\n") print("\n".join(report(results))) print("\nERROR lines:", error_counts(results)) print() suite = unittest.defaultTestLoader.loadTestsFromModule(test_collector) unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) """Day 6: collect logs from several servers at once, and report on every one.""" import asyncio from dataclasses import dataclass from sources import fetch_log @dataclass(frozen=True) class Result: server: str status: str # "ok", "failed" or "timeout" lines: tuple[str, ...] = () error: str = "" async def fetch_safely(server, timeout): """One server's Result. Expected network failures become data; bugs escape.""" 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)) async def collect(servers, timeout): """A Result for every server, fetched all at once, in the order given. Expected failures are Results. A bug escapes, and cancels the other fetches. """ async with asyncio.TaskGroup() as group: tasks = [group.create_task(fetch_safely(server, timeout)) for server in servers] return [task.result() for task in tasks] def error_counts(results): """{server: number of ERROR lines} for every server that answered.""" return {r.server: sum(1 for line in r.lines if line.split()[2] == "ERROR") for r in results if r.status == "ok"} def report(results): """One line of text per server, as the tests show.""" return [f"{r.server:<10}{r.status:<10}{f'{len(r.lines)} lines' if r.status == 'ok' else r.error}" for r in results] """Four simulated log servers. Don't change this file: it's the network.""" import asyncio closed = [] # servers whose connection has been closed, in order in_flight = 0 # fetches started and not yet finished most_in_flight = 0 # the most that were ever in flight at once SERVERS = { "auth": {"delay": 0.10, "lines": [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:19:30 ERROR auth: user=grace msg=\"token expired\"", "2026-09-01 09:20:11 INFO auth: user=alan action=logout", ]}, "payments": {"delay": 5.00, "lines": []}, # hangs: never answers in time "search": {"delay": 0.05, "error": "connection reset by peer"}, "db": {"delay": 0.20, "lines": [ "2026-09-01 09:15:30 ERROR db: msg=\"replica lag\"", "2026-09-01 09:16:00 WARNING db: slow_query=2.4s", "2026-09-01 09:17:45 ERROR db: msg=\"deadlock\"", ]}, } async def fetch_log(server): """Download one server's log lines. Slow servers are slow; broken ones raise.""" global in_flight, most_in_flight in_flight += 1 most_in_flight = max(most_in_flight, in_flight) try: spec = SERVERS[server] await asyncio.sleep(spec["delay"]) if "error" in spec: raise ConnectionError(spec["error"]) return list(spec["lines"]) finally: in_flight -= 1 closed.append(server) import asyncio import unittest import sources from collector import collect, error_counts, report SERVERS = ["auth", "payments", "search", "db"] class TestCollect(unittest.TestCase): @classmethod def setUpClass(cls): # collect once, then check it several ways sources.closed.clear() sources.most_in_flight = 0 cls.results = asyncio.run(collect(SERVERS, timeout=0.3)) def test_every_server_has_a_result_in_order(self): self.assertEqual([(r.server, r.status) for r in self.results], [("auth", "ok"), ("payments", "timeout"), ("search", "failed"), ("db", "ok")]) def test_servers_are_fetched_at_the_same_time(self): # One after another, only one fetch would ever be in flight. self.assertEqual(sources.most_in_flight, len(SERVERS)) def test_every_connection_is_closed(self): self.assertEqual(sorted(sources.closed), sorted(SERVERS)) def test_failures_say_why(self): by_server = {r.server: r for r in self.results} self.assertEqual(by_server["search"].error, "connection reset by peer") self.assertEqual(by_server["payments"].error, "no answer after 0.3s") def test_error_counts(self): self.assertEqual(error_counts(self.results), {"auth": 1, "db": 2}) def test_report(self): self.assertEqual(report(self.results), [ "auth ok 3 lines", "payments timeout no answer after 0.3s", "search failed connection reset by peer", "db ok 3 lines", ]) class TestBugs(unittest.TestCase): def test_a_bug_escapes_and_stops_the_other_fetches(self): async def attempt(): sources.closed.clear() escaped = False try: await collect(["payments", "no-such-server"], timeout=10) except* KeyError: # a bug, not a network failure escaped = True return escaped, sorted(sources.closed) # checked before asyncio.run tidies up # payments takes 5 seconds, so it must already have been cancelled, and closed. self.assertEqual(asyncio.run(attempt()), (True, ["no-such-server", "payments"]))

Working on your own machine? Download the Day 6 files and carry on there. You need Python 3.12 or later, and nothing else.

Tomorrow the analyzer returns, to be finished, logged and given a command line. Fetching logs from servers would plug in as one more source of lines, since parse_lines accepts any iterable of text.

Going further: threads, processes and real HTTP

Optional, and on your own machine

This section needs a local Python. The browser that runs this page has no threads or processes, and its network access goes through the browser. Nothing later in the course depends on it.

asyncio overlaps waiting, on one thread. Python has two other tools for doing several things at once, and each suits a different kind of work.

Threads, for blocking calls. Many libraries block: requests.get, time.sleep, most database drivers. Run each call on its own thread and the waits overlap, without rewriting the library. asyncio.to_thread does this from async code. concurrent.futures.ThreadPoolExecutor does it from ordinary code:

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

SERVERS = ["auth", "payments", "search"]


def blocking_fetch(server):
    time.sleep(0.3)                     # a blocking call, like requests.get(...)
    return f"{server} log"


async def with_asyncio():
    return await asyncio.gather(*(asyncio.to_thread(blocking_fetch, s) for s in SERVERS))


def with_threads():
    with ThreadPoolExecutor() as pool:
        return list(pool.map(blocking_fetch, SERVERS))


for name, run in [("to_thread", lambda: asyncio.run(with_asyncio())), ("thread pool", with_threads)]:
    start = time.perf_counter()
    print(f"{name:12} {run()} in {time.perf_counter() - start:.1f}s")

Both take 0.3 seconds for three 0.3-second waits.

Processes, for heavy calculation. In the standard build of CPython, only one thread runs Python code at a time. That rule is the GIL, the global interpreter lock. So threads overlap waits, but not calculation. Separate processes each have their own interpreter, and really do run in parallel on several CPU cores:

import time
from concurrent.futures import ProcessPoolExecutor


def crunch(n):
    return sum(i * i for i in range(n))


if __name__ == "__main__":                  # required: each process imports this file
    jobs = [20_000_000] * 4
    start = time.perf_counter()
    [crunch(n) for n in jobs]
    print(f"one process:   {time.perf_counter() - start:.1f}s")
    start = time.perf_counter()
    with ProcessPoolExecutor() as pool:
        list(pool.map(crunch, jobs))
    print(f"four processes: {time.perf_counter() - start:.1f}s")

On a four-core laptop, the second number is roughly a third of the first. Processes cost more to start, and everything sent to them has to be copied across, so they pay off only for real work.

There’s also a free-threaded build of CPython without the GIL, where threads can calculate in parallel too. It arrived as an experiment in 3.13 and is officially supported from 3.14, as a separate, optional build. It isn’t the default yet, and some libraries don’t support it yet, so check before relying on it.

Kind of workUse
Many waits, with async librariesasyncio
Many waits, with blocking librariesthreads (to_thread, ThreadPoolExecutor)
Heavy calculationprocesses (ProcessPoolExecutor)

Real HTTP. The standard library’s urllib blocks, so from async code, run it in a thread:

import asyncio
import urllib.request

URLS = ["https://www.python.org", "https://docs.python.org/3/", "https://pypi.org"]


def get_status(url):
    with urllib.request.urlopen(url, timeout=5) as response:
        return response.status


async def main():
    results = await asyncio.gather(*(asyncio.to_thread(get_status, url) for url in URLS),
                                   return_exceptions=True)
    for url, result in zip(URLS, results):
        print(url, result)


asyncio.run(main())

For real projects, the third-party httpx library has a native async client (httpx.AsyncClient), which needs no threads at all. On macOS with Python from python.org, a CERTIFICATE_VERIFY_FAILED error means the certificates haven’t been installed yet. Run Install Certificates.command from the Python folder in Applications.

What you can now do

  • Fetch many sources concurrently with a time limit, and report on every one of them, whatever happened.
  • Keep the async part of a program small, with plain functions around it.
  • Choose between asyncio, threads and processes by the kind of work.
Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.