Bobobk

Practical Python, Linux and automation guides

Python Conditional JSON Fetching: ETag, 304, and a Local Cache

2026-10-29 Technology
Python Conditional JSON Fetching: ETag, 304, and a Local Cache
On this page

A scheduled script downloads the same JSON document every hour, even when nothing changes. An HTTP ETag lets the next request ask whether its saved version is still current, avoiding another response body when the server returns 304 Not Modified.

This beginner-to-intermediate tutorial builds a standard-library client, stores the validator and parsed data together, and tests the complete workflow on loopback. It complements bounded HTTP timeout and retry handling: here the new goal is conditional fetching, not retrying failures.

1. Method: keep data and validator together

RFC 9110 section 13.1.2 specifies If-None-Match for conditional GET requests. Use these rules:

Result Action
No cache Send an ordinary GET.
Cache has an ETag Send it unchanged as If-None-Match.
200 with valid JSON Save URL, ETag, and parsed data in one cache file.
304 after sending a validator Reuse saved data; do not parse an empty body.
200 without an ETag Save data with a null validator; fetch unconditionally next time.
HTTP/network/parse failure Exit nonzero; keep the previous cache.

The quoted tag is opaque: preserve both quotation marks and a possible W/ prefix. Weak validators are usable for this conditional GET; they do not establish byte identity. See the ETag definition.

Use one trusted cache path per fixed public endpoint, with one writer and a stable request representation. The endpoint must permit local persistence. This is an application snapshot, not a general HTTP cache: it does not implement Cache-Control, Vary, authentication-specific storage, or resumable downloads. Do not apply it to private responses or endpoints forbidding storage.

2. Setup: a reproducible local endpoint

Use Python 3.10+ and curl; no pip packages are needed. Tested on Linux with Python 3.11.4. The downloadable files are fetch_cached.py, demo_server.py, and check_cache.py.

  1. In terminal A, create a new practice directory and start the demo server:
python3 --version
mkdir etag-demo
cd etag-demo
curl -fS https://www.bobobk.com/downloads/http-etag-cache/fetch_cached.py -o fetch_cached.py
curl -fS https://www.bobobk.com/downloads/http-etag-cache/demo_server.py -o demo_server.py
curl -fS https://www.bobobk.com/downloads/http-etag-cache/check_cache.py -o check_cache.py
python3 fetch_cached.py --help
printf '{"count": 1}\n' > data.json
python3 demo_server.py --port 8765

The server reads data.json from its working directory, computes a content-based tag, and listens only on 127.0.0.1. It is a teaching fixture, not a production server. If the port is occupied, choose another --port and change the client URL below to match. Leave this terminal running.

  1. Save or download the client below. In terminal B, enter the same etag-demo directory before proceeding to section 4.

3. Complete client: handle 304 explicitly

"""Revalidate one small public JSON endpoint. Python 3.10+, one writer."""
import argparse
from http.client import HTTPException
import json
import math
import os
from pathlib import Path
import sys
import tempfile
from urllib.error import HTTPError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

LIMIT = 1024 * 1024


def read_limited(stream, limit):
    raw = stream.read(limit + 1)
    if len(raw) > limit:
        raise ValueError("document exceeds size limit")
    return raw


def save_cache(path, record):
    text = json.dumps(record, ensure_ascii=False, allow_nan=False) + "\n"
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", encoding="utf-8", dir=path.parent, delete=False
        ) as stream:
            temporary = Path(stream.name)
            stream.write(text)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)


def fetch(url, path, timeout):
    cached = None
    try:
        with path.open("rb") as stream:
            cached = json.loads(read_limited(stream, 8 * LIMIT))
    except FileNotFoundError:
        pass
    if cached is not None:
        if (not isinstance(cached, dict) or cached.get("url") != url
                or "data" not in cached or "etag" not in cached
                or not (cached["etag"] is None or isinstance(cached["etag"], str))):
            raise ValueError("cache schema or URL mismatch; use a separate cache")
    elif path.exists():
        raise ValueError("cache must be an object, not null")

    headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
    if cached is not None and cached["etag"]:
        headers["If-None-Match"] = cached["etag"]
    try:
        with urlopen(Request(url, headers=headers), timeout=timeout) as response:
            if response.status != 200:
                raise ValueError(f"unexpected HTTP status: {response.status}")
            if response.headers.get("Content-Encoding", "identity") != "identity":
                raise ValueError("compressed response not supported")
            data = json.loads(read_limited(response, LIMIT).decode("utf-8"))
            record = {"url": url, "etag": response.headers.get("ETag"), "data": data}
    except HTTPError as exc:
        try:
            if exc.code != 304:
                raise
            if "If-None-Match" not in headers:
                raise ValueError("304 without a cached validator") from exc
            return {"status": "not_modified", "data": cached["data"]}
        finally:
            exc.close()
    save_cache(path, record)
    return {"status": "updated", "data": data}


