春江暮客

春江暮客的个人学习分享网站

用 Python 减少重复下载:ETag、304 与本地 JSON 缓存

2026-10-29 技术
用 Python 减少重复下载:ETag、304 与本地 JSON 缓存

定时脚本每小时下载同一份 JSON,即使内容没变,也会重复传输。利用 HTTP ETag,下次请求可以先询问已保存的版本是否仍有效;服务端返回 304 Not Modified 时,就不必再传输响应正文。

本文面向初学者到中级读者,用标准库编写客户端,把校验标记与解析后的数据一起保存,并在本机回环地址测试完整流程。它是HTTP 超时与有限重试教程的后续:这次解决的是条件获取,不是失败重试。

1. 方法:数据和校验标记一起保存

RFC 9110 第 13.1.2 节规定了条件 GET 使用的 If-None-Match。这里采用以下规则:

结果 动作
没有缓存 发送普通 GET。
缓存里有 ETag 原样放入 If-None-Match。
200 且 JSON 可解析 把 URL、ETag 和解析后的数据保存到同一个缓存文件。
发送校验标记后收到 304 复用保存的数据,不解析空正文。
200 但没有 ETag 保存数据,校验标记为 null;下次仍发普通 GET。
HTTP、网络或解析失败 非零退出,保留上一份缓存。

ETag 是不透明的带引号标记:双引号和可能出现的 W/ 前缀都要保留。弱校验标记可以用于这里的条件 GET,但不能证明字节完全相同。参见 ETag 定义。

每个固定的公开接口使用一个可信缓存路径,只允许一个写入进程,请求的表示形式也应保持一致。接口必须允许本地持久保存。这是应用层快照,不是通用 HTTP 缓存:没有实现 Cache-Control、Vary、与身份认证相关的存储策略或断点续传。不要用于私有响应或禁止存储的接口。

2. 准备:可重复的本地接口

需要 Python 3.10+ 和 curl,无需安装 pip 包。示例已在 Linux、Python 3.11.4 下测试。下载文件:fetch_cached.py、demo_server.py 和 check_cache.py。

  1. 在终端 A 新建练习目录,启动演示服务:
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

服务从工作目录读取 data.json,根据内容计算标记,只监听 127.0.0.1。它仅供教学,不是生产服务器。如果端口被占用,换一个 --port,并同步修改下方客户端 URL。保持这个终端运行。

  1. 保存或下载下面的客户端。在终端 B 进入同一个 etag-demo 目录,再执行第 4 节。

3. 完整客户端:显式处理 304

"""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 会通过 HTTPError暴露未被处理的 HTTP 状态;要先单独处理 304,再让其他 HTTP 失败按错误处理,并关闭响应。这个分支要求已经保存数据、也确实发送了校验标记。无条件请求意外收到 304,不能凭空生成有效缓存。

客户端最多读取 1 MiB 响应字节,要求 UTF-8 JSON,并拒绝压缩正文。保存的是解析后的数据,不是保留原始空白或数字写法的字节副本,也不检查业务字段。缓存读取上限为 8 MiB,为序列化转义预留空间。缓存损坏或 URL 不匹配时直接失败,不会悄悄换一份输入。

save_cache() 先序列化,再写入同目录临时文件,最后替换目标。失败请求或无效 JSON 因此不会覆盖上一份快照。JSON 原子写入教程解释了文件系统边界:替换不是多进程锁,也不提供完整的断电持久性保证。

4. 使用:观察 200、304 和内容变化

  1. 在终端 B 仅为回环地址绕过代理,连续请求两次:
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

预期 stdout 如下,每次运行一行:

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

第二次仍然发送 HTTP 请求,只是在 304 后复用本地数据。两种结果都以 0 退出。304 不改变缓存字节和修改时间,因此缓存 mtime 不是最近一次校验成功的时间。

  1. 修改演示输入,再获取一次:
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

这次获取输出 {"status": "updated", "data": {"count": 2}}。缓存包含 url、etag 和 data,校验标记随数据一起改变。检查文件时不要把 ETag 的双引号去掉。

  1. 根据本次运行是否成功决定后续动作,而不是只判断缓存存在:
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

退出码 1 表示获取或缓存错误,2 表示命令行参数错误。shell 重定向在失败时可能留下空的 result.json,因此只能在成功分支使用它。这个客户端刻意不做自动重试,也不会把“失败后使用旧缓存”伪装为成功。

5. 验证:测试变化与错误响应

在终端 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

自检在自动选择的回环端口启动独立服务,使用临时缓存,核对实际 JSON 与退出码,最后停止服务并删除临时目录。它不使用终端 A 的数据或缓存。断言覆盖校验标记传输、304 前后的缓存字节与 mtime、内容更新,以及失败后旧缓存是否保留。

用于定时任务时,如果还需要记录校验时间,应单独记录成功事件。产物新鲜度检查解释了为什么未改变缓存的 mtime 不能证明最近何时检查过远端资源。

6. 常见问题

现象 原因与具体处理
一直 updated 检查接口是否返回 ETag。没有标记时本来就会完整 GET,不要自行伪造远端校验标记。
304 without a cached validator 没有匹配的已存数据,这个响应不可用。检查服务端或中间代理,不要把空正文当成 JSON。
cache schema or URL mismatch 给新 URL 使用新的专用缓存文件名。如果其他任务仍使用旧文件,要保留旧文件。
缓存损坏后出现 ERROR: 先执行 python3 -m json.tool cache.json 检查;用 mv cache.json cache.saved.json 归档损坏的演示缓存,再重新获取。归档名应尚不存在。
连接被拒绝或端口错误 保持回环服务运行,并使用它打印的 URL。如果环境把本地请求也发往代理,按上文设置 no_proxy。
超时 先检查服务状态,再考虑增大 --timeout。Python 的 urlopen 文档说明这是阻塞操作超时,不是整个任务的硬性总时限。
权限错误或父目录不存在 选择已存在、可信且可写的缓存目录,不要仅为绕过错误而使用 root 运行。

结束后在终端 A 按 Ctrl+C 停止服务。真实部署应使用经过证书验证的 HTTPS;这里的回环 HTTP 仅用于本地实验。脚本不会修改调度器或服务器配置。

7. 总结

把 ETag 和它对应的数据放在一起,把 304 当作复用,而不是再次解析 JSON。即使旧快照还在,请求失败也必须保留失败状态。先跑通本地自检,再把这个小客户端接入公开数据自动化任务。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management 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 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 mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print 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 selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它