Web Scraping & Browser Automation (Python and JavaScript) Module 5 – Politeness, caching and robust crawls
Caching and conditional requests: ETag, 304
Ask for a page only if it changed: ETag with If-None-Match, Last-Modified with If-Modified-Since, freshness from Cache-Control, and what a 304 saves.
What you will learn
- Send conditional requests with ETag and If-None-Match, or Last-Modified and If-Modified-Since, and use a 304
- Decide from max-age, Expires and Age whether a stored copy is fresh or must be revalidated
- Build a validator cache in SQLite and measure the bytes and requests it saves over several runs
- Recognise validators that change on every answer, are ignored or miss a change, and guard against each
- Configure requests-cache, hishel and Scrapy so that they revalidate stored pages instead of serving them stale
Before you start
What you’ll learn · Before you start
What you will learn
- Send conditional requests with ETag and If-None-Match, or Last-Modified and If-Modified-Since, and use a 304
- Decide from max-age, Expires and Age whether a stored copy is fresh or must be revalidated
- Build a validator cache in SQLite and measure the bytes and requests it saves over several runs
- Recognise validators that change on every answer, are ignored or miss a change, and guard against each
- Configure requests-cache, hishel and Scrapy so that they revalidate stored pages instead of serving them stale
Before you start
On this page
The practice shop’s price board changes once a day, but a collector that checks it every 10 minutes downloads it 144 times, 304,056 bytes for two versions of one page. With conditional requests the same 144 visits download 4,223 bytes: 142 times the shop answers only that nothing has changed.
HTTP caching works in two steps. Validators, an ETag or a modification date, let you ask “has it changed?” and get an
empty 304 Not Modified when it has not. Freshness, from Cache-Control, says when you need not ask at all.
A validator lets the server answer “unchanged” with no body
The board’s first answer carries two validators besides the page: ETag: "ed6f053bf0293c66", an
ETag: An entity tag: a quoted value a server sends with a page, such as "ed6f053bf0293c66", that changes when the page changes. A client sends it back in If-None-Match to ask whether the page is still the same. that changes whenever the page does, and Last-Modified, the time it last changed.
Store them with the page. Next time send the ETag back in If-None-Match, or the date in If-Modified-Since, which
turns the request into a conditional request: A request that carries a validator in If-None-Match or If-Modified-Since, so that the server answers 304 Not Modified with no body when the client's stored copy is still current.: if nothing changed, the answer is
304 Not Modified, which RFC 9110 says cannot contain content; if it did, a normal 200 with the new page. Step
through four visits:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first visit. With nothing stored, the collector asks for /board without a validator, as for any page.
- A page with validators. The answer is 200 with the 2,111 bytes of the board, an ETag of "ed6f053bf0293c66" and a Last-Modified. The collector stores all three together.
- Has it changed?. Ten minutes later it asks again with If-None-Match: "ed6f053bf0293c66", which means send the board only if its ETag is no longer this one. The date may travel along in If-Modified-Since, but next to the tag the shop ignores it.
- 304, no body. The board has not changed, so the shop answers 304 Not Modified with headers only. The collector uses the copy it stored, and 0 bytes of page crossed.
- After the change. At 12.2 h the same question gets a 200 with the new board, 2,112 bytes, and a new ETag, "b1a526728549bf32", which replaces the stored one.
- A day of visits. Visiting every 10 minutes for a day is 144 requests either way. Without validators they bring 304,056 bytes of pages; with If-None-Match, 2 answers are 200 and 142 are 304, 4,223 bytes in all.
The program makes those visits through urllib, then a whole day of them, with and without the ETag:
cache/conditional_get.py
"""Visits to the price board with validators: the first downloads it, the next ask "has it changed?" and get 304
with no body until the board changes at 12 hours. Then a day of visits every 10 minutes, with and without them."""
import nimbu_lab as lab
def visit(when, headers):
lab.clock.seconds = when
status, answer, body = lab.get("/board", headers)
shown = ", ".join(f"{k}: {v}" for k, v in headers.items()) or "no validators"
print(f"{when / 3600:>5.1f} h {shown:<52} {status} {len(body):>5} bytes ETag {answer['ETag']}")
return answer
lab.reset()
first = visit(0, {})
visit(600, {"If-None-Match": first["ETag"]})
newer = visit(43_800, {"If-None-Match": first["ETag"]})
visit(44_400, {"If-None-Match": newer["ETag"]})
print("Last-Modified of the first answer:", first["Last-Modified"])
visit(600, {"If-Modified-Since": first["Last-Modified"]})
print()
for label, use_validators in [("without validators", False), ("with If-None-Match", True)]:
lab.reset()
etag, downloaded = None, 0
for visit_number in range(144): # every 10 minutes for a day
lab.clock.seconds = visit_number * 600
status, answer, body = lab.get("/board", {"If-None-Match": etag} if use_validators and etag else {})
etag = answer["ETag"]
downloaded += len(body)
statuses = [entry["status"] for entry in lab.LOG]
print(f"A day, {label}: {len(statuses)} requests, {statuses.count(200)} answered 200 and "
f"{statuses.count(304)} answered 304, {downloaded:,} bytes of pages") Output
0.0 h no validators 200 2111 bytes ETag "ed6f053bf0293c66" 0.2 h If-None-Match: "ed6f053bf0293c66" 304 0 bytes ETag "ed6f053bf0293c66" 12.2 h If-None-Match: "ed6f053bf0293c66" 200 2112 bytes ETag "b1a526728549bf32" 12.3 h If-None-Match: "b1a526728549bf32" 304 0 bytes ETag "b1a526728549bf32" Last-Modified of the first answer: Fri, 15 Jan 2027 08:00:00 GMT 0.2 h If-Modified-Since: Fri, 15 Jan 2027 08:00:00 GMT 304 0 bytes ETag "ed6f053bf0293c66" A day, without validators: 144 requests, 144 answered 200 and 0 answered 304, 304,056 bytes of pages A day, with If-None-Match: 144 requests, 2 answered 200 and 142 answered 304, 4,223 bytes of pages
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 conditional_get.py
nimbu_lab.py (the shop) · cache/nimbu_lab.py
"""Nimbu Bazaar, the practice shop of this track, with the pages of the caching lesson. Every product, price and
person in it is invented.
The shop is a WSGI application (PEP 3333): app() answers one request. get() sends a request through Python's own
HTTP client, urllib.request, whose last step hands it to app() instead of opening a socket, so nothing leaves your
program. Time is the shop's clock, which you move on with clock.sleep(), and LOG keeps what the shop saw.
The pages:
/board the wholesale price board: ETag, Last-Modified and Cache-Control: max-age=300; it changes
at 12 hours on the shop's clock
/products/NB-0001/ … 240 product pages, each with its ETag and Last-Modified; new_night(n) changes 12 of them
/broken/rotating an ETag that changes on every answer, although the page does not
/broken/ignores validators sent, but If-None-Match ignored: always 200 with the whole page
/broken/second Last-Modified only, to the second, on a page that changes twice within one second
Conditional requests are answered as RFC 9110 section 13.2.2 orders them: If-None-Match first, compared with the
weak comparison; If-Modified-Since only when there is no If-None-Match.
"""
import email.utils
import hashlib
import http.client
import io
import random
import urllib.error
import urllib.request
import urllib.response
from urllib.parse import urlsplit
HOST = "bazaar.nimbu.test"
EPOCH = 1_800_000_000 # the clock reads 0 at this moment, in seconds since 1970
KINDS = ["Basmati rice", "Toor dal", "Green tea", "Jaggery", "Turmeric", "Mustard oil", "Poha", "Rock salt",
"Cashews", "Cardamom", "Ragi flour", "Ghee", "Honey", "Peanuts", "Coffee", "Raisins", "Sesame seeds",
"Tamarind", "Cumin", "Black pepper", "Rolled oats", "Cloves", "Fennel", "Moong dal", "Chana dal",
"Wheat flour", "Semolina", "Coriander", "Red chilli", "Almonds", "Dates", "Coconut oil", "Brown rice",
"Millet", "Flax seeds", "Cinnamon", "Ginger powder", "Vermicelli", "Masoor dal", "Sago"]
SIZES = [250, 500, 1000, 2000, 5000, 10000] # grams
class Clock:
"""The shop's clock, in seconds."""
def __init__(self):
self.seconds = 0.0
def now(self):
return self.seconds
def sleep(self, seconds):
self.seconds += max(0.0, seconds)
clock = Clock()
LOG = [] # what the shop saw: one entry per request
STATE = {} # each product's version and when it last changed; counters of the broken pages
def products():
"""The 240 products: 40 kinds in 6 pack sizes."""
out = []
for k, kind in enumerate(KINDS):
per_kg = 60 + (k * 37) % 900
for grams in SIZES:
size = f"{grams // 1000} kg" if grams >= 1000 else f"{grams} g"
out.append({"sku": f"NB-{len(out) + 1:04d}", "name": f"{kind} {size}", "paise": per_kg * grams // 10 + 1000})
return out
PRODUCTS = {p["sku"]: p for p in products()}
def reset():
"""A fresh shop: the clock at 0, an empty log, every product at version 1."""
clock.seconds = 0.0
LOG.clear()
STATE.clear()
STATE.update({sku: (1, 0.0) for sku in PRODUCTS}, rotating=0, second=[])
def new_night(n):
"""Move the clock to the start of night n (n days after 0) and change 12 products' prices, chosen by n."""
clock.seconds = n * 86_400.0
for sku in random.Random(n).sample(sorted(PRODUCTS), 12):
version, _ = STATE[sku]
STATE[sku] = (version + 1, clock.seconds)
def http_date(seconds):
return email.utils.formatdate(EPOCH + seconds, usegmt=True)
def etag_of(body):
"""A strong validator: a hash of the bytes, which changes whenever the page does."""
return '"' + hashlib.sha256(body).hexdigest()[:16] + '"'
def tags_in(field):
"""The opaque tags of an If-None-Match field, with any W/ prefix dropped: the weak comparison."""
return {t.strip().removeprefix("W/") for t in field.split(",") if t.strip()}
def conditional(headers, etag, modified, body, extra=()):
"""A 304 with no body when the client's copy is current, else a 200 with the page (RFC 9110, 13.2.2)."""
common = [("Date", http_date(clock.now())), *extra]
if etag:
common.append(("ETag", etag))
if modified is not None:
common.append(("Last-Modified", http_date(modified)))
inm, ims = headers.get("If-None-Match"), headers.get("If-Modified-Since")
if inm is not None:
unchanged = "*" in tags_in(inm) or (etag is not None and etag.removeprefix("W/") in tags_in(inm))
elif ims is not None and modified is not None:
try:
unchanged = int(modified) <= email.utils.parsedate_to_datetime(ims).timestamp() - EPOCH
except (TypeError, ValueError):
unchanged = False
else:
unchanged = False
if unchanged:
return "304 Not Modified", common, b""
return "200 OK", [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(body))), *common], body
def board_page():
"""The price board: 40 rows; its prices change at 12 hours."""
turn = 1 if clock.now() >= 43_200 else 0
rows = "".join(f"<tr><td>{p['name']}</td><td>₹{(p['paise'] + 150 * turn) // 100:,}.00</td></tr>\n"
for p in list(PRODUCTS.values())[::6])
body = f"<!doctype html>\n<title>Price board · Nimbu Bazaar</title>\n<table>\n{rows}</table>\n".encode()
return body, 43_200.0 if turn else 0.0
def product_page(sku):
version, changed = STATE[sku]
p = PRODUCTS[sku]
price = p["paise"] + 25 * (version - 1)
filler = "".join(f"<li>Pack note {i}: store in a cool, dry place.</li>\n" for i in range(24))
body = (f"<!doctype html>\n<title>{p['name']} · Nimbu Bazaar</title>\n<h1>{p['name']}</h1>\n"
f"<p class=\"price\">₹{price // 100:,}.{price % 100:02d}</p>\n<ul>\n{filler}</ul>\n").encode()
return body, changed
def app(environ, start_response):
"""The shop: answers one request, and writes it into LOG first."""
path = environ["PATH_INFO"]
headers = {k[5:].replace("_", "-").title(): v for k, v in environ.items() if k.startswith("HTTP_")}
if environ.get("HTTP_HOST") != HOST:
status, out, body = "421 Misdirected Request", [("Content-Type", "text/plain")], b"not a Nimbu Bazaar address\n"
elif path == "/board":
page, modified = board_page()
status, out, body = conditional(headers, etag_of(page), modified, page, [("Cache-Control", "max-age=300")])
elif path.startswith("/products/") and path.split("/")[2] in PRODUCTS:
page, modified = product_page(path.split("/")[2])
status, out, body = conditional(headers, etag_of(page), modified, page)
elif path == "/broken/rotating": # the same page, a new ETag every time (say, a counter in the tag)
STATE["rotating"] += 1
page = b"<!doctype html>\n<title>Delivery areas</title>\n<p>We deliver to 18 areas.</p>\n"
status, out, body = conditional(headers, f'"r{STATE["rotating"]}"', 0.0, page)
elif path == "/broken/ignores": # sends validators, never reads them
page = b"<!doctype html>\n<title>Opening hours</title>\n<p>9 am to 9 pm, every day.</p>\n"
status, out, body = conditional({}, etag_of(page), 0.0, page)
elif path == "/broken/second": # two changes at 100.2 s and 100.7 s, one Last-Modified of 100 s
edits = [t for t in (100.2, 100.7) if clock.now() >= t]
page = f"<!doctype html>\n<title>Stock note</title>\n<p>Edits so far: {len(edits)}</p>\n".encode()
status, out, body = conditional(headers, None, int(edits[-1]) if edits else 0.0, page)
else:
status, out, body = "404 Not Found", [("Content-Type", "text/plain")], b"no such page\n"
LOG.append({"t": clock.now(), "path": path, "status": int(status[:3]), "bytes": len(body),
"if_none_match": headers.get("If-None-Match"), "if_modified_since": headers.get("If-Modified-Since")})
start_response(status, out)
return [body]
class ShopTransport(urllib.request.AbstractHTTPHandler):
"""urllib's last step, with app() where the socket would be."""
handler_order = 100
http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_
def https_open(self, req):
headers = dict(req.unredirected_hdrs)
headers.update({k: v for k, v in req.headers.items() if k not in headers})
parts = urlsplit(req.full_url)
environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
"wsgi.input": io.BytesIO(b"")}
environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items()})
got = {}
body = b"".join(app(environ, lambda status, hdrs, exc_info=None: got.update(status=status, headers=hdrs)))
raw = "".join(f"{k}: {v}\r\n" for k, v in got["headers"]) + "\r\n"
message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, int(got["status"][:3]))
response.reason = response.msg = got["status"][4:]
return response
http_open = https_open
OPENER = urllib.request.OpenerDirector()
for _handler in [ShopTransport(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPErrorProcessor(),
urllib.request.UnknownHandler()]:
OPENER.add_handler(_handler)
def get(path, headers=None):
"""GET path through urllib: (status, headers, body). urllib raises HTTPError for 304 and errors; this returns
them, so a 304 comes back as (304, its headers, b"")."""
request = urllib.request.Request(f"https://{HOST}{path}", headers={
"User-Agent": "NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)", **(headers or {})})
try:
with OPENER.open(request, timeout=10) as response:
return response.status, response.headers, response.read()
except urllib.error.HTTPError as error:
return error.code, error.headers, error.read()
reset() Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The day took 144 requests either way; conditional requests saved bytes, not requests, and 98.6 % of them. urllib
reports a 304 as an HTTPError, because it is not a 2xx, so the shop’s get() catches it. Some APIs reward
validators twice: GitHub’s REST API does not count a 304 against its primary rate limit when the request was
correctly authorised.
| Validator | ETag | Last-Modified |
|---|---|---|
| Sent back in | If-None-Match |
If-Modified-Since |
| Notices | every change, when the tag is strong | changes at least a second apart |
| When both are sent | it decides | the server must ignore it |
Send back both when the page gave you both, as RFC 9111 asks of a cache. A server that has the tag decides by it,
because RFC 9110 calls If-None-Match the more accurate condition, and the date is there for older intermediaries
that do not understand tags.
Freshness decides whether to ask at all
The board also says Cache-Control: max-age=300. RFC 9111 calls a stored answer fresh while its age is less than
its freshness lifetime: How long a stored answer may be used without asking the server, from max-age, or Expires minus Date, or a cautious guess. Past it the copy is stale and must be confirmed first., and lets a cache use a fresh copy without contacting the
server. The lifetime comes from max-age; without it, Expires minus Date; without either, a cache may guess, and
the standard suggests at most a fraction of the time since Last-Modified, typically 10 %. A copy past its lifetime
is stale and is used only after a conditional request confirms it. Follow the board at 2 and 10 minutes:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two minutes later. The collector wants the board again 120 s after storing it. The answer was not marked no-cache, so the next question is its age.
- Still fresh. Its lifetime is max-age=300 and its age is 120 s, so the copy is fresh, and RFC 9111 lets a cache use it without contacting the server at all.
- Ten minutes later. At 600 s the copy is older than its 300 s lifetime. It is stale, and a stale copy is used only once the server has confirmed it.
- Ask, cheaply. The collector sends a conditional request with If-None-Match. The 304 that comes back makes the stored copy fresh again for another 300 s.
- An answer marked no-cache. A page sent with no-cache may be stored, but must be confirmed before every use, however young it is, so the flow goes straight to a conditional request.
A collector’s cache is a private cache, used by one client, so these directives decide its caching:
| The answer says | What the collector’s cache does |
|---|---|
max-age=300 |
uses the copy for 300 s without asking, then revalidates |
no-cache |
may store it, but revalidates before every use |
no-store |
does not keep it at all |
must-revalidate |
never uses it stale, even when the server cannot be reached |
A collector need not build its cache by hand. requests-cache adds a cache to a requests session. hishel adds one to httpx clients. Scrapy’s HttpCacheMiddleware keeps the answers of a crawl on disk, under a policy that a setting chooses.
Key idea
Ask conditionally and an unchanged page costs a few headers instead of its body; respect freshness and a fresh page costs no request at all.
Checkpoint
Check: ETag and Last-Modified in conditional requests · Freshness, when no request is needed
A validator cache in SQLite, measured over three nights Pro: opens with a Pro, Premium or Ultimate pass
A collector’s cache has to survive between runs and remember what it sent. This part keeps the validators and the pages in SQLite, runs three nights over the shop’s 240 product pages and measures what it saves.
In this part:
- Three nightly runs over 240 product pages, measured
- What the store keeps, column by column
- Practise: a conditional fetch that keeps its store honest
- Fetch a page only when it has changed
- Results of the sample tests
1 runnable example (cache_store.py, nimbu_lab.py (the shop)) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Validators that let a cache down, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A validator is only as good as the server that makes it. This part reproduces three that fail in different ways on the lab shop and adds the check that catches each.
In this part:
- Three broken validators on the lab shop, and what each costs
- Choosing what to trust, validator by validator
1 runnable example (lying_validators.py, nimbu_lab.py (the shop)) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Freshness rules and library caches, worked through Pro: opens with a Pro, Premium or Ultimate pass
Freshness decides how many requests you send at all, and a library’s defaults decide whether it ever revalidates. This part works RFC 9111’s rules through seven stored answers, then compares requests-cache, hishel and Scrapy.
In this part:
- Seven stored answers through RFC 9111’s rules
- What three Python caches do by default
1 runnable example (freshness.py, nimbu_lab.py (the shop)) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise reading validators and freshness
The practice session asks about validators, 304 answers and freshness, beginning with your weakest answers:
Practice that adapts to you
Practise this lesson
8 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is a 304 Not Modified answer, and how does a client get one?
A 304 says that the copy the client already holds is still current, so the server sends headers and no body. The
client earns it with a conditional GET: it stores the ETag or Last-Modified of an earlier answer and sends it back
in If-None-Match or If-Modified-Since. If the page is unchanged the server answers 304 and the client reuses its
copy; if the page changed it answers 200 with the new version and new validators. A collector saves bandwidth this
way, and some APIs, such as GitHub’s, do not count an authorised 304 against the rate limit.
Three harder questions follow from this lesson: a nightly crawl whose bandwidth bill has to fall, a cache that served yesterday’s prices, and a site whose validators change on every visit. Their first steps are on this module’s question page:
-
Mid to senior
Your nightly crawl downloads 40,000 product pages from a partner, about 9 GB a night, and the partner says only about 2 % of the pages change each day. They ask you to cut your traffic. What do you change, and what saving can you promise?
-
Mid to senior
A collector built on a caching HTTP library has stored the same prices for a week, although the shop changed them twice. Nothing logged an error. How do you work out why, and how do you keep it from happening again?
-
Fresher to mid
You added If-None-Match to a crawler, but one site never answers 304: every visit downloads the whole page, although it rarely changes. What could cause it, and what do you do about it?
Key takeaways
- Store a page’s
ETagandLast-Modifiedand send one back, the ETag first: an unchanged page then costs an empty 304 instead of its body, 4,223 bytes a day for the board instead of 304,056. - A stored copy is fresh while its age is below its lifetime, from
max-age,ExpiresminusDateor a cautious guess, and a fresh copy needs no request at all. no-cachemeans ask before every use,no-storemeans keep nothing, and a stale copy is used only after a conditional request confirms it.
Terms in this lesson
- conditional request
- A request that carries a validator in If-None-Match or If-Modified-Since, so that the server answers 304 Not Modified with no body when the client's stored copy is still current.
- ETag
- An entity tag: a quoted value a server sends with a page, such as "ed6f053bf0293c66", that changes when the page changes. A client sends it back in If-None-Match to ask whether the page is still the same.
- freshness lifetime
- How long a stored answer may be used without asking the server, from max-age, or Expires minus Date, or a cautious guess. Past it the copy is stale and must be confirmed first.
References
- RFC 9110: HTTP Semantics (sections 8.8, 13.1.2, 13.1.3, 13.2.2 and 15.4.5) (IETF)
- RFC 9111: HTTP Caching (sections 4.2, 4.3 and 5.2.2) (IETF)
- Best practices for using the REST API (use conditional requests) (GitHub)
- requests-cache 1.3.3: Cache Headers (requests-cache project)
- requests-cache 1.3.3: Expiration (requests-cache project)
- Hishel: Caching Policies (Hishel project)
- Hishel: Storages (Hishel project)
- Scrapy 2.19: Downloader middleware (HttpCacheMiddleware and its policies) (Scrapy developers)
- sqlite3: How to use the connection context manager (Python 3.14) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress