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.
- Design around an injected async
Protocolso network code is testable offline - Run a bounded worker pool fed by an
asyncio.Queueinside aTaskGroup - Implement per-host rate limiting and retries with exponential backoff and jitter
- Shut down cleanly with sentinels and stay correct under cancellation
- Parse HTML and URLs with the standard library (
html.parser,urllib.parse,urllib.robotparser) - Produce a deterministic JSON report suitable for CI
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
| Feature | Behavior | Module |
|---|---|---|
| Fetcher seam | async def fetch(url) -> Response(status, headers, body); never follows redirects; raises FetchError for transient network failures | fetcher.py |
| Link extraction | <a href> and <area href>, honors <base href>, skips rel="nofollow", de-duplicates in first-seen order | links.py |
| URL normalization | resolve relative links, lowercase scheme and host, drop default ports and fragments, remove dot segments, reject non-HTTP schemes | urls.py |
| Same-site filter | exact host match (optionally subdomains); off-site links are recorded as external but not fetched | urls.py |
| robots.txt | fetched once per host (behind a lock), disallowed URLs reported as blocked, Crawl-delay raises the host interval | robots.py |
| Bounded concurrency | config.concurrency workers consume an asyncio.Queue | crawler.py |
| Per-host rate limit | at most one request per per_host_interval seconds per host; hosts are independent | ratelimit.py |
| Retries | FetchError, 429, 500, 502, 503, 504 are retried retries times; delay is uniform(0, min(cap, base·2n)) | retry.py |
| Limits | max_depth (start page is depth 0) and max_pages (sets truncated) | crawler.py |
| Redirects | 3xx with Location is recorded and its target crawled at the same depth | crawler.py |
| Shutdown | after queue.join(), one None sentinel per worker; the TaskGroup then exits | crawler.py |
| Cancellation | cancelling crawl() leaves no tasks, no in-flight requests, and a usable partial report() | crawler.py |
| Report | JSON: summary, broken links with sorted referrers, redirects, blocked, external, per-page results, depth counts | report.py |
| CLI | python -m crawler URL; exit 0 clean, 1 broken links, 2 bad URL or timeout | cli.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 returnNonesame_siteresists theexample.com.evil.orgsuffix trickpixi 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 rulescrawl_delay()returns 0.0 when unset-k robotspasses
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
FetchErrorare 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.timeoutaroundcrawl()leaves zero leftover tasks- A partial
report()is available after cancellation - A non-transient bug in the fetcher surfaces as an
ExceptionGroup - A
Crawlercan 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
| Criterion | Weight | Excellent |
|---|---|---|
| Correctness (grading tests) | 50% | All 81 tests pass with pixi run grade p05 |
| Concurrency design | 20% | No leaked tasks; every get paired with task_done; locks only where state is shared across awaits |
| Resilience and politeness | 10% | Retries only transient failures; jittered backoff; per-host interval and Crawl-delay honored |
| Code quality | 10% | Full type hints, Google docstrings, pixi run ruff check clean, no print outside main() |
| Report usefulness | 10% | 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
- Check (but do not crawl) external links, with their own per-host limits.
- Honor
Retry-Afteron 429/503 and<meta name="robots" content="nofollow">. - Write an
httpx.AsyncClientfetcher (pixi add httpx) and benchmark it against the thread-backed one. - Persist the frontier in
sqlite3so an interrupted crawl can resume. - While it runs, inspect it with
python -m asyncio pstree PID(3.14) to see the worker tree. - Emit progress with
structlog(previewed in M42).