用 Codex 完善任务列表读取:把 ETag、304 和缓存正文接起来

收到 304 后,应用应该使用哪一份 JSON?从这一个问题把条件读取做完整。

你能带走什么
  • 区分过期与内容改变,用条件 GET 重新验证
  • 保留完整正文,正确处理无正文 304 和 HEAD
  • 按弱比较处理 If-None-Match 的标签列表
  • 把读取缓存验收与乐观写入并发分开

1. 列表没改,为什么每次都要重新传一份 JSON?

任务列表打开后,用户切走再回来,客户端通常会再读一次。第二次读取时,列表可能没有变化。只在客户端保留旧 JSON 会漏掉更新;每次都重新传完整 JSON 又没有利用已经拿到的内容。条件读取让客户端把上次收到的验证器交还给服务端,由服务端判断这份内容还能不能继续用。

本文做一个很小的 Node 练习:GET /tasks 首次返回 200 和完整列表,再次携带 If-None-Match 时返回 304;测试程序把一个任务的 done 从 false 改成 true 后,旧标签换回新的 200,新的标签随后又得到 304。这里的任务列表只有一项,重点是验证读取结果与缓存正文的对应关系。

以下代码于 2026 年 10 月 13 日在 Linux、Node v24.19.0 上实际执行。所有 HTTP 请求只访问 127.0.0.1 的临时端口,未安装依赖、未运行 Codex 或其他模型,也没有测量浏览器、CDN 或生产网络。全文的运行结果来自所附 Node 程序。

同一个列表,四次读取的两种响应四次请求都到达本机服务端。两次 304 没有响应正文;读取器使用自己保存的完整 JSON。同一个列表,四次读取的两种响应读取器与缓存正文本机 HTTP 服务 ① 无条件 GET → 200:保存正文与 ETag A ② If-None-Match: A → 304:复用旧正文 测试夹具将 done 改为 true ③ If-None-Match: A → 200:替换为正文 B ④ If-None-Match: B → 304:复用新正文 每行双向箭头是一轮请求与响应;A / B 代指实际带引号的 ETag
四次请求都到达本机服务端。两次 304 没有响应正文;读取器使用自己保存的完整 JSON。

2. 先限定练习范围,避免写出一个自称通用的缓存

服务端只有一个确定的 JSON 表示,HTTP 接口只允许 GET 和 HEAD。客户端每个实例在构造时用 new URL(url).href 保存完整 URL 的字符串快照,只执行串行 GET;调用者后来修改传入的 URL 对象,也不会改变这个实例的请求目标。它没有按路径截断 URL 的缓存键,也没有把不同查询条件塞到同一缓存槽里;当前服务对带查询参数的 /tasks 请求直接返回 404。

这不是浏览器缓存的替代品。示例不处理 Vary、压缩内容、Range、账户隔离、离线持久化或通用的新鲜度计算。真实服务如果加入过滤参数、登录账户或语言版本,必须重新定义缓存身份,不能把本文的单一槽位直接扩展成全站缓存。带认证的个人数据还需要访问控制;private 指令本身不是登录验证。

示例使用 Node 自带的 node:http、node:crypto、node:test 和 node:assert。HTTP 客户端直接收集响应正文,因此能明确区分“本次网络收到的正文”和“应用从上一次 200 保存下来的正文”。相关 API 可查 Node 24 HTTP 官方文档;文档分支可能更新,本文实测版本仍以 v24.19.0 为准。

3. 把缓存设为立即过期,观察重新验证而不等待

200 和 304 都带 Cache-Control: private, max-age=0, must-revalidate。max-age=0 使保存的响应没有可直接复用的新鲜期;下一次读取先请求服务端验证。must-revalidate 要求过期后先成功验证再复用。示例因此不需要 sleep,也不依赖机器速度去碰一个到期边界。服务失败时,读取器会报错,不会悄悄把旧数据当作本次成功结果。

过期描述的是能否未经验证就使用,不是在宣布 JSON 已经改变。过期的响应完全可能通过验证,得到 304。一次 304 完成当前读取的验证后,max-age=0 仍然是零;第三次读取仍需要验证。它不会因为刚拿到 304,就凭空获得几十秒的新鲜期。

