Quick answer: A polite scraper limits concurrency per host, spaces requests with a small random delay (jitter), honors robots.txt and any Crawl-delay, caches responses so it never refetches the same page, backs off when it sees errors or 429s, and identifies itself with an honest User-Agent and a contact URL. The goal is simple: collect the data you need without making the site slower for anyone else.
Politeness is not just etiquette. A scraper that hammers a server gets blocked, poisons the IP it runs from, and sometimes causes real harm to a small site running on modest hardware. A polite scraper collects more data over time precisely because it does not get itself banned. Let me show you how to build one.
The five habits of a polite scraper
- Limit concurrency per host. One slow site should not receive fifty simultaneous connections from you.
- Rate-limit per host with jitter. Space requests out, and randomize the spacing so you are not a metronome.
- Respect
robots.txtandCrawl-delay. If the site tells you what it wants, listen. - Cache aggressively. The politest request is the one you never send because you already have the answer.
- Back off on errors. A
429or503means "slow down," so slow down.
Do these five things and you are already more considerate than most crawlers on the web.
Honor robots.txt first
Before fetching anything from a host, read its robots.txt and obey it. Python's standard library has a parser built in.
import urllib.robotparser as robotparser
def make_robots(base_url, ua):
rp = robotparser.RobotFileParser()
rp.set_url(base_url.rstrip("/") + "/robots.txt")
rp.read()
return rp
rp = make_robots("https://example.com", "PoliteBot")
if rp.can_fetch("PoliteBot", "https://example.com/some/path"):
... # allowed
crawl_delay = rp.crawl_delay("PoliteBot") or 1.0 # seconds, default to 1
robots.txt is a request from the site owner, and honoring it is the baseline of ethical collection. I cover the wider picture in ethical web scraping compliance best practices.
Rate limit per host, with jitter
The key word is per host. A global delay is too blunt: it slows you down across sites that could handle more, while still allowing bursts at a single slow host if you are crawling many at once. Track the last-request time for each host and wait out the remaining delay before the next hit.
import time, random, threading
from urllib.parse import urlparse
class HostRateLimiter:
def __init__(self, min_delay=1.0, jitter=0.5):
self.min_delay = min_delay
self.jitter = jitter
self._last = {} # host -> last request timestamp
self._locks = {} # host -> lock
self._guard = threading.Lock()
def _lock_for(self, host):
with self._guard:
return self._locks.setdefault(host, threading.Lock())
def wait(self, url, crawl_delay=None):
host = urlparse(url).netloc
delay = max(self.min_delay, crawl_delay or 0)
with self._lock_for(host):
last = self._last.get(host, 0)
elapsed = time.monotonic() - last
sleep_for = delay - elapsed + random.uniform(0, self.jitter)
if sleep_for > 0:
time.sleep(sleep_for)
self._last[host] = time.monotonic()
The jitter matters. A perfectly regular request every 1.000 seconds is an obvious machine signature. A request every 1.0 to 1.5 seconds looks far more like organic traffic and is gentler on the server's own rate windows.
Limit concurrency per host
Even with a delay, you want a hard ceiling on simultaneous connections to any one host. A semaphore per host does this cleanly.
from collections import defaultdict
import threading
class HostConcurrency:
def __init__(self, max_per_host=2):
self._sems = defaultdict(lambda: threading.Semaphore(max_per_host))
def slot(self, host):
return self._sems[host]
# usage
conc = HostConcurrency(max_per_host=2)
host = "example.com"
with conc.slot(host):
... # at most 2 concurrent requests to this host
Two concurrent connections per host is a reasonable default for a stranger's site. Raise it only if you have permission or clear evidence the site can take it.
Back off when the site pushes back
When you get a 429 Too Many Requests or a 503, the right response is to wait, ideally for the duration in the Retry-After header, and retry with exponential backoff plus jitter. Never retry immediately in a tight loop; that turns a temporary throttle into a self-inflicted ban. The full mechanics are in HTTP 429 error explained and how to fix it.
import time, random, requests
def fetch_with_backoff(session, url, headers, max_tries=5):
for attempt in range(max_tries):
r = session.get(url, headers=headers, timeout=30)
if r.status_code == 200:
return r
if r.status_code in (429, 503):
retry_after = r.headers.get("Retry-After")
wait = float(retry_after) if retry_after and retry_after.isdigit() \
else (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
continue
r.raise_for_status()
raise RuntimeError(f"gave up on {url}")
Cache so you never refetch
A response you already fetched should never be requested again until it plausibly changed. Even a simple on-disk cache keyed by URL saves the target from serving the same page twice and saves you time. For volatile pages, use conditional requests with ETag/If-Modified-Since so the server can answer 304 Not Modified cheaply. I wrote a dedicated guide on this: cache web responses to save time and credits.
Identify yourself honestly
A polite scraper does not hide. Send a descriptive User-Agent with a URL where the site owner can learn who you are and how to reach you.
UA = "PoliteBot/1.0 (+https://yoursite.example/botinfo; [email protected])"
This does two good things: it lets a site owner contact you instead of silently blocking you, and it signals good faith. Many sites are happy to let identified, well-behaved bots through.
Putting it together
Here is the shape of the whole polite fetcher.
def polite_get(url, session, limiter, conc, rp, ua):
if not rp.can_fetch(ua, url):
return None # respect robots.txt
host = urlparse(url).netloc
with conc.slot(host):
limiter.wait(url, crawl_delay=rp.crawl_delay(ua))
return fetch_with_backoff(session, url, {"User-Agent": ua})
Concurrency cap, per-host delay with jitter, robots check, and backoff, all in a handful of lines. Wrap a cache around it and you have a crawler that most site owners would never notice, let alone block.
The lazy way to be polite
Every rule above (per-host limits, backoff, honoring robots.txt, rotation, caching) is work you can hand off. link.sc applies polite defaults server side, so a single call returns clean content without you wiring up rate limiters and retry loops.
curl https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/article", "format": "markdown"}'
You still owe the same respect to the sites you collect from, but you are not the one who has to implement the throttle.
A note on ethics
Politeness and legality overlap but are not identical. Scrape only public data, stay within a site's stated limits, do not evade authentication or paywalls, and stop when a site clearly asks you to. Being polite is partly self-interest (you avoid bans) and partly just being a good citizen of a shared web. Both reasons point the same way.
Skip the rate-limiter plumbing: link.sc fetches with polite defaults built in. Get started free.