陈旧响应可以在源站故障时维持公开手册的可读性,也可能保留过时价格、已撤销权限或误导性的状态。可用性并不天然高于正确性。启用这种行为之前,应先确定哪些信息允许继续使用、什么条件下可以复用,以及最多能旧多久,而不是看到一次错误响应就无条件拿旧成功结果代替。
用可用性换新鲜度
过期和淘汰是两件事。过期响应仍可留在缓存里,用于条件验证或受到许可的陈旧响应;但冷缓存或已经淘汰条目的缓存,没有旧正文可返回。陈旧指令无法凭空创造副本,保存着字节也不会自动授予复用权限。选中的响应还必须匹配当前请求,包括相关 Vary 维度和访问边界,不能拿另一个请求留下的成功内容填补故障。
如何表示
stale-while-revalidate 允许在有界窗口内返回旧响应,同时尝试不阻塞该响应的验证。stale-if-error 则允许在尝试访问源站遭遇符合条件的错误时使用旧响应。两者触发条件不同,都不强制缓存保存内容,也不能保证每种缓存实现都支持。请求到达时若已越过允许窗口,不能因为之前有过一次许可,就继续无限期使用旧内容。
对于 max-age=30, stale-if-error=60,额外六十秒从新鲜期结束后开始计算,不是从每一次失败尝试重新开始。候选响应的总年龄窗口约为九十秒。RFC 5861 指定了会形成 500、502、503、504 的错误场景。认证失败或资源不存在,不能随手重新包装成可用性故障,再用旧的成功响应掩盖真正需要客户端看到的结果。
成功的条件验证不一定重新下载正文。三零四响应可以依据验证元数据更新存储响应,同时保留原来的表示字节。应把这种结果与替换正文的二百响应、故障期间的陈旧命中分别记录;三种情况可能显示相同页面,却对应不同的新鲜度依据,不能仅靠正文是否改变判断验证是否发生。
坏掉的样子
每次重验证失败后都把 Age 清零,会把有界例外变成无限陈旧。保留旧正文却刷新存储时间戳,也会造成同类错误。失败时应保留年龄;只有真正的新响应或成功验证,才提供更新新鲜度的依据。后台验证通常由流量触发,所以一个长期无人访问的缓存,不一定会在下一次用户访问之前主动把内容更新好。
只读或幂等并不代表可以安全返回旧内容。一个 GET 也可能涉及对时效敏感的权限或价格。RFC 9111 禁止在适用的 no-cache、must-revalidate 等指令不允许时复用陈旧响应。应避免互相矛盾的策略,并测试实际部署的缓存如何处理这些限制。另外,RFC 5861 中关于旧 Warning 字段的建议,已被废弃该字段的 RFC 9111 更新,不能把它当作当前验收必需项。
观察指标中要区分陈旧响应和新鲜命中,记录总年龄、错误类别以及验证是否成功,同时避免记录秘密信息。如果每次都返回旧内容,漂亮的 HTTP 成功率可能掩盖持续故障的源站。恢复验收必须确认发生了真实替换或成功验证,而不是只看到源站进程重新启动,就宣布数据已经恢复新鲜。
快速验证
准备 Node.js 与 npm。本轮使用 Node 24.19.0 和已有的 http-cache-semantics 4.2.0 安装运行了 JavaScript。安装固定版本后,用 NODE_PATH="$stale_lab/node_modules" node 执行 JavaScript。这是使用虚构响应和受控时钟的确定性策略与正文选择实验,不发送请求,也不制造真实宕机;只有安装阶段需要联网。
stale_lab=$(mktemp -d)
npm install --prefix "$stale_lab" --ignore-scripts --no-audit --no-fund --package-lock=false http-cache-semantics@4.2.0
const assert = require("node:assert/strict");
const CachePolicy = require("http-cache-semantics");
const start = Date.parse("2026-09-07T00:00:00Z");
let clock = start;
class TestPolicy extends CachePolicy {
now() { return clock; }
}
const request = { url: "https://example.test/manual", method: "GET", headers: {} };
const policy = new TestPolicy(request, { status: 200, headers: {
"cache-control": "public, max-age=30, stale-if-error=60",
etag: '"revision-a"', age: "0",
} });
const oldBody = Buffer.from("manual revision A");
clock = start + 35000;
const decision = policy.evaluateRequest(request);
assert.equal(decision.response, undefined);
assert.equal(decision.revalidation.synchronous, true);
assert.equal(decision.revalidation.headers["if-none-match"], '"revision-a"');
console.log("age 35: validate before using stale-if-error");
for (const status of [500, 502, 503, 504]) {
const result = policy.revalidatedPolicy(request, { status, headers: { "cache-control": "no-store" } });
assert.equal(result.modified, false);
assert.equal(result.policy, policy);
assert.equal(result.policy.responseHeaders().age, "35");
const body = result.modified ? Buffer.from("origin error") : oldBody;
assert.equal(body.toString(), "manual revision A");
console.log(status, "uses old body without resetting Age");
}
const denied = policy.revalidatedPolicy(request, { status: 403, headers: { "cache-control": "no-store" } });
assert.equal(denied.modified, true);
assert.equal(denied.policy.storable(), false);
console.log("403 does not use the stale success response");
clock = start + 89000;
assert.equal(policy.revalidatedPolicy(request, { status: 503, headers: {} }).modified, false);
clock = start + 91000;
const expired = policy.revalidatedPolicy(request, { status: 503, headers: { "cache-control": "no-store" } });
assert.equal(expired.modified, true);
assert.equal(expired.policy.storable(), false);
console.log("age 89: stale allowed; age 91: error must be surfaced");
const recovered = policy.revalidatedPolicy(request, { status: 200, headers: {
"cache-control": "public, max-age=30, stale-if-error=60",
etag: '"revision-b"', age: "0",
} });
assert.equal(recovered.modified, true);
assert.equal(recovered.policy.age(), 0);
assert.equal(recovered.policy.satisfiesWithoutRevalidation(request), true);
const newBody = Buffer.from("manual revision B");
const delivered = recovered.modified ? newBody : oldBody;
assert.equal(delivered.toString(), "manual revision B");
console.log("recovery 200: replace the body and start the new policy");
年龄三十五秒时,正常决策要求同步验证;单独的 stale-if-error 不会授权立即把旧响应当作命中返回。随后四种符合条件的虚构错误都保留版本 A 和原策略,向下游返回的 Age 仍然是三十五秒,而不是被重置成零。403 分支不会复用旧成功正文,并且这条带 no-store 的错误响应不具备存储资格。
同一个旧条目在八十九秒年龄时仍可覆盖符合条件的错误,在九十一秒时则不行,重复失败没有延长窗口。之后收到带版本 B 的成功 200,会产生新鲜策略并选择新正文。实验同时检查元数据决策和调用方到底保留、替换了哪些字节,不是只确认一个布尔返回值看起来正确。
这个库不会代替调用方发请求、重试、安排后台任务或保存正文。断言只覆盖一个匹配的热缓存条目和这组无冲突策略,不覆盖网络超时处理、所有指令组合或完整 CDN。部署验收仍需沿真实链路测试冷缓存、窗口耗尽和故障恢复,并确认允许的陈旧程度没有越过该资源的正确性要求。
延伸阅读
来源
- RFC 5861:陈旧控制,扩展窗口和符合条件的错误状态,已核验 2026-09-07。
- RFC 9111:提供陈旧响应,陈旧复用限制,已核验 2026-09-07。
- http-cache-semantics 4.2.0,重验证决策和调用方负责的正文存储,已核验 2026-09-07。