如果需要正数 max-age 下的无网络复用,还要处理响应年龄、Date、Age、请求时延等条件,本文没有实现这些。no-cache 也不等于禁止存储;no-store 才是不要保存该响应的指令。可对照 RFC 9111 的新鲜度计算和 响应缓存指令,不要把这些名称当成可互换的开关。

“需要验证”和“内容改变”是两个问题max-age=0 让下一次使用前必须验证。验证结果可能是内容未变的 304,也可能是包含新正文的 200。“需要验证”和“内容改变”是两个问题缓存中已有完整 JSON + ETagCache-Control: private, max-age=0, must-revalidate 再次 GET,携带保存的验证器 标签匹配:304,正文 0 字节保留原正文,更新 Date 等元数据标签不同:200,收到新正文替换正文和 ETag,供下次使用 两条分支都完成了本次验证;max-age=0 不会因此变成一段新鲜期
max-age=0 让下一次使用前必须验证。验证结果可能是内容未变的 304,也可能是包含新正文的 200。

4. ETag 是带引号的验证器,不是一个可比较大小的版本号

本例先把列表序列化成 UTF-8 Buffer,再对这份将要发送的精确字节计算 SHA-256,生成形如 "sha256-…" 的强 ETag。双引号是字段值的一部分,客户端原样保存并原样发回。标签不要求单调递增,不能拿字符串大小判断新旧;如果相同字节重新出现,内容哈希也可以回到原值。

If-None-Match 使用弱比较:请求中的 W/"abc" 可以与服务端的 "abc" 匹配。标签列表中任何一项匹配,就满足“已有可用副本”的判断。列表不能简单用逗号 split,因为合法的 opaque-tag 可以在引号内含逗号。示例解析器按带引号的标签读取,也容忍长度上限以内的空列表项;混用星号和普通标签、缺少引号等输入按本例约定返回 400。

单独的星号检查当前是否存在表示。对存在的 /tasks 发 If-None-Match: *,GET 得到 304;对不存在的路径仍应是 404。路由和方法检查先执行,不能把星号当成把任意请求改成 304 的捷径。定义与比较规则见 RFC 9110 ETag和 If-None-Match。

5. 304 更新元数据,正文仍来自先前的 200

返回 304 时,本服务发送 ETag、Date 和 Cache-Control,不发送 JSON 正文或 trailer。读取器先确认自己确实有该 URL 的完整正文,再确认本次返回的强 ETag 与保存值完全相同,最后更新这三个元数据字段,保留原正文。读取结果中的 JSON 是从保存字节重新解析得到的对象;调用者修改这个对象,不会污染下一次读取。

这份读取器特意只接受本服务的强哈希标签和固定策略。服务端能接受请求里的弱标签,并不表示客户端实现了任意弱验证器之间的通用缓存合并。没有本地正文的 304 会报错;带着另一个强 ETag 的 304 也会报错。真实产品可以另行发无条件 GET 补齐正文,但不能把 304 的空正文解析为一个空任务列表。

HEAD 的行为也要分开看:HEAD 200 没有正文,但可以给出相应 GET 正文的 Content-Length;本例为 60。HEAD 304 同样没有正文。本例的 304 省略 Content-Length,不把它设置为 0;规范允许特定条件下携带对应 200 的长度,所以不能笼统说所有 304 都禁止该字段。依据见 304、Content-Length和 重新验证后更新已存响应。

304 只能接回它验证过的那份正文读取器实例绑定一个完整 URL。没有对应正文、验证器不同或服务失败,都不能把 304 或旧数据当作成功的列表。304 只能接回它验证过的那份正文可以接回保存的正文同一读取器、同一 URL、相同强 ETag200 留下完整 JSON304 带回当前 ETag / Date / 缓存策略结果:返回原 JSON 的新解析副本 必须报错或另行获取完整响应收到 304,却没有保存过正文304 中的强 ETag 与缓存不同服务器返回 500 或缓存策略已变化结果:本例报错,不偷偷回退旧数据 真实应用加入筛选、账户或语言后,要重新设计缓存身份与隔离本例不支持查询参数、Vary、压缩变体、持久化缓存或并发读取
读取器实例绑定一个完整 URL。没有对应正文、验证器不同或服务失败,都不能把 304 或旧数据当作成功的列表。