def main():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("url")
    parser.add_argument("cache", type=Path, help="dedicated JSON cache; parent must exist")
    parser.add_argument("--timeout", type=float, default=5)
    args = parser.parse_args()
    parts = urlsplit(args.url)
    if parts.scheme not in ("http", "https") or not parts.hostname:
        parser.error("URL must use http or https")
    if not math.isfinite(args.timeout) or args.timeout <= 0:
        parser.error("--timeout must be positive and finite")
    try:
        result = fetch(args.url, args.cache, args.timeout)
        print(json.dumps(result, ensure_ascii=False, allow_nan=False))
    except (OSError, ValueError, HTTPException, RecursionError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Python exposes unhandled HTTP statuses through HTTPError; handle 304 before treating other HTTP failures as errors, and close its response. This branch requires both saved data and a sent validator. An unsolicited 304 cannot manufacture a valid cache.

The client accepts at most 1 MiB of response bytes, requires UTF-8 JSON, and rejects compressed bodies. It stores parsed data, not an exact copy of the original whitespace or number spelling; it does not validate application fields. Cache reads are capped at 8 MiB to allow serialized escaping overhead. A corrupt cache or URL mismatch fails instead of silently switching inputs.

save_cache() serializes before writing, then replaces the destination from a sibling temporary file. This prevents a failed request or invalid JSON from replacing the previous snapshot. The atomic JSON write tutorial explains the filesystem boundary: replacement is not multi-writer locking or a complete power-loss durability guarantee.

4. Usage: observe 200, then 304, then changed data

  1. In terminal B, bypass proxies only for loopback and run the same request twice:
export no_proxy=127.0.0.1,localhost
export NO_PROXY=127.0.0.1,localhost
python3 fetch_cached.py http://127.0.0.1:8765/data.json cache.json
python3 fetch_cached.py http://127.0.0.1:8765/data.json cache.json

Expected stdout, one line per invocation:

{"status": "updated", "data": {"count": 1}}
{"status": "not_modified", "data": {"count": 1}}

The second run still makes an HTTP request, but reuses the local data after 304. Both outcomes exit 0. The cache’s bytes and modification time stay unchanged on 304; its mtime is therefore not the last successful validation time.

  1. Change the demo input and fetch again:
printf '{"count": 2}\n' > data.json
python3 fetch_cached.py http://127.0.0.1:8765/data.json cache.json
python3 -m json.tool cache.json

The fetch now prints {"status": "updated", "data": {"count": 2}}. The cache includes url, etag, and data; its validator changes together with the data. Do not strip the ETag’s quotes when inspecting the file.

  1. Gate downstream work on the current invocation, not just cache existence:
if python3 fetch_cached.py http://127.0.0.1:8765/data.json cache.json > result.json; then
    python3 -m json.tool result.json
else
    printf 'Fetch failed; do not consume the old cache as a fresh result\n' >&2
fi

Exit 1 means a fetch/cache error; exit 2 means invalid CLI arguments. The shell redirection may leave an empty result.json after failure, so consume it only inside the success branch. This client deliberately has no automatic retry or stale-on-error success mode.

5. Validation: test changed and broken responses

Run the downloaded self-check in terminal B:

python3 check_cache.py
PASS: 200 stores; 304 reuses without rewriting; weak ETag preserved
PASS: changed ETag replaces data and validator together
PASS: 503, invalid JSON, oversized body, timeout preserve old cache
PASS: no ETag means another unconditional GET
PASS: unsolicited 304 fails without creating a cache
PASS: corrupt cache, URL mismatch, invalid timeout rejected
OK: all conditional-fetch checks passed

The test starts its own loopback server on an automatically selected port, uses a temporary cache, checks actual JSON and exit codes, then stops the server and removes its temporary directory. It does not use terminal A’s data or cache. Assertions cover validator transmission, cache bytes and mtime on 304, changed data, and preservation after failure.

For scheduled use, record validation success separately if you also need a timestamp. See output freshness checks for why an unchanged cache’s mtime cannot prove when the remote resource was last checked.

6. Troubleshooting

Symptom Cause and concrete fix
Always updated Inspect whether the endpoint returns an ETag. A missing tag intentionally forces another full GET; do not invent a remote validator.
304 without a cached validator The response is unusable without matching saved data. Inspect the server or intermediary; do not treat an empty body as JSON.
cache schema or URL mismatch Use a new dedicated cache filename for the new URL. Preserve the old file if another task still uses it.
ERROR: after malformed cache Inspect python3 -m json.tool cache.json; archive the damaged demo cache with mv cache.json cache.saved.json, then fetch afresh. Choose a nonexisting archive name.
Connection refused or wrong port Keep the loopback server running and match the printed URL. Use no_proxy as shown above if your environment routes local requests through a proxy.
Timeout Check service health before increasing --timeout. Python’s urlopen documentation describes blocking-operation timeouts, not a hard total job deadline.
Permission or missing-parent error Choose a writable trusted cache directory that already exists; do not run the script as root just to bypass the error.

Stop terminal A’s server with Ctrl+C when finished. Keep real deployments on verified HTTPS; loopback HTTP here is only a local fixture. The script makes no scheduler or server-configuration changes.

7. Summary

Save the ETag alongside the data it validates, and handle 304 as reuse rather than a JSON parsing attempt. A failed request must remain a failure even when an old snapshot exists. Use the local checks before attaching this small client to a public-data automation task.

Tags

1024 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu monitoring cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download economics electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print probability prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sql sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite wallet web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 迅雷解析

友情链接