Part 5 · Advanced Python · Graded project

Project 5: Async Web Crawler

Build a polite, resilient, concurrent crawler that walks a documentation site and reports every broken link along with the pages that point to it. It is the kind of tool teams run in CI every night, and it exercises every hard part of asyncio: bounded concurrency, rate limiting, retries, structured shutdown, and cancellation.

⏱ ~8–10 hoursPrerequisites: M38, M40 (also uses M37 and M16)

The scenario

Your team's documentation site has grown to hundreds of pages written by dozens of people. Pages get renamed, sections move, and readers keep landing on 404s. You are asked for a tool that can run in CI and answer one question: which links are broken, and where are they? A plain "this URL is a 404" is not enough. The writer fixing it needs the referrers, the pages that contain the bad link.

Fetching pages one at a time is far too slow: a 300-page site at 200 ms per request takes a minute, and most of that time the program sits idle waiting on the network. That is exactly the I/O-bound workload asyncio was built for (M38). But unbounded concurrency would hammer the server, so the crawler must also be polite: obey robots.txt, cap concurrent requests, and space out requests per host.

Learning goals

Dependency injection

The crawler depends on a Fetcher Protocol, not on urllib. Tests inject FakeFetcher; production injects UrllibFetcher.

Structured concurrency

Workers live inside a TaskGroup: none can leak, and a crash in one cancels the rest and surfaces as an ExceptionGroup.

Back-pressure and politeness

A fixed worker pool bounds concurrency; a per-host limiter bounds request rate; Crawl-delay from robots.txt can raise it.

Resilience

Transient failures (connection resets, 429, 503) are retried with capped exponential backoff and full jitter, using an injected sleep and Random.

Feature specification

FeatureBehaviorModule
Fetcher seamasync def fetch(url) -> Response(status, headers, body); never follows redirects; raises FetchError for transient network failuresfetcher.py
Link extraction<a href> and <area href>, honors <base href>, skips rel="nofollow", de-duplicates in first-seen orderlinks.py
URL normalizationresolve relative links, lowercase scheme and host, drop default ports and fragments, remove dot segments, reject non-HTTP schemesurls.py
Same-site filterexact host match (optionally subdomains); off-site links are recorded as external but not fetchedurls.py
robots.txtfetched once per host (behind a lock), disallowed URLs reported as blocked, Crawl-delay raises the host intervalrobots.py
Bounded concurrencyconfig.concurrency workers consume an asyncio.Queuecrawler.py
Per-host rate limitat most one request per per_host_interval seconds per host; hosts are independentratelimit.py
RetriesFetchError, 429, 500, 502, 503, 504 are retried retries times; delay is uniform(0, min(cap, base·2n))retry.py
Limitsmax_depth (start page is depth 0) and max_pages (sets truncated)crawler.py
Redirects3xx with Location is recorded and its target crawled at the same depthcrawler.py
Shutdownafter queue.join(), one None sentinel per worker; the TaskGroup then exitscrawler.py
Cancellationcancelling crawl() leaves no tasks, no in-flight requests, and a usable partial report()crawler.py
ReportJSON: summary, broken links with sorted referrers, redirects, blocked, external, per-page results, depth countsreport.py
CLIpython -m crawler URL; exit 0 clean, 1 broken links, 2 bad URL or timeoutcli.py

Architecture

The crawl is a classic producer/consumer pipeline where the consumers are also producers: each worker pulls a Job(url, depth, referrer), fetches it, and pushes newly discovered links back onto the same queue. The queue's unfinished-task counter is what tells us we are done: queue.join() returns only when every job that was ever put has had a matching task_done().

flowchart LR
  S[start URL] --> Q[(asyncio.Queue of Job)]
  subgraph TG[TaskGroup]
    W1[worker 1]
    W2[worker 2]
    W3[worker N]
  end
  Q --> W1 & W2 & W3
  W1 & W2 & W3 --> R{robots policy
cached per host} R -->|allowed| L[HostRateLimiter] L --> F[fetch_with_retries] F --> FE[[Fetcher Protocol
FakeFetcher / UrllibFetcher]] F --> P[extract_links + normalize_url] P -->|same site, new, depth ok| Q P -->|off site| X[external set] W1 & W2 & W3 --> REP[results, referrers, redirects] REP --> J[CrawlReport.to_json]

Getting started with Pixi

pixi run projects            # list projects and which you've started
pixi run start p05           # copy the starter into work/p05_async_crawler/
pixi run grade p05           # run the 81 grading tests against your work/ copy
pixi run practicum grade p05 -k urls -x   # one milestone at a time, stop at first failure

After the tests pass, try it for real on a site you control:

cd work/p05_async_crawler
pixi run python -m crawler https://docs.example.com/ --max-depth 2 --interval 1.0 > report.json

Milestones

Milestone 1: URLs and links

