Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: A validator lets the server answer "unchanged" with no body · Freshness decides whether to ask at all. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

Interactive · message by message The price board, asked for again only if it changed Step through four visits to the board and see which answers carry the page and which carry only a status.
The price board, asked for again only if it changed, step 6 of 6. Messages so far: Your collector to Nimbu Bazaar: GET /board; Nimbu Bazaar to Your collector: 200, 2,111 bytes, ETag; Your collector to Nimbu Bazaar: If-None-Match: "ed6f…"; Nimbu Bazaar to Your collector: 304, 0 bytes; Nimbu Bazaar to Your collector: 200, 2,112 bytes, new ETag.Your collector4,223 bytes, not 304,056Nimbu Bazaar/board, changes at 12 hGET /board200, 2,111 bytes, ETagIf-None-Match: "ed6f…"304, 0 bytes200, 2,112 bytes, new ETag

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first visit. With nothing stored, the collector asks for /board without a validator, as for any page.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Visits to the price board with and without validators

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()

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:

Interactive · stage by stage Fresh, stale, or marked to be checked every time Follow three moments when the collector wants the board again and see which ones need a request.
Fresh, stale, or marked to be checked every time, step 5 of 5. Showing The page is wanted again (age 120 s), Marked no-cache? (no-cache: ask each time), Younger than its lifetime? (600 s > 300 s: stale), Ask with its validator (304: still the same), Use the copy (the stored copy). In focus: Marked no-cache?, Ask with its validator.yes: freshyesThe page is wanted againage 120 sMarked no-cache?no-cache: ask each timeYounger than its lifetime?600 s > 300 s: staleAsk with its validator304: still the sameUse the copythe stored copyyes

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Your store holds a page with both an ETag and a Last-Modified. What should the next request for that page send?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both, If-None-Match with the ETag and If-Modified-Since with the date

    Send back every validator the page gave you; a server that knows the tag decides by If-None-Match, the more accurate test. In the lab, the ETag of the board turned a day of 144 visits from 304,056 bytes into 4,223.

  2. Question 2 of 2 The board came with Cache-Control: max-age=300 and was stored 4 minutes ago. The collector wants it again now. What does its cache do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Uses the stored copy without sending any request

    Fresh means age below lifetime. At 4 minutes the board is fresh for another 60 s; at 10 minutes it would be stale and need a conditional request, as the flow showed.

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.

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.

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.

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.

  1. Question 1 of 8 What does a 304 Not Modified answer contain?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Headers only, saying the client's stored copy is still current

    A 304 answers a conditional GET whose condition was false: nothing changed, so the server sends the validators and other metadata and no content.

  2. Question 2 of 8 Which request header carries a stored ETag back to the server?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: If-None-Match

    The pairs are ETag with If-None-Match and Last-Modified with If-Modified-Since. If-Match and If-Unmodified-Since are for changes to a resource, not for caching.

  3. Question 3 of 8 A collector sends If-None-Match with urllib.request and the page has not changed. What happens in its code?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: urllib raises HTTPError with code 304, which the collector catches to use its stored copy

    With urllib, handle the 304 in an except branch, as the lab's get() does. Libraries with a cache, such as requests-cache or hishel, do this for you.

  4. Question 4 of 8 A partner's API returns an ETag on every list page and does not count an authorised 304 against your rate limit. You poll 50 pages every 5 minutes, and most never change. What saves the most of your rate limit?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: Sending If-None-Match with each page's stored ETag, so unchanged pages come back as 304

    Conditional requests turn unchanged pages into 304s, and an API that does not count those against the limit leaves almost all of it for the pages that changed. Keep the parameters and sort order stable so that the same page keeps the same ETag.

  5. Question 5 of 8 When does RFC 9111 call a stored answer fresh?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: While its age is less than its freshness lifetime

    The lifetime comes from max-age, else from Expires minus Date, else from a cautious heuristic; the age is how long ago the server produced or confirmed the copy.

  6. Question 6 of 8 An answer says Cache-Control no-cache. What may the collector's cache do with it?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Store it, but revalidate it with the server before every use

    no-cache sends every use through a conditional request, which is cheap when the server answers 304; no-store keeps the answer out of the cache altogether.

  7. Question 7 of 8 An answer has no max-age, a Date of 10:00:00 and an Expires of 10:45:00 on the same day. What is its freshness lifetime?

    Type a number.

    A hint

    Without max-age, the lifetime is Expires minus Date.

    Show the answer: Question 7 of 8

    Answer: 2700 seconds

    45 minutes are 2,700 seconds. RFC 9111 subtracts Date, the server's own clock, so the answer does not depend on how far the cache's clock is from the server's.

  8. Question 8 of 8 The board sends max-age=300, and your collector wants it every minute. Of each five minutes of visits, how many requests does a cache that follows RFC 9111 send?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: One conditional request; the other four visits use the fresh copy

    Freshness and validation work together: max-age removes the requests inside the lifetime, and a conditional request makes the one at the end of it cheap.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Store a page’s ETag and Last-Modified and 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, Expires minus Date or a cautious guess, and a fresh copy needs no request at all.
  • no-cache means ask before every use, no-store means 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.