Unit 11 Project: Fetch Many Pages Politely
Fast, bounded, and still a good citizen
In this lesson
The paginated fetch you wrote in Unit 7 walks through pages one at a time. Twenty pages at half a second each is ten seconds of almost pure waiting, the exact shape of problem this unit exists to solve.
This project speeds it up without turning you into the kind of client that gets blocked. That balance is the actual work: making it fast is four lines, and making it fast and polite and debuggable is the rest of the lesson.
Explain it like I’m 5
Sending five polite runners to fetch things instead of one, and making sure all five never hammer on the door at the same moment.
Why not just fetch all twenty at once
Your instinct after the last three lessons is to launch every page simultaneously. Do that and you learn about HTTP 429, Too Many Requests. Most APIs publish a rate limit, and exceeding it gets you throttled, temporarily banned, or permanently blocked.
So the number you want is not “as many as possible.” It is the largest number that stays comfortably under the limit, which for a documented cap of 10 requests per second might be a pool of 5. Bounding concurrency is not a compromise on speed; it is the difference between a script that works and one that gets you blocked.
Decide the bound with arithmetic rather than a guess. Twelve pages, half a second each. With at_once running together, the pages go out in rounds of that size, so the total is the number of rounds multiplied by the time per page. Fill in wall_clock and compare three settings.
import math
PAGES, PER_PAGE = 12, 0.5
def wall_clock(pages, per_page, at_once):
# TODO: pages go out in rounds of `at_once`; return the total seconds
return 0
for at_once in (1, 4, 12):
print(f"{at_once:2d} at once: {wall_clock(PAGES, PER_PAGE, at_once):.1f}s")
The number of rounds is math.ceil(pages / at_once): 12 pages 4 at a time is 3 rounds. Multiply that by per_page and return it.
import math
PAGES, PER_PAGE = 12, 0.5
def wall_clock(pages, per_page, at_once):
rounds = math.ceil(pages / at_once)
return rounds * per_page
for at_once in (1, 4, 12):
print(f"{at_once:2d} at once: {wall_clock(PAGES, PER_PAGE, at_once):.1f}s")
1 at once: 6.0s
4 at once: 1.5s
12 at once: 0.5s
Reading those numbers properly
Going from 1 to 4 saves 4.5 seconds. Going from 4 to 12 saves another 1 second — a quarter of the benefit, for triple the load on someone else's server. This is the shape of nearly every concurrency decision you will make: the first few workers buy almost all of the win, and the rest buy trouble.
Pick the knee of that curve, not the end of it. Four is the right answer here.
The script
Every piece here comes from a previous unit. The logging is Unit 9, the @retry is Unit 10, the session and rate limiting are Unit 7. The only new part is the pool.
import logging, time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
AT_ONCE = 4
PAGES = range(1, 13)
def fetch_page(session, number):
"""Fetch one page. Raises on failure; the caller decides what that means."""
log.info("page %d: requesting", number)
response = session.get("https://example.com/api/items",
params={"page": number}, timeout=10)
response.raise_for_status()
log.info("page %d: done", number)
return response.json()["items"]
def fetch_all(pages, at_once=AT_ONCE):
items, failed = [], []
with requests.Session() as session:
with ThreadPoolExecutor(max_workers=at_once) as pool:
futures = {pool.submit(fetch_page, session, n): n for n in pages}
for future in as_completed(futures):
number = futures[future]
try:
items.extend(future.result())
except requests.RequestException as err:
log.warning("page %d failed: %s", number, err)
failed.append(number)
return items, failed
if __name__ == "__main__":
start = time.perf_counter()
items, failed = fetch_all(PAGES)
log.info("%d items from %d pages in %.1fs (%d failed)",
len(items), len(PAGES), time.perf_counter() - start, len(failed))
2026-07-28 10:14:02 INFO page 1: requesting 2026-07-28 10:14:02 INFO page 2: requesting 2026-07-28 10:14:02 INFO page 3: requesting 2026-07-28 10:14:02 INFO page 4: requesting 2026-07-28 10:14:03 INFO page 2: done 2026-07-28 10:14:03 INFO page 5: requesting 2026-07-28 10:14:03 INFO page 1: done ... 2026-07-28 10:14:04 WARNING page 9 failed: 503 Server Error ... 2026-07-28 10:14:05 INFO 220 items from 12 pages in 1.7s (1 failed)
Why the logs matter more than they did before
Look at the output again: page 2 finishes before page 1, and page 5 starts the instant a slot frees up. Interleaved output is not a defect — it is what concurrency looks like from the outside. But it does mean a bare print("done") is now useless, because you cannot tell which page it refers to.
Two habits make interleaved output readable. Put the identifier in every message: page %d, not done. And use timestamps, which logging gives you for free and which are the only way to reconstruct what overlapped with what.
There is a practical reason to prefer logging over print here beyond the formatting: log calls are safe to make from several threads at once, and will not interleave halfway through a line. Two threads printing at the same moment can produce genuinely mangled output.
Where to take it next
Three extensions, in increasing order of difficulty:
- Retry the failures. You have
@retryfrom Unit 10. A 503 is worth another attempt; a 404 is not. Retry only what can plausibly succeed. - Stream the results.
items.extend(...)holds everything in memory. For a large crawl, write each page to a file or the Unit 8 database as it arrives. - Rewrite it with async. Swap
requestsforhttpx, the pool forasyncio.gather, and bound it with anasyncio.Semaphore(4)instead ofmax_workers. Same structure, same result, and good practice for reading async code in the wild.
Common mistake: Unbounded concurrency triggering a 429
If four workers are fast, forty should be faster, and locally, against a fake server, they are.
Set max_workers deliberately from the API's documented rate limit, and stay well below it.
Common mistake: One failed page aborting the whole run
The try ends up around the entire loop instead of around one future.result().
Catch per page, inside the loop. Log the failure, record the page number, and let the rest finish.
Common mistake: Printing without saying which page
Sequential code has an obvious current item, so bare messages were always enough.
Concurrent output is interleaved. Put the page number in every message and let logging add the timestamp.
Common mistake: Creating a new Session inside every worker
Sharing one across threads feels like it must be unsafe.
A requests.Session is fine to share here, and reusing its connection pool is a large part of the speedup. Create one and pass it in.
Why bound the number of simultaneous requests?
Beyond the knee of the curve you buy very little speed and a real risk of a 429 or a ban.
What should happen when one page fails?
Catching around a single future.result() inside the loop keeps one failure local to one page.
Why is logging more useful than printing in a concurrent run?
Output from several workers arrives mixed together, so each line has to identify itself and say when it happened.
Mini exercise (medium)
The run finished and some pages failed. A failed page arrived as None instead of its content. Write collect() to split the arrivals into the pages that worked and the numbers of the ones that did not, so the run can report honestly instead of pretending it fetched everything.
Practice here. Fill in the missing piece and click Run to try your answer in place.
# Pages as they came back. A page that failed arrived as None.
ARRIVALS = [(0, "page 0"), (1, None), (2, "page 2"), (3, None), (4, "page 4")]
def collect(arrivals):
kept, failed = [], []
# TODO: keep the pages that worked; record the numbers of those that did not
return kept, failed
kept, failed = collect(ARRIVALS)
print("kept: ", kept)
print("failed:", failed)
print(f"{len(kept)} of {len(ARRIVALS)} pages")
One loop over the pairs. If the value is None, append the number to failed; otherwise append the value to kept. Return both lists.
# Pages as they came back. A page that failed arrived as None.
ARRIVALS = [(0, "page 0"), (1, None), (2, "page 2"), (3, None), (4, "page 4")]
def collect(arrivals):
kept, failed = [], []
for number, value in arrivals:
if value is None:
failed.append(number)
else:
kept.append(value)
return kept, failed
kept, failed = collect(ARRIVALS)
print("kept: ", kept)
print("failed:", failed)
print(f"{len(kept)} of {len(ARRIVALS)} pages")
kept: ['page 0', 'page 2', 'page 4']
failed: [1, 3]
3 of 5 pages
assert collect([]) == ([], []), "no arrivals gives two empty lists"
assert collect([(7, None)]) == ([], [7]), "a failed page contributes its number, not its value"
assert collect([(7, "ok")]) == (["ok"], []), "a good page contributes its value, not its number"
print("✓ Looks good!")