~/TechPurAI
~/tutorials/web-scraping-with-python/being-a-polite-scraper
intermediate·part 5 of 6·2 min read

Being a polite scraper: rate limiting, retries, and sessions

Updated Aug 14, 2026Python

The crawler from part 4 works — and fires requests as fast as Python and the network allow, with no recovery if one fails. Neither is fine on a real site. This part fixes both: slowing down on purpose, and surviving a bad response instead of crashing the whole crawl.

Slowing down between requests

python
import time

DELAY_SECONDS = 1.0

while url and pages_visited < max_pages:
    response = requests.get(url, headers=HEADERS, timeout=5)
    # ... parse, extend all_quotes, get next url ...
    pages_visited += 1
    time.sleep(DELAY_SECONDS)

A one-second pause between requests is close to invisible to you and meaningfully lighter on the target server, which — unlike the API-shaped services requests usually talks to — often isn't provisioned to handle a script hitting it as fast as your network allows. If the site's robots.txt specified a Crawl-delay, that number is a floor for DELAY_SECONDS, not a ceiling.

Reusing a Session across the whole crawl

python
session = requests.Session()
session.headers.update(HEADERS)

response = session.get(url, timeout=5)

Every requests.get() call so far has opened its own connection. Across a crawl making dozens of requests to the same host, a Session reuses the underlying TCP connection instead — and setting session.headers.update(HEADERS) once means the User-Agent from part 2 no longer needs to be passed to every individual call. Swap every requests.get(...) in the crawler for session.get(...) and pass the same session through to get_author_bio too.

Retrying a request that fails

python
def fetch_with_retry(session: requests.Session, url: str, max_retries: int = 3) -> requests.Response | None:
    for attempt in range(1, max_retries + 1):
        try:
            response = session.get(url, timeout=5)
            response.raise_for_status()
            return response
        except requests.exceptions.RequestException as err:
            if attempt == max_retries:
                print(f"Giving up on {url} after {max_retries} attempts: {err}")
                return None
            wait = 2 ** attempt
            print(f"Attempt {attempt} failed for {url}, retrying in {wait}s")
            time.sleep(wait)
    return None

One dropped connection or one slow response timing out shouldn't end a crawl that's ninety pages in. fetch_with_retry wraps a request in up to max_retries attempts, with the wait between attempts doubling each time (2 ** attempt: 2s, 4s, 8s) — long enough that a genuinely overloaded server gets breathing room, short enough that a one-off blip doesn't stall the run for minutes. Returning None after the final failure, rather than raising, lets the crawl loop decide to skip that one page and keep going instead of losing everything collected so far.

Common mistake

Retrying immediately, with no growing delay between attempts. If the reason a request failed was the server being overloaded, retrying instantly just adds to the load that caused the failure in the first place — the backoff is what actually gives it room to recover.

Everything's fetched, parsed, crawled, rate-limited, and resilient to failure now. The last part saves it somewhere useful, and assembles the whole series into one finished script.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous4. Crawling multiple pages: pagination and following linksnext →6. Saving the data and shipping the finished scraper