6. 保存四个文件,在空目录运行

将后文完整的 server.mjs、client.mjs、demo.mjs、test.mjs 保存到同一个空目录,使用 UTF-8 编码。无需 package.json,也不用 npm install。先看 Node 版本,再运行演示和测试:

node --version
node demo.mjs
node --test test.mjs

server.listen(0, '127.0.0.1') 让操作系统分配临时端口,不占用固定的 3000 或 8080。演示和每个测试都在结束时关闭自身服务。客户端限制只访问 loopback HTTP,单次请求有三秒空闲超时和 64 KiB 正文上限。它们是练习的保护边界,并非面向不可信服务的完整安全方案。

演示输出如下,四行可以逐字比较。bodyBytes 是 Node 收到的 HTTP 响应正文字节数,不包含状态行、响应头或 TCP 开销。Date 使用测试注入的固定时钟,每步加一秒;程序没有修改系统时间,也没有真的等待一秒。

{"step":"first","status":200,"bodyBytes":60,"reused":false,"done":false,"date":"Tue, 13 Oct 2026 00:00:00 GMT"}
{"step":"unchanged","status":304,"bodyBytes":0,"reused":true,"done":false,"date":"Tue, 13 Oct 2026 00:00:01 GMT"}
{"step":"changed","status":200,"bodyBytes":59,"reused":false,"done":true,"date":"Tue, 13 Oct 2026 00:00:02 GMT"}
{"step":"unchanged-again","status":304,"bodyBytes":0,"reused":true,"done":true,"date":"Tue, 13 Oct 2026 00:00:03 GMT"}

60 变成 59 是 false 改为 true 后的 UTF-8 正文长度变化。两个零只说明两次 304 没有传输响应正文,不说明请求没有到达服务端,更不是“速度提升百分之多少”的测量结果。这个实现仍先构造完整表示再计算哈希,没有证明服务端 CPU、数据库查询或浏览器渲染成本下降。

7. 服务端完整源码:server.mjs

import http from 'node:http';
import { createHash } from 'node:crypto';

export const POLICY = 'private, max-age=0, must-revalidate';

// entity-tag 不是带反斜杠转义的 JSON 字符串;逗号可以在引号内。
export function parseTags(value) {
  if (typeof value !== 'string' || value.length > 8192) {
    throw new Error('invalid validator field');
  }
  if (value.replace(/^[\t ]+|[\t ]+$/g, '') === '*') return { any: true, tags: [] };
  const tags = [];
  const token = /(W\/)?"([\x21\x23-\x7e\x80-\xff]*)"/y;
  let i = 0;
  while (i < value.length) {
    // RFC 列表接收方容忍空元素;总长度已有限制。
    while (i < value.length && /[\t ,]/.test(value[i])) i++;
    if (i === value.length) break;
    token.lastIndex = i;
    const match = token.exec(value);
    if (!match) throw new Error('invalid entity-tag');
    tags.push({ weak: Boolean(match[1]), opaque: match[2] });
    i = token.lastIndex;
    while (i < value.length && /[\t ]/.test(value[i])) i++;
    if (i < value.length && value[i] !== ',') {
      throw new Error('missing comma');
    }
  }
  return { any: false, tags };
}

function matches(parsed, current, strong = false) {
  const tag = parseTags(current).tags[0];
  return parsed.any || parsed.tags.some(candidate =>
    candidate.opaque === tag.opaque &&
    (!strong || (!candidate.weak && !tag.weak)));
}

function representation(tasks) {
  const body = Buffer.from(JSON.stringify({ tasks }) + '\n', 'utf8');
  const etag = '"sha256-' + createHash('sha256').update(body).digest('hex') + '"';
  return { body, etag };
}

