一个定时读取JSON的脚本,第一次拿到200并保存正文,第二次收到304。如果仍把“这次读到的正文”写入文件,就可能用空内容覆盖上一份有效数据。另一类故障是200已经返回,但正文被截断或不是合法JSON,脚本却提前覆盖了旧文件。
本文用真实本机HTTP请求串起完整流程:下载、条件验证、更新、坏响应处理和本地缓存损坏后的恢复。适合正在为小型数据读取脚本增加缓存的Python开发者。所有数据均为合成内容,仅访问127.0.0.1,不连接生产网站;示例故意限定接口契约,不是通用HTTP缓存库。
下载条件下载实验包:本机服务、客户端、五步演示与12项测试(ZIP)
13字节的正文,第二次不用重传
解压进入conditional-json-v1后运行:
python3 run_lab.py
python3 -m unittest -v
需要Python 3.11+,本次实测macOS与Python 3.14.4。运行器自动选择本机端口、创建临时缓存,结束后关闭服务并清理。服务最初返回{"version":1}和ETag "v1"。下面来自实际请求结果:
| 步骤 | HTTP结果 | 读取值 | 本地文件 |
|---|---|---|---|
| 首次下载 | 200,13字节 | version=1 | 创建缓存 |
| 版本未变 | 304,0正文 | version=1 | 字节完全不变 |
| 服务更新到v2 | 200,13字节 | version=2 | 完成校验后替换 |
| 服务返回坏JSON | 200,但解析失败 | 抛出错误 | 保留v2 |
| 本地缓存已损坏 | 不发条件头,重新200下载 | version=2 | 重建有效缓存 |
第二次调用发送了If-None-Match: "v1",服务据此返回304;客户端返回的是已经保存并检查过的正文,不是把空响应解析成JSON。这里统计的是响应正文的字节数,不包括HTTP头、TCP开销,也不是下载耗时测试。
只有手里真有正文,才发送验证条件
本地缓存用一个JSON文件保存四项:请求URL、ETag、正文的base64表示、正文SHA-256。读取时先核对结构、URL、ETag格式、解码结果、大小、摘要及JSON对象形状。任一项不符合,就不使用这份缓存发条件请求。
为什么URL也必须绑定?两个地址可能恰好使用同样的ETag字符串,它们并不是同一个资源。测试先缓存一个URL,再改查询参数访问,确认第二个地址没有携带前一个地址的验证值。
有效缓存存在 → GET + If-None-Match
├─ 304 → 复用已验证的本地正文
└─ 200 → 验证新正文,完成后替换缓存
无有效缓存 → 普通GET
├─ 200 → 验证并建立缓存
└─ 304 → 拒绝,不能凭空恢复正文
实验特意让服务器在没有缓存时返回304,客户端会报错,不创建文件。另一项测试只改坏缓存摘要,确认下一次请求不带If-None-Match,并重新下载。摘要用于发现本地正文与记录不一致,不能证明服务器可信,也不是数字签名。
200只决定下一步读取,不能直接放行写盘
客户端收到200后,先确认Content-Type、编码、ETag与Content-Length,再读取有上限的正文,检查实际长度并解析JSON。只有得到合法JSON对象,才进入保存步骤。这个顺序避免“响应头看起来成功”就破坏旧结果。
本包限制正文最多65536字节,要求明确Content-Length,不接受压缩和分块传输;不把这些限制说成HTTP标准对所有响应的要求。它们是为了把这次实验限定为一个容易检查的小型JSON接口。
截断测试中,服务宣称的Content-Length比实际正文长5字节。客户端读取后发现不完整并报错,旧文件字节不变。坏JSON测试则说明,即使传输长度完全正确,应用内容仍可能不可用。两类错误不能用同一句“HTTP下载成功”掩盖。
示例只要求顶层JSON对象,并拒绝非有限数值;它没有检查业务字段,也未专门拒绝重复JSON键。真实配置读取还应校验必需字段、类型、范围和版本兼容性,不能把一次JSON解析成功当作业务验收。
把正文和验证信息一起替换
如果正文文件先更新,ETag文件稍后更新,两者之间失败就可能留下不匹配的一组状态。本例把两者放进同一缓存对象,再写入目标目录中的临时文件。临时文件完成flush与fsync后,才调用os.replace替换目标。
with tempfile.NamedTemporaryFile(
dir=path.parent, prefix='.json-cache-', delete=False
) as f:
temp = Path(f.name)
f.write(serialized_cache)
f.flush()
os.fsync(f.fileno())
os.replace(temp, path)
上面只展示关键顺序,完整代码还有finally中的临时文件清理。测试在replace处注入写盘错误,验证旧缓存仍在且没有留下该轮临时文件。目录必须事先存在并可信,本例没有文件锁,不支持多个写者共享一个缓存路径。
文件内容fsync之后原子替换,并不等于已经验证所有掉电场景。本包没有fsync父目录,也没有进行断电、强杀或网络盘测试;不把一次正常替换扩大为完整持久性保证。
no-cache在这个流程里意味着每次都验证
实验服务返回Cache-Control: no-cache,客户端每次fetch都发请求,没有按“缓存还新鲜”直接跳过网络。这里可以保存正文,但复用前需要验证。304的价值是减少正文传输,而不是保证完全没有网络请求。
为了控制范围,本例只接受这个固定策略;遇到no-store、Vary或Set-Cookie就报错,不保存本次响应,也不静默使用旧值。重定向、弱ETag、HTTPS、认证、共享缓存及内容协商均不支持。它对304还要求响应ETag与已有值完全相同,这是教学协议的额外限制,不代表所有合法HTTP实现都必须这样做。
真实客户端接入公网时,需要按RFC处理适用的缓存指令、表示变体与验证响应中的元数据更新,不能简单删除这些拒绝分支就宣布兼容。尤其不能给包含账户差异的接口仅按URL共用缓存。
错误时保留文件,不等于悄悄继续用旧数据
本例遇到上游500、跳转、格式错误或写盘失败都会向调用方抛错。保留旧文件是为了避免丢失上一份有效内容,不代表这次业务调用成功,也没有自动提供“失败时使用陈旧缓存”的策略。
如果业务允许暂时使用旧数据,还需额外规定最大陈旧时间、提示方式和哪些错误可以降级。当前缓存没有保存这些策略,不应由调用方随便捕获所有异常后继续当作新数据使用。
12项测试经过真实本机HTTP,覆盖200、304、ETag变化、本地摘要损坏、无缓存304、URL变化、错误类型/策略/状态、超限与截断,以及替换失败。写盘失败通过可控注入触发;其他响应分支由本机服务实际发送。未测试浏览器、CDN、TLS、代理、并发、超时或生产站点缓存。
实验的实际成果是把请求、校验和写盘边界拆清楚:验证条件来自可用正文,成功传输还要经过内容校验,旧结果在新结果完成前保持可恢复。读者可先修改本机服务的响应,再观察客户端是否按约定拒绝;不要拿正式下载目录作为第一次故障实验的位置。
源码包SHA-256:d86f6d74b1845cb4f7889645d279ef8d7c4c32bc49762ee39ec2566ebc4cb914,MIT许可。封面为AI生成的浅色条件下载与缓存复用示意图,不是请求日志或实测截图。
协议依据与相关旧稿
资料查阅与编辑日期:2026年10月27日。作者:玖柒。