Implement remove_dot_segments, normalize_url, host_of, same_site, robots_url, and extract_links. Normalization is what makes de-duplication work: HTTP://Docs.Example.com:80/a/./b/../c#top and /a/c on that site are the same page.

  • Relative links resolve against the page (and <base href>)
  • mailto:, javascript:, fragments-only, and bad ports return None
  • same_site resists the example.com.evil.org suffix trick
  • pixi run practicum grade p05 -k "urls or links" passes

Milestone 2: robots.txt

Wrap urllib.robotparser.RobotFileParser in RobotsPolicy. A 404 robots.txt means "no rules". Note that RobotFileParser only accepts whole-second Crawl-delay values.

  • allowed() honors per-agent rules
  • crawl_delay() returns 0.0 when unset
  • -k robots passes

Milestone 3: politeness and resilience

Build HostRateLimiter and fetch_with_retries. Both take an injected sleep (and the limiter a clock), so tests advance virtual time instantly instead of waiting.

  • Concurrent callers for one host are serialized one interval apart
  • Different hosts never wait for each other
  • Backoff ceilings double and cap; delays are jittered in [0, ceiling]
  • 404 is final; 503 and FetchError are retried
  • -k "retry or ratelimit" passes

Milestone 4: the engine

Implement Crawler.crawl, the worker loop, scheduling, robots caching, and page processing.

  • Each URL is fetched exactly once even when linked from many pages
  • Referrers are recorded for every link, including duplicates
  • max_depth, max_pages, redirects, external links, and blocked URLs behave as specified
  • Peak in-flight requests equals concurrency

Milestone 5: shutdown and cancellation

Make the crawl end cleanly and fail loudly. Every queue.get() is paired with task_done() in a finally; in_progress is cleaned in a finally; the TaskGroup guarantees no worker outlives crawl().

  • asyncio.timeout around crawl() leaves zero leftover tasks
  • A partial report() is available after cancellation
  • A non-transient bug in the fetcher surfaces as an ExceptionGroup
  • A Crawler can be reused for a second crawl

Milestone 6: report and CLI

Implement build_report, CrawlReport.to_dict/to_json, and cli.main. Sort everything so the JSON is deterministic: CI diffs should show real changes only.

  • Report summary matches the documented shape
  • CLI exits 0, 1, or 2 as specified
  • pixi run grade p05: all 81 tests pass

The heart of it: worker pool and shutdown

This is the shape the tests expect. Study why each try/finally is there before you write yours.

async def crawl(self, start_url: str) -> CrawlReport:
    start = normalize_url(start_url)
    if start is None:
        raise ValueError(f"not a crawlable URL: {start_url!r}")
    self._reset(start)
    queue: asyncio.Queue[Job | None] = asyncio.Queue()
    self._schedule(queue, Job(start, 0, None))
    async with asyncio.TaskGroup() as group:
        for i in range(self.config.concurrency):
            group.create_task(self._worker(queue), name=f"crawler-worker-{i}")
        await queue.join()                  # every put has a matching task_done
        for _ in range(self.config.concurrency):
            queue.put_nowait(None)          # one sentinel per worker
    return self.report()

async def _worker(self, queue):
    while True:
        job = await queue.get()
        try:
            if job is None:
                return
            ...                             # robots, rate limit, fetch, parse, schedule
        finally:
            queue.task_done()               # even on error or cancellation

Rubric

CriterionWeightExcellent
Correctness (grading tests)50%All 81 tests pass with pixi run grade p05
Concurrency design20%No leaked tasks; every get paired with task_done; locks only where state is shared across awaits
Resilience and politeness10%Retries only transient failures; jittered backoff; per-host interval and Crawl-delay honored
Code quality10%Full type hints, Google docstrings, pixi run ruff check clean, no print outside main()
Report usefulness10%Deterministic, sorted JSON with referrers; meaningful exit codes

Hints

My crawl hangs forever

Almost always a missing queue.task_done(). If a worker returns early (blocked by robots, a redirect, an exception) without calling it, queue.join() never returns. Put task_done() in a finally right after get().

robots.txt is fetched several times

Several workers reached the same host at once and all saw an empty cache. Guard the check-and-fetch with a per-host asyncio.Lock: the second worker waits, then finds the cached policy. This is a race even though asyncio is single-threaded, because there is an await between the check and the store.

The rate-limit test sees requests at the same instant

Record the "last request" time after sleeping and while still holding the host's lock. If you compute the wait outside the lock, two callers compute the same wait and wake up together.

The cancellation test finds leftover tasks

Create workers with group.create_task, not asyncio.create_task. Only tasks owned by the TaskGroup are cancelled and awaited when the body is cancelled.

A redirect target is fetched twice

Send the normalized Location through the same _schedule path as ordinary links, so the seen set de-duplicates it.

Why asyncio.to_thread for urllib?

urllib blocks. Calling it directly inside a coroutine would freeze the event loop, and with it every other worker. to_thread runs it in the default thread pool; the loop keeps scheduling other coroutines while the thread waits on the socket (and the GIL is released during socket I/O).

Stretch goals

Reflection quiz