export function createTaskServer(tasks, { clock = () => new Date() } = {}) {
  let current = representation(tasks);
  const server = http.createServer({ rejectNonStandardBodyWrites: true }, (req, res) => {
    req.resume();
    function error(status, message, extra = {}) {
      const body = Buffer.from(message + '\n');
      res.writeHead(status, { 'Cache-Control': 'no-store',
        'Content-Type': 'text/plain; charset=utf-8',
        'Content-Length': body.length, ...extra });
      res.end(req.method === 'HEAD' ? undefined : body);
    }
    if (req.url !== '/tasks') return error(404, 'not found');
    if (!['GET', 'HEAD'].includes(req.method)) {
      return error(405, 'read-only endpoint', { Allow: 'GET, HEAD' });
    }
    // 本例无 Last-Modified 日期,日期条件不参与判断;不支持 Range。
    // If-Match 只为保持优先级正确,不提供任何 HTTP 写入操作。
    try {
      if (req.headers['if-match'] !== undefined &&
          !matches(parseTags(req.headers['if-match']), current.etag, true)) {
        return error(412, 'precondition failed');
      }
      const headers = { ETag: current.etag, 'Cache-Control': POLICY,
        Date: clock().toUTCString() };
      if (req.headers['if-none-match'] !== undefined &&
          matches(parseTags(req.headers['if-none-match']), current.etag)) {
        res.writeHead(304, headers);
        return res.end(); // 没有 JSON、正文或 trailer;也不发送 Content-Length。
      }
      res.writeHead(200, { ...headers, 'Content-Type': 'application/json; charset=utf-8',
        'Content-Length': current.body.length });
      res.end(req.method === 'HEAD' ? undefined : current.body);
    } catch {
      return error(400, 'malformed validator'); // 本实验选定的错误处理策略。
    }
  });
  return { server,
    // 仅供测试在同一进程替换合成夹具,不是对外开放的写接口。
    replaceFixture(next) { current = representation(next); }
  };
}

export async function listen(server) {
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
  });
  return `http://127.0.0.1:${server.address().port}/tasks`;
}

export async function close(server) {
  await new Promise((resolve, reject) => {
    server.close(error => error ? reject(error) : resolve());
    server.closeAllConnections();
  });
}

8. 读取器完整源码:client.mjs

import http from 'node:http';

const POLICY = 'private, max-age=0, must-revalidate';
const STRONG_TAG = /^"sha256-[0-9a-f]{64}"$/;

// 直接收集 node:http 收到的正文;这里没有浏览器缓存介入。
export function requestRaw(url, { method = 'GET', headers = {} } = {}) {
  const target = new URL(url);
  if (target.protocol !== 'http:' || target.hostname !== '127.0.0.1') {
    throw new Error('this lab only permits loopback HTTP');
  }
  return new Promise((resolve, reject) => {
    const req = http.request(target, { method, headers, agent: false }, res => {
      const chunks = [];
      let bytes = 0;
      res.on('data', chunk => {
        bytes += chunk.length;
        if (bytes > 65536) res.destroy(new Error('body exceeds lab limit'));
        else chunks.push(chunk);
      });
      res.on('error', reject);
      res.on('end', () => resolve({ status: res.statusCode,
        headers: res.headers, body: Buffer.concat(chunks) }));
    });
    req.setTimeout(3000, () => req.destroy(new Error('request timed out')));
    req.on('error', reject);
    req.end();
  });
}

