Python Concurrency Crawler Lab
Offline, runnable, progressive

Learn async by crawling a tiny web.

Start with breadth-first search, compare threads with asyncio, then practice timeout, retry, semaphores, and cancellation through focused examples.

/ /tutorial /reference /examples/basic /examples/concurrent external filtered

A deterministic website with cycles, duplicates, and filtered links.

Runnable lessons

Progressive lessons

Each lesson gives you an idea to understand, code to run, and a question to answer before moving on.

00 Website graph, BFS, and crawler state
Learn

A crawler treats pages as nodes and links as edges. Start sequentially so the queue order is visible and deterministic.

  • urls_to_crawl holds discovered work waiting to run.
  • discovered_urls prevents cycles and duplicates.
  • fetched_urls records completed pages.
Try

Run the baseline and predict the fetch order before reading the output.

python3 synchronous_crawlers.py
python3 -m unittest -v test_crawlers

Open the sequential run loop

Check yourself: Why must a URL be marked discovered when it is queued, rather than only after fetching it?

01 Thread pool, Future, and waiting for progress
Learn

A thread pool runs blocking fetch functions in worker threads. submit() returns a Future, which is a handle to work that may still be running.

The crawler stores pending futures so it knows what is in flight, waits for the first one to finish, calls result() to observe failures, then submits more URLs.

Run
python3 compare_examples.py

# Read:
ThreadPoolWebCrawler.run()
wait(..., FIRST_COMPLETED)

Open the Future/wait loop

Check yourself: Why does the crawler submit only up to the available worker slots instead of submitting every discovered URL immediately?

02 Asyncio Task, await, and the event loop
Learn

Asyncio schedules Task objects on one event loop thread. A task pauses at await, leaving the loop free to advance other tasks while a simulated request waits.

The async crawler owns shared state in one coordinator method, so completed tasks return links and the coordinator updates the queue without a thread lock.

Run
python3 async_crawler.py
python3 compare_examples.py

# Read:
AsyncWebCrawler.run()
asyncio.wait(..., FIRST_COMPLETED)

Open the asyncio run loop

Check yourself: Why can completion order differ from sequential BFS order, even if every reachable URL is still found?

03 Timeout: bound how long waiting may occupy capacity
Learn

Non-blocking waiting still consumes a request slot. A timeout prevents one slow page from holding capacity indefinitely.

Try
python3 -m lessons.lesson_timeout

return await asyncio.wait_for(
    get_html_content_async(url),
    timeout=timeout_seconds,
)

Open timeout source

Experiment: Lower the timeout below the simulated page delay. What result should fetch_with_timeout() return?

04 Retry with backoff: recover without hammering
Learn

Retry only temporary failures. Exponential backoff spaces later attempts so a struggling service has time to recover.

Try
python3 -m lessons.lesson_retry

delay_seconds = base_delay_seconds * (
    2 ** (attempt_number - 1)
)

Open retry source

Check yourself: Why should a malformed URL or permission failure generally not be retried?

05 Semaphore: bound active asynchronous requests
Learn

A semaphore caps work inside the protected fetch region. It protects a service from a burst, but does not limit how many URLs have been discovered.

Try
python3 -m lessons.lesson_semaphore

async with semaphore:
    await fetch_page()

Open semaphore source

Check yourself: What separate limit would stop a huge website from growing the crawl frontier?

06 Cancellation: stop and collect outstanding tasks
Learn

Cancelling signals that work should stop. Awaiting cancelled tasks lets their cleanup complete and observes their terminal state.

Try
python3 -m lessons.lesson_cancellation

task.cancel()
await asyncio.gather(
    task, return_exceptions=True
)

Open cancellation source

Check yourself: Why is creating tasks without later awaiting or cancelling them risky?

Code Studio

Read the important parts without leaving the lesson.

Start with the annotated excerpts, then open the full runnable source directly on this page. GitHub is optional, not the next required step.

Shared boundary: the same offline site exposes a blocking fetch and an async fetch, so the comparison is fair.
def get_html_content(url: str) -> str:
    time.sleep(PAGE_DOWNLOAD_DELAYS.get(url, 0.02))
    return _read_example_page(url)

async def get_html_content_async(url: str) -> str:
    await asyncio.sleep(PAGE_DOWNLOAD_DELAYS.get(url, 0.02))
    return _read_example_page(url)
Open complete source below ↓
Full source reader

Read complete files in place.

Open this file on GitHub →
Interactive Lab

Change inputs, then explain what changes.

These small simulations focus on the mental models behind the runnable Python examples.

Lab 1 · Capacity

Overlap waiting time

Requests are already known here. Raising the limit overlaps waits; it does not make one page faster.

Total simulated time:

Lab 2 · State

Step through a crawl

Using two request slots, advance one event and watch discovered, queued, running, and fetched URLs change.

Discovered
Queued
Running
Fetched

Lab 3 · Resilience

Combine timeout and retry

Post-lesson design lab: combine the two separate runnable lessons. Set latency and temporary failures to predict an eventual request outcome before implementing it in a crawler.

Model: up to 3 attempts with backoff delays of 1 then 2 ticks. Read lesson_timeout.py and lesson_retry.py, then sketch how you would compose them.

Self-check

Can you predict the behavior?

Answer before opening the code again. Correct explanations matter more than memorizing syntax.

Why not call requests.get() directly inside async def?

Explain in your own words

Asyncio needs each task to yield at await. A blocking call keeps the event loop thread busy, so unrelated tasks cannot make progress.

What does max_concurrency=2 limit?

Explain in your own words

It limits active fetches, not total discovered pages. A crawler may still discover many URLs and hold them in the waiting queue.

What does pool.submit(crawl_page, url) return immediately?

Explain in your own words

A Future lets the coordinator remember scheduled work, wait for completion, and call result() to surface unexpected worker exceptions.

After cancelling pending tasks, why await them?

Explain in your own words

Cancellation is delivered into the coroutine. Awaiting gives cancelled tasks a chance to run cleanup and lets the caller observe their final state.