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.
- 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.
- Save or download the client below. In terminal B, enter the same
etag-demodirectory 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
- 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.
- 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.
- 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.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-http-etag-cache.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Build URL Query Parameters in Python: Unicode, Plus Signs, and Repeated Keys
- Reliable File Downloads in Python: Streaming, Size Limits, and SHA-256
- Python HTTP JSON Requests: Timeouts, Bounded Retries, and Local Tests
- Python File Permissions on Linux: umask, Mode 600, and No Overwrites
- Merge Sorted Logs by Timestamp in Python with heapq.merge