// 只用于本例固定策略、单一 URL、串行 GET 的内存读取器。
// 不计算 freshness/Age,也不实现通用 HTTP 缓存。
export class RevalidatingClient {
  #entry;
  #url;
  #busy = false;
  constructor(url) { this.#url = new URL(url).href; }
  async read() {
    if (this.#busy) throw new Error('serial reads only');
    this.#busy = true;
    try {
      const sent = this.#entry?.etag;
      const wire = await requestRaw(this.#url, {
        headers: sent ? { 'If-None-Match': sent } : {}
      });
      if (wire.status !== 200 && wire.status !== 304) {
        throw new Error(`unexpected HTTP ${wire.status}`);
      }
      if (wire.headers['cache-control'] !== POLICY ||
          wire.headers.vary !== undefined || wire.headers['content-encoding'] !== undefined ||
          !STRONG_TAG.test(wire.headers.etag ?? '') ||
          !Number.isFinite(Date.parse(wire.headers.date ?? ''))) {
        throw new Error('response outside the lab contract');
      }
      if (wire.status === 200) {
        if (wire.headers['content-type'] !== 'application/json; charset=utf-8' ||
            Number(wire.headers['content-length']) !== wire.body.length) {
          throw new Error('invalid full response');
        }
        const value = JSON.parse(wire.body.toString('utf8'));
        if (!Array.isArray(value.tasks)) throw new Error('invalid task list');
        this.#entry = { body: Buffer.from(wire.body), etag: wire.headers.etag,
          date: wire.headers.date, policy: wire.headers['cache-control'] };
      } else {
        if (!this.#entry || sent !== wire.headers.etag || wire.body.length !== 0) {
          throw new Error('304 has no matching stored response');
        }
        // 本服务只回这三种缓存元数据;保留之前 200 的完整正文。
        this.#entry = { ...this.#entry, etag: wire.headers.etag,
          date: wire.headers.date, policy: wire.headers['cache-control'] };
      }
      return { status: wire.status, sent: sent ?? null, etag: this.#entry.etag,
        date: this.#entry.date, policy: this.#entry.policy,
        bodyBytes: wire.body.length, reused: wire.status === 304,
        value: JSON.parse(this.#entry.body.toString('utf8')) };
    } finally { this.#busy = false; }
  }
}

9. 四步演示完整源码:demo.mjs

import { createTaskServer, listen, close } from './server.mjs';
import { RevalidatingClient } from './client.mjs';

const first = [{ id: 1, title: '写验收清单', done: false }];
const changed = [{ id: 1, title: '写验收清单', done: true }];
// 注入固定时钟只为了可重复的报文,不更改系统时钟。
let time = Date.parse('2026-10-13T00:00:00Z');
const fixture = createTaskServer(first, { clock: () => new Date(time) });
const url = await listen(fixture.server);
try {
  const client = new RevalidatingClient(url);
  for (const name of ['first', 'unchanged', 'changed', 'unchanged-again']) {
    if (name === 'changed') fixture.replaceFixture(changed);
    const result = await client.read();
    console.log(JSON.stringify({ step: name, status: result.status,
      bodyBytes: result.bodyBytes, reused: result.reused,
      done: result.value.tasks[0].done, date: result.date }));
    time += 1000;
  }
} finally { await close(fixture.server); }

10. 独立预期与 HTTP 测试:test.mjs

测试没有从服务端的序列化函数拿预期正文,而是在测试文件里手写两份精确 JSON,独立计算预期标签。原始响应测试另写了一条 node:http 请求路径,没有复用客户端的 requestRaw。这样可以分别发现服务端错误和客户端复用错误;它仍是一组功能测试,不是完整的 HTTP 协议一致性认证。

import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { createTaskServer, listen, close, parseTags } from './server.mjs';
import { RevalidatingClient } from './client.mjs';

// 预期正文独立手写,不调用生产实现的 JSON 序列化/ETag 帮助函数。
const BODY1 = '{"tasks":[{"id":1,"title":"写验收清单","done":false}]}\n';
const BODY2 = '{"tasks":[{"id":1,"title":"写验收清单","done":true}]}\n';
const FIRST = [{ id: 1, title: '写验收清单', done: false }];
const NEXT = [{ id: 1, title: '写验收清单', done: true }];
const POLICY = 'private, max-age=0, must-revalidate';
const expectedTag = body => '"sha256-' + createHash('sha256').update(body).digest('hex') + '"';
const TAG1 = expectedTag(BODY1);
const TAG2 = expectedTag(BODY2);

// 独立测试传输,不复用 client.mjs 的 requestRaw。
function send(url, { method = 'GET', headers = {} } = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request(url, { method, headers, agent: false }, res => {
      const parts = [];
      res.on('data', part => parts.push(part));
      res.on('error', reject);
      res.on('end', () => resolve({ status: res.statusCode,
        headers: res.headers, body: Buffer.concat(parts), trailers: res.trailers }));
    });
    req.setTimeout(3000, () => req.destroy(new Error('test request timed out')));
    req.on('error', reject);
    req.end();
  });
}
async function fixture(t) {
  let now = Date.parse('2026-10-13T00:00:00Z');
  const app = createTaskServer(FIRST, { clock: () => new Date(now) });
  const url = await listen(app.server);
  t.after(() => close(app.server));
  return { ...app, url, advance() { now += 1000; } };
}
async function fake(t, handler) {
  const server = http.createServer(handler);
  const url = await listen(server);
  t.after(() => close(server));
  return url;
}
function full(res, policy = POLICY) {
  res.writeHead(200, { ETag: TAG1, 'Cache-Control': policy,
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(BODY1), Date: 'Tue, 13 Oct 2026 00:00:00 GMT' });
  res.end(BODY1);
}
function unchanged(res, tag = TAG1) {
  res.writeHead(304, { ETag: tag, 'Cache-Control': POLICY,
    Date: 'Tue, 13 Oct 2026 00:00:01 GMT' });
  res.end();
}

test('200 has exact UTF-8 body, strong quoted ETag and byte length', async t => {
  const app = await fixture(t);
  const result = await send(app.url);
  assert.equal(result.status, 200);
  assert.equal(result.body.toString(), BODY1);
  assert.equal(result.body.length, 60);
  assert.equal(result.headers['content-length'], '60');
  assert.equal(result.headers.etag, TAG1);
  assert.equal(result.headers['cache-control'], POLICY);
});
test('matching GET gives 304 with required metadata and no body/trailers', async t => {
  const app = await fixture(t);
  const result = await send(app.url, { headers: { 'If-None-Match': TAG1 } });
  assert.equal(result.status, 304);
  assert.equal(result.body.length, 0);
  assert.deepEqual(result.trailers, {});
  assert.equal(result.headers.etag, TAG1);
  assert.equal(result.headers.date, 'Tue, 13 Oct 2026 00:00:00 GMT');
  assert.equal(result.headers['cache-control'], POLICY);
  assert.equal(result.headers['content-length'], undefined);
  assert.equal(result.headers['content-type'], undefined);
});
test('weak request validator matches the server strong validator', async t => {
  const app = await fixture(t);
  const result = await send(app.url, { headers: { 'If-None-Match': 'W/' + TAG1 } });
  assert.equal(result.status, 304);
});
test('tag list handles commas inside quotes and tolerated empty elements', async t => {
  const app = await fixture(t);
  const result = await send(app.url, { headers: {
    'If-None-Match': ', "not,the,current,tag", , W/' + TAG1 + ',' } });
  assert.equal(result.status, 304);
  assert.deepEqual(parseTags('"a,b", W/"c"').tags,
    [{ weak: false, opaque: 'a,b' }, { weak: true, opaque: 'c' }]);
});
test('wildcard matches an existing resource, but never masks 404', async t => {
  const app = await fixture(t);
  assert.equal((await send(app.url, { headers: { 'If-None-Match': '*' } })).status, 304);
  assert.equal((await send(app.url.replace('/tasks', '/missing'), {
    headers: { 'If-None-Match': '*' } })).status, 404);
});
test('nonmatching, case-different and empty-list validators return full 200', async t => {
  const app = await fixture(t);
  for (const value of ['"different"', TAG1.replace('sha256', 'SHA256'), ' , ']) {
    const result = await send(app.url, { headers: { 'If-None-Match': value } });
    assert.equal(result.status, 200);
    assert.equal(result.body.toString(), BODY1);
  }
});
test('malformed tags are rejected by the documented 400 policy', async t => {
  const app = await fixture(t);
  for (const value of ['bare', 'w/"x"', '*, "x"', '"a" "b"', '"a b"']) {
    assert.equal((await send(app.url, { headers: { 'If-None-Match': value } })).status, 400);
  }
  assert.throws(() => parseTags('\u00a0*'));
});
test('HEAD 200 and HEAD 304 both have no body', async t => {
  const app = await fixture(t);
  const full = await send(app.url, { method: 'HEAD' });
  assert.equal(full.status, 200);
  assert.equal(full.body.length, 0);
  assert.equal(full.headers['content-length'], '60');
  assert.equal(full.headers.etag, TAG1);
  const checked = await send(app.url, { method: 'HEAD', headers: { 'If-None-Match': TAG1 } });
  assert.equal(checked.status, 304);
  assert.equal(checked.body.length, 0);
});
test('fixture change invalidates old tag; new tag validates new bytes', async t => {
  const app = await fixture(t);
  app.replaceFixture(NEXT);
  const changed = await send(app.url, { headers: { 'If-None-Match': TAG1 } });
  assert.equal(changed.status, 200);
  assert.equal(changed.body.toString(), BODY2);
  assert.equal(changed.headers.etag, TAG2);
  assert.notEqual(TAG1, TAG2);
  const checked = await send(app.url, { headers: { 'If-None-Match': TAG2 } });
  assert.equal(checked.status, 304);
});
test('write methods return 405 before any conditional result', async t => {
  const app = await fixture(t);
  for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
    const result = await send(app.url, { method, headers: { 'If-None-Match': '*' } });
    assert.equal(result.status, 405);
    assert.equal(result.headers.allow, 'GET, HEAD');
  }
  assert.equal((await send(app.url)).body.toString(), BODY1);
});
test('If-Match uses strong comparison and precedes If-None-Match', async t => {
  const app = await fixture(t);
  for (const value of ['"old"', 'W/' + TAG1]) {
    assert.equal((await send(app.url, { headers: {
      'If-Match': value, 'If-None-Match': TAG1 } })).status, 412);
  }
  assert.equal((await send(app.url, { headers: {
    'If-Match': TAG1, 'If-None-Match': TAG1 } })).status, 304);
});
test('If-Modified-Since does not override a nonmatching ETag', async t => {
  const app = await fixture(t);
  const result = await send(app.url, { headers: { 'If-None-Match': '"old"',
    'If-Modified-Since': 'Wed, 31 Dec 2098 00:00:00 GMT' } });
  assert.equal(result.status, 200);
});
test('reader reuses only body, updates Date, then replaces changed representation', async t => {
  const app = await fixture(t);
  const client = new RevalidatingClient(app.url);
  const first = await client.read();
  app.advance();
  const same = await client.read();
  assert.equal(first.sent, null);
  assert.equal(same.sent, TAG1);
  assert.equal(same.status, 304);
  assert.equal(same.bodyBytes, 0);
  assert.deepEqual(same.value, JSON.parse(BODY1));
  assert.notEqual(same.date, first.date);
  app.replaceFixture(NEXT);
  const changed = await client.read();
  const checked = await client.read();
  assert.deepEqual([first.status, same.status, changed.status, checked.status], [200, 304, 200, 304]);
  assert.deepEqual(changed.value, JSON.parse(BODY2));
  assert.equal(checked.sent, TAG2);
  // 调用者修改返回对象,不得污染客户端保存的正文。
  checked.value.tasks[0].title = 'caller mutation';
  assert.deepEqual((await client.read()).value, JSON.parse(BODY2));
});
test('304 without local stored body is an error', async t => {
  const url = await fake(t, (req, res) => unchanged(res));
  await assert.rejects(new RevalidatingClient(url).read(), /no matching stored response/);
});
test('304 with a different strong tag cannot validate the stored body', async t => {
  let count = 0;
  const url = await fake(t, (req, res) => ++count === 1 ? full(res) : unchanged(res, TAG2));
  const client = new RevalidatingClient(url);
  await client.read();
  await assert.rejects(client.read(), /no matching stored response/);
});
test('500 does not silently fall back to stale data', async t => {
  let count = 0;
  const url = await fake(t, (req, res) => {
    if (++count === 1) return full(res);
    res.writeHead(500, { 'Cache-Control': 'no-store' });
    res.end('broken');
  });
  const client = new RevalidatingClient(url);
  await client.read();
  await assert.rejects(client.read(), /unexpected HTTP 500/);
});
test('reader refuses an unexpected cache policy and does not cross URL identity', async t => {
  const url = await fake(t, (req, res) => full(res, 'no-store'));
  await assert.rejects(new RevalidatingClient(url).read(), /outside the lab contract/);
  const app = await fixture(t);
  const client = new RevalidatingClient(app.url);
  await client.read();
  const other = new RevalidatingClient(app.url + '?filter=done');
  await assert.rejects(other.read(), /unexpected HTTP 404/);
  // 调用者持有的 URL 对象改变后,实例仍使用构造时的完整 URL。
  const supplied = new URL(app.url);
  const bound = new RevalidatingClient(supplied);
  await bound.read();
  supplied.search = '?filter=done';
  const same = await bound.read();
  assert.equal(same.status, 304);
  assert.deepEqual(same.value, JSON.parse(BODY1));
});

11. 检查的不只是一次成功读取

17 项测试在正常环境与清理过环境变量的新进程中均通过。覆盖首次 200 的精确字节、无正文的 304、弱标签匹配、列表内逗号与空项、星号、非匹配与大小写、非法标签的 400、HEAD、内容改变后的新标签、写方法 405、If-Match 优先级、日期条件不会覆盖 ETag、读取器元数据更新和对象隔离、无本地正文、错误强标签、500 不回退,以及策略或 URL 不符。

服务端未提供 Last-Modified,因此日期条件没有可比较的修改日期;带 If-None-Match 时,If-Modified-Since 也不会改变该条件的判定。代码中的 If-Match 小分支使用强比较,并先于 If-None-Match 执行,以免组合条件绕过更早的前提。它接受的是读取请求,没有扩展成一个写接口。

独立测试中的缺正文 304、错误标签 304 和 500 来自本机的故意异常服务。正常任务服务不会产生这些响应,但客户端仍需要知道遇到它们时如何失败。检查只看“有无抛错”不够,断言也绑定到相应错误原因,避免把另一类失败误认作预期行为。

12. 与乐观写入并发分开验收

本文解决的是“已经保存的响应能否供这一次读取复用”。多人同时编辑任务时,问题会变成“写入是否基于服务端仍然认可的版本”。那通常要把写操作与 If-Match 等前提绑定,并在服务端原子地验证和执行;一次读取拿到 304,并不保证之后的写入不会覆盖别人。

本例虽然演示了 If-Match 的读取前提和强比较,但对 POST、PUT、PATCH、DELETE 一律返回 405。replaceFixture 只是演示程序持有的同进程函数,用来更换合成测试数据,不是 HTTP 修改接口,不处理数据库事务、写入竞争或权限。如果把这个练习接入实际任务应用,应为写入另做竞争测试,而不是把这里的 17 项通过当作并发安全证明。

强验证器还必须对应实际选择的表示。服务端今后若按筛选条件生成不同正文,或引入 gzip、语言版本与用户私有视图,不能只沿用一个“列表更新时间”标签而忽略具体响应。先确定表示与缓存身份,再判断需要哪些 Vary 字段、隔离策略和标签生成规则。

13. 让 Codex 按这些结果实现,而不是只加一个 ETag 头

把这个实验作为现有项目的验收参考时,可以给 Codex 一段明确的任务说明。下面是可改写的提示文本,不是本文实际运行过的模型会话:

在任务列表的只读 GET/HEAD 接口上实现 ETag 条件读取。
先找出现有路由、响应序列化位置、认证与缓存策略,不修改写接口。
ETag 对应实际返回的表示;If-None-Match 使用弱比较。
匹配时返回无正文的 304,并保留应该发送的缓存元数据。
客户端在 304 后复用已保存的完整正文,不能把空正文解析为空列表。
补测试:200 → 304 → 数据改变后 200 → 新标签 304。
另测 HEAD、标签列表、星号、无本地正文和验证失败。
如果服务有筛选、账户、语言或压缩变体,先说明缓存身份,避免串用。
列出实际测试命令、通过/失败结果,以及未覆盖的生产环境边界。

验收时再看一次请求与正文的去向:第一次是谁保存了什么;第二次发回了哪个标签;304 接回了哪份正文;改变数据后哪个标签被替换。如果这些问题都有可检查的答案,ETag 才真正参与了正确的读取流程。

14. 规范与复现边界

协议核对日期为 2026 年 10 月 13 日。本文参考 RFC 9110:HTTP Semantics、RFC 9111:HTTP Caching和 Node 24 HTTP 官方文档。图示为本实验绘制,不是浏览器面板截图。

实际验证范围为 Linux / Node v24.19.0、本机 HTTP/1.1、固定 JSON 表示、受控串行请求。没有运行真实浏览器缓存、代理、TLS、CDN、跨账户缓存、压力测试或模型评测。全部源码已嵌入本文,可逐文件保存复跑。