描述客户端愿意接收哪些媒体类型的一系列 HTTP 头。

Accept 系列头让服务器协商应答

内容协商是在服务器能够实际生成的表示之间进行选择。请求偏好只是选择的输入,并不能证明响应已经满足这些偏好,客户端仍需检查收到的表示。Vary 记录影响复用的请求维度,不负责命名最后选中的格式、语言或压缩方式。把选择输入与响应结果混为一谈,会让排查从一开始就看错字段。 分开看待不同维度 请求偏好 所选表示的响应元数据 Accept Content-Type Accept-Language Content-Language Accept-Encoding 实际施加内容编码时的 Content-Encoding 这些维度相互独立,请求 JSON 不等于请求英文或 gzip。Content-Language 描述内容所面向受众的语言,不保证每个字符都属于某一种语言。如果服务器只有英文 HTML 和中文 JSON,不能把分别算出的“最优格式”和“最优语言”拼成根本不存在的中文 HTML。应先列清实际可用的完整组合,再定义选择规则;偏好不能凭空增加服务端的生成能力。 质量值范围是零到一,省略时为一,最多允许三位小数。零表示不可接受,不是“稍微不喜欢”。对媒体类型,一个候选的质量值由匹配它的最具体范围决定。显式的 text/html;q=0 不会被更宽泛的 */*;q=1 解禁。如果只是找整串字段里最大的数字,或者拿逗号前第一项,就会丢掉“哪个范围约束哪个候选”的关系,得到看似按权重处理、实际上违反排除条件的结果。 坏掉的样子 缺失某个偏好字段通常表示在该维度上没有偏好,并不等同于显式拒绝。RFC 9110 允许服务器在没有可接受表示时返回 406,也允许忽略该字段,因此默认响应可能仍然无法被客户端使用。承诺严格协商的 API 必须自己明确这项策略。客户端可以表达排除条件,却不能仅靠发出这个字段,强迫所有现网服务器都执行严格拒绝逻辑。 各类字段还存在不同的匹配语义。Accept 使用媒体范围,语言协商需要约定语言标签匹配方式,Accept-Encoding 则有 identity、字段缺失和空值的专门规则。通用列表解析器不会自动补齐所有业务策略。特别是不能把缺失字段悄悄变成一个空集合,再推导出“客户端拒绝一切”。应用选择默认值与解析器返回没有匹配项,是两个需要明确区分的事实。 相同权重也没有一条跨所有服务器通用的顺序规则。应用应让并列时的选择可预测,并实际测试。完整的语言偏好还可能帮助识别用户,因此记录全部偏好档案、把所有可能字段都放进 Vary,并非天然合理。Vary 只处理匹配问题,缓存资格、新鲜度和授权边界仍需各自的规则,不能因为增加了这个字段就认定内容可以共享。 快速验证 准备 Python 3.11 或更新版本及 POSIX shell,在临时环境安装本次验证的解析器,再用 "$accept_lab/venv/bin/python" 运行 Python 代码。安装需要外网,实验本身不发请求、不启动服务器。它验证的是 Werkzeug 3.1.8 的候选选择行为,不是假装复现任意浏览器或 CDN 的完整实现。 ...

十一月 23, 2027 · 2 分钟 · XB Field Notes
一种二进制序列化格式,其固定编号字段允许 schema 向前向后演进。

Protobuf 让 Schema 演进而不破坏编码

向后兼容来自两点:字段编号绝不重用;新增字段保持可选,未知字段被跳过而不是报致命错误。 编号就锁定了含义 protobuf 字段在被分配编号的那一刻,就把这个编号当作线上身份的契约;名字反而可以被随意改,wire 类型不能动。tag 号到名字的映射要小心看护,绝不要在同一号上换个含义——否则,还拿着旧定义的老进程,在看到同一编号时就会把它静默当成别的含义去解读。 .proto 里声明顺序只是写给人的便利,真正在线路上传输的是一串 (字段编号、wire 类型、值) 的三元组。只要两边的编号一致,就能安全地跳过自己看不懂的字段。这就是 protobuf 向前兼容的来源:在字段真正出现之前,先用 reserved 把一个位子占住,等于预先销出了一个稳定命名空间。 新旧双方共用一条线路 要做到前后向兼容,靠的是一组朴素的规则。向后兼容指新版程序能读旧数据写入的字节:所以旧字段要一直在,不需要时也要 reserved 起来。向前兼容指旧版本程序能容忍新 schema 写出来的数据:因为未知字段会被保留并原样写回。加一个新号字段这两个方向都兼容,改已有字段的 wire type 则不兼容。 真正的坑往往不在字段本身,而在编号的复用。删除一个编号然后换个含义,会让所有还带着旧定义、拿着旧库的老客户端读到全错的东西。所以习惯是:用 reserved 占位,不删除再复用;同一个号永远别改义;基本标量类型保持稳定;至于取值非法这类问题,要在数据进入系统的边界处用校验挡住。 未知字段跳过而非失败 让演进显得容易的特性,其实藏在编码里。未知字段会被保存下来、原样透传,于是某个不完全了解新 schema 的中间进程(网关、日志管道),也可以照常帮忙转发,而不会把新字段弄丢。这也意味着当 schema 变化时,你不是非要立刻把整条链路都升级。 要让这些好碰上,还得在同一位面持有纪律:全团队共用同一套 .proto 生成码、维护一个 schema 注册表、把最老与最新的客户端放一起做交叉版本测试、把字段编号列成 lint 让人看得见,以及一旦类型必须改,就用「新字段 + 废弃旧字段」来处理;这样新版旧版并行地主保持可读,而不是静默地把兼容性留给下一次部署去赌。 ...

十一月 19, 2027 · 2 分钟 · XB Field Notes
一份保存下来的客户端意图,在多次投递尝试中携带同一个键。

幂等键是客户端的承诺

幂等键命名的是一次预期行动,不是一次网络投递尝试。服务支持这项契约时,客户端在结果不明确后重试同一个行动,应该继续发送同一个键。每次重试都生成新键,实际是在告诉服务器“这是另一次行动”,因此完全可能合理地产生第二份业务效果。客户端必须先把行动身份与尝试次数分开。 创建一次,在后续尝试中保留 在第一次发送之前生成键,并把它与操作参数、目标一起保存。如果后台任务可能在客户端重启后恢复,任务记录也需要保留这个身份。仅把键放在临时网络连接或一次调用栈中,无法可靠地把后续尝试关联回原来的意图,恢复程序也不应临时猜一个替代值。 真正的新行动应该使用新键,即使参数与之前的购买完全一样。仅用正文哈希无法区分“再买一次同样的东西”和“重试第一次购买”。键与请求指纹承担不同职责:键标记行动,指纹检查是否发生了不兼容的复用。把二者合并,既可能漏去重,也可能错误阻止合理的新操作。 键的格式和作用域应遵循服务提供方的契约。Stripe 建议使用 UUIDv4 等随机标识,并提醒不要把敏感数据放进键中。键不是认证凭据,拥有某个键也不能代替访问已存结果所需的授权。不要把 bearer 凭据、邮箱或个人信息塞进用于关联请求的标识。 接收方必须真正实现这份承诺 只加一个 Idempotency-Key 请求头,不会自动实现去重。服务端必须在明确契约下识别它,说明比较哪些参数、保留哪些结果、执行尚未完成时如何处理重复请求,以及记录会保存多久。这些都是服务行为,不能从客户端看见一个字段名就推断已经具备。 Stripe 记录的是首次进入执行阶段的请求状态码与正文,包括 500 响应。如果参数验证失败,或与正在执行的请求发生冲突,而端点执行尚未开始,则不会保存对应幂等结果。因此,“重复请求必须等待第一份结果”并不是所有 API 都必须采用的通用规则,调用方要处理实际的在途冲突语义。 不同提供方也不保证逐字重放响应。EC2 文档说明,成功重试不会再次执行行动,但返回结果可以包含更新后的创建状态等信息。RFC 9110 定义的是预期效果;具体服务是否复用原始响应字节,是叠加在其上的另一层契约,不能把两者当成同一要求。 让键的含义保持稳定 同一个键被配上不兼容的参数时,应按照服务明确的错误规则处理,而不是静默创建另一项操作,或覆盖原先保存的结果。JSON 字段顺序、默认值、数字表示和省略字段,都可能影响比较。一个小例子能规范化某种输入,不代表已经解决任意正文的语义等价问题。 下面的实验只接受一个商品字符串和一个正整数量。将这两个字段排序,定义了它自己的比较规则,并不是通用 JSON 等价算法。示例用 409 表示参数冲突,也是测试接口的政策,不是每一家幂等 API 都保证返回的状态码。 坏掉的样子 超时不能证明操作没有执行。使用新键重试,可能把一次已经成功但丢了响应的购买变成两次。为了绕过已保存的错误而换键,也可能在原行动结果仍不明时额外启动工作。应该按照服务文档给出的恢复路径核对原行动,而不是用新身份掩盖不确定性。 保留期同样限制承诺范围。Stripe 允许在键至少保存二十四小时后清理,并将清理后的同键请求视为新请求;这既不是永久保存身份,也不是适用于所有服务的精确过期定时器。客户端的恢复窗口必须与实际保留契约一致,不能只考虑立即发生的重试。 快速验证 保存为 intent_check.py,用 Python 3.10 以上版本执行 python3 intent_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码在临时目录中写入一份行动记录,再读回,退出时清理目录。API 与响应丢失都是明确的顺序模拟,不会发送网络请求,也不会连接真实支付服务。 ...

一月 20, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调选对方法并保留请求意图。

HTTP 方法在描述意图

URL 指认目标资源,请求方法描述客户端希望源站执行的操作。服务器、缓存、代理、爬虫和重试客户端都会读取这个动词,因此方法不是路径上的装饰,而是关于预期效果的一份承诺。只有当承诺诚实时,外围设施才能做出安全的缓存、预取、重试和监控决定。同一个 URL 配上不同方法,对下游来说就是不同请求。 安全与幂等不是一回事 安全方法的定义是“本质上只读”:GET 和 HEAD 都不应要求源站改变目标资源状态。服务器仍可能写访问日志、计费或更新观测计数,但这些附带动作不改变方法的安全语义。幂等则表示,把完全相同的请求发送多次,服务器的预期效果与发送一次相同。PUT 和 DELETE 在协议上具有幂等意图,可是第一次请求可能已经到达而响应在途中丢失,客户端仍需结合版本、条件头和业务结果判断是否重试。 POST 通常表示创建从属资源或执行一次动作;它不会因为当前处理器恰好没有重复副作用,就自动变成幂等。PATCH 表示部分修改,也不天然保证重放安全。OPTIONS 用来询问通信选项,TRACE 有专门的诊断用途。具体资源允许哪些方法由源站决定;已知但不支持的方法应返回 405,并用 Allow 说明可用集合。未知方法则可能得到 501,不能把两个状态码混成一个“失败”。 让动词帮助基础设施判断 GET 与 HEAD 是否可缓存,要结合响应的缓存规则,但它们可被预取、重复读取,正是因为请求意图是只读。若把登出、购买或删除藏在 GET 地址后面,浏览器预取、链接扫描器、爬虫或缓存都可能触发不该发生的动作。重试库可以优先考虑幂等请求,却仍需检查认证、期限、请求体能否重放、条件版本和服务端是否已经提交。幂等不是分布式事务,也不保证客户端永远只看见一份响应。 PUT 用请求表示替换指定目标的状态,重复发送相同字节会趋向同一个预期状态;DELETE 让目标不存在,第一次可能返回 204,后续可能返回 404。若服务端要选择新 ID 并创建对象,用 POST 加幂等键或单独的占位记录更准确,不能仅为了方便重试就假装它是 PUT。API 文档应同时写清方法、状态码、前置条件、版本冲突和重试策略,不能只写一个“请求成功”。 幂等性只约束请求的目标效果,不约束响应文本、日志行数或计费记录。客户端在超时后可以重发 PUT,是因为即使第一条已经到达,目标状态仍应与重发一次相同;但它仍需保存同样的正文和 If-Match 等前置条件。服务端若用时间戳、随机值或未声明的附带动作改变替换结果,就破坏了调用方依赖的幂等契约。把这些附带动作写进文档,才能让重试者知道哪些状态需要另行查询。 ...

十月 24, 2026 · 3 分钟 · XB Field Notes
带作用域的操作键,在一个事务中关联业务效果与保留下来的结果记录。

幂等键归一到一次逻辑行动

一个幂等键只能在服务定义的作用域与保留窗口内标记一次逻辑行动。重复同一段字符串,不是放之四海皆准的“恰好执行一次”保证。服务需要把身份关联到正确的主体与操作,协调竞争中的请求,并保存足够权威的信息,以便稍后认出已经处理的行动。键只是入口,真实保证取决于这条处理链。 作用域是身份的一部分 如果全局只按客户端提交的键查找,就可能把不同租户的行动合并,甚至把别人的已存结果返回给当前调用方。查找或重放之前仍须完成认证与授权。应把可信作用域、稳定的操作标识、客户端行动键一起作为查找身份,并保留各组件边界,避免直接拼接字符串产生歧义。 究竟哪些维度属于作用域,要由 API 契约决定。EC2 明确区分地域级与可用区级幂等;按其文档规则,相同客户端令牌在不同地域或可用区可能允许执行不同的行动。因此,把重试切换到另一个地域,并不自动保留全局去重保证,不能只检查令牌文本是否一致。 请求含义也需要约束。同一个作用域内,原键配上不同参数时,必须按照已经声明的冲突规则处理。下面的实验只对一个正整数量生成比较表示,不比较任意 HTTP 正文,不执行认证,也不认为对正文取个哈希,就能自动推断业务语义是否相同。 让业务效果与结果记录一起提交 先查记录、再独立写入,会出现竞争:两个工作进程都没看见旧记录,于是都执行了业务。如果两份业务效果已经发生,之后只有一个结果记录插入成功,那么结果表上的唯一键本身并不能消除之前的重复效果。需要保护的是业务修改与记忆结果之间的完整边界。 两者都位于同一个事务数据库时,可以放在一个事务中提交。示例先执行 SQLite BEGIN IMMEDIATE,再查询记录,并在同一事务里写入业务行与结果记录。SQLite 同时只允许一个写事务,因此竞争连接会被串行处理;复合主键也明确表达了去重所依赖的身份组成。 事务保护止于这个数据库。外部支付、邮件发送或消息投递,不会因为本地事务失败就自动撤销;这些效果需要自己的端到端投递与去重契约。把整个流程命名为“恰好一次”,并不能凭空补出跨越多个系统的原子性,评审时必须沿真实副作用检查边界。 保留期也是正确性边界 如果旧请求仍可能回来,而记住它的键已经过期,那次请求就可能重新执行。Stripe 文档明确说明,记录清理后的同键请求会成为新请求;其至少二十四小时的保留说明属于该提供方,并不是所有服务通用的计时器,更不能解释成永久去重。 恢复窗口应与保留期一致,还要区分临时去重记录和长期业务唯一性规则。删除结果记录,不会撤销原来的订单。如果业务禁止这笔订单在任何时候被重新创建,就需要一个比短期重试缓存更持久的权威约束,不能把希望都放在键还没过期这件事上。 恢复较旧的记录数据库、清空状态或切换到独立存储,也可能忘记已经完成的行动。恢复方案应保留业务数据与操作记录之间的关系,不能只证明各自的文件还能读取,却没有验证它们是否对应同一批已提交事实。 坏掉的样子 提交成功后丢失响应,会让客户端不知道结果,但不会自动删除已存记录。提交前事务失败,则应同时不留下业务效果与成功记录。这是两种不同情况,需要不同证据,不能把“客户端看到了错误”直接等同于“服务器没有执行”。 数据库竞争、存储不可用或未知结果,也不能被当成“查不到旧记录”。看到操作仍在执行,不应自动放行第二份业务效果。需要明确重复请求是等待、得到执行中响应,还是走另一条已有文档的恢复路径;成功结果重放只是可能状态之一,不覆盖所有中间阶段。 快速验证 保存为 scope_check.py,用 Python 3.10 以上版本执行 python3 scope_check.py。实验已在 Python 3.14.4、SQLite 3.53.0 上运行。标准库代码在私有临时目录创建数据库,结束后清理;每次尝试都打开自己的连接。租户与操作参数代表已经完成身份验证和路由解析的测试输入,不是客户端请求头的替代品。 from concurrent.futures import ThreadPoolExecutor from contextlib import closing import json from pathlib import Path import sqlite3 from tempfile import TemporaryDirectory from threading import Barrier with TemporaryDirectory(prefix="idempotency-scope-") as directory: database = Path(directory) / "orders.sqlite" with closing(sqlite3.connect(database)) as db: db.executescript(""" CREATE TABLE effects ( id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, operation TEXT NOT NULL, quantity INTEGER NOT NULL CHECK (quantity > 0) ); CREATE TABLE receipts ( tenant TEXT NOT NULL, operation TEXT NOT NULL, request_key TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (tenant, operation, request_key) ); """) def execute(tenant, operation, key, quantity, now, fail_before_receipt=False): # Tenant/operation are already authenticated/routed fixture identifiers. assert type(quantity) is int and quantity > 0 fingerprint = json.dumps({"quantity": quantity}, separators=(",", ":")) scope = (tenant, operation, key) with closing(sqlite3.connect(database, timeout=5)) as db: with db: db.execute("BEGIN IMMEDIATE") previous = db.execute( "SELECT fingerprint, result, expires_at FROM receipts " "WHERE tenant=? AND operation=? AND request_key=?", scope, ).fetchone() if previous is not None and previous[2] > now: if previous[0] != fingerprint: return "conflict", None return "replay", json.loads(previous[1]) if previous is not None: db.execute("DELETE FROM receipts WHERE tenant=? AND operation=? AND request_key=?", scope) cursor = db.execute( "INSERT INTO effects (tenant, operation, quantity) VALUES (?, ?, ?)", (tenant, operation, quantity), ) result = {"effect_id": cursor.lastrowid, "quantity": quantity} if fail_before_receipt: raise RuntimeError("injected failure after effect insertion, before receipt insertion") db.execute("INSERT INTO receipts VALUES (?, ?, ?, ?, ?, ?)", (*scope, fingerprint, json.dumps(result), now + 60)) return "created", result def counts(): with closing(sqlite3.connect(database)) as db: return db.execute("SELECT (SELECT count(*) FROM effects), (SELECT count(*) FROM receipts)").fetchone() gate = Barrier(8) def attempt(_): gate.wait(timeout=5) return execute("north", "web-order", "intent-a", 2, 0) with ThreadPoolExecutor(max_workers=8) as pool: outcomes = list(pool.map(attempt, range(8))) assert [kind for kind, _ in outcomes].count("created") == 1 assert [kind for kind, _ in outcomes].count("replay") == 7 original = outcomes[0][1] assert all(result == original for _, result in outcomes) and counts() == (1, 1) print("eight independent connections produce one effect, one receipt, and seven replays") assert execute("north", "web-order", "intent-a", 3, 1) == ("conflict", None) assert counts() == (1, 1) assert execute("south", "web-order", "intent-a", 2, 1)[0] == "created" assert execute("north", "batch-order", "intent-a", 2, 1)[0] == "created" assert execute("north", "web-order", "intent-b", 2, 1)[0] == "created" assert counts() == (4, 4) print("changed parameters conflict; a different tenant, operation, or intent key has separate scope") try: execute("north", "web-order", "rolled-back", 1, 2, fail_before_receipt=True) except RuntimeError as error: assert str(error).startswith("injected failure") else: raise AssertionError("expected the transaction to fail") assert counts() == (4, 4) assert execute("north", "web-order", "rolled-back", 1, 2)[0] == "created" assert counts() == (5, 5) print("a raised exception rolls back the effect and receipt together; retry then creates one effect") _, committed = execute("north", "web-order", "committed-no-reply", 1, 3) # Discard the reply, then query through a fresh connection; no network loss is simulated here. assert execute("north", "web-order", "committed-no-reply", 1, 3) == ("replay", committed) assert counts() == (6, 6) print("a fresh connection replays a committed result without adding another effect") assert execute("north", "web-order", "intent-a", 2, 59) == ("replay", original) kind, after_expiry = execute("north", "web-order", "intent-a", 2, 60) assert kind == "created" and after_expiry["effect_id"] != original["effect_id"] assert counts() == (7, 6) print("at the explicit 60-second retention boundary, the same key creates a new effect") print("SQLite", sqlite3.sqlite_version, "verified counts: 7 effects, 6 retained receipts") 八条竞争连接最终产生一条业务记录、一条结果记录和七次重放,所有返回结果一致。改变数量会冲突;改变租户、操作或行动键,则按已经声明的作用域创建独立工作,使计数达到四条业务记录与四条结果记录。 ...

十月 12, 2026 · 3 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 HTTP 方法效果的对比。

HTTP 方法表达的是必须实现的契约

HTTP 方法描述客户端希望对目标资源执行的动作,客户端和中间层会据此判断抓取、缓存或重试方式。选择一个常见动词只是开始,应用还必须实现这个动词承诺的行为。把破坏性操作命名成 GET,并不会让它自动安全。可用的接口说明还需要交代资源模型、表示格式、授权和错误条件,不能认为方法名称已经替代了所有业务文档。 分清安全性与幂等性 安全方法具有基本只读的请求语义。GET 取得表示,HEAD 获取相应响应的元数据,却不携带响应内容。访问日志等附带效果,不会让一次读取在协议意义上变成不安全操作。但因为爬虫跟随 GET 链接就扣费,是另一回事:这个请求要求的业务动作本身改变状态,违反了安全方法的预期。判断时要看请求的预期目的,而不是要求服务器任何计数器都绝对不能变化。 幂等性关心同一个请求重复执行后的预期服务器效果。PUT 根据提交的表示替换目标资源状态;DELETE 移除目标与当前功能之间的关联。重复执行它们,预期效果应与一次执行相同,返回码、日志条数和耗时则可能不同。幂等性不能让请求自动获得权限,也不能保证不会受到其他并发写入影响。协议性质不是对任意业务实现的免费保护。 POST 请求目标资源按自身语义处理提交内容,创建资源只是常见用法,并不是全部定义。POST 通常不具备幂等保证,但应用可以另行提供明确的去重契约。PATCH 表达局部修改,具体性质取决于补丁格式和操作内容;重复“设成某个值”与重复“再增加一次”并不相同。不能看到方法名就认定所有局部修改都可以无条件重放。 坏掉的样子 重试组件如果把任何超时都当成可以重复 POST 的理由,就可能重复创建事件或扣款。即使使用幂等方法,也要理解并发与资源当前状态;条件请求可以防止覆盖已经被别人改过的表示。方法语义帮助定义动作,不会替代业务前置条件或事务规则。一次响应丢失,并不自动说明服务器没有执行原操作,重试判断必须考虑这个不确定窗口。 GET 也不代表所有缓存都可以自动重用任意响应,状态、保鲜指令、认证与表示选择仍然影响复用。GET 和 HEAD 的请求内容没有通用定义的语义,有些实现或中间层会拒绝它。不能因为本地处理器接受了读取请求中的正文,就认定这种查询方式在所有代理和客户端之间都能互通。设计公共接口时,应核对真实调用链的处理能力。 对于服务器认识、但目标不允许的方法,可以返回四零五,并用 Allow 指明支持的方法。这个能力列表不是授权凭据,也不同于 CORS 许可响应头。OPTIONS 可以说明通信选项,却不会因此让另一个浏览器来源自动有权读取响应。方法支持、账号授权和浏览器跨源限制,各自解决不同层面的问题,不能用同一个字段替代它们。 快速验证 将脚本保存为 methods.py 后执行 python3 methods.py。它使用标准库启动临时回环服务,提供一个条目资源和一个创建事件的端点。请求通过真实 HTTP 连接依次发送,断言同时检查响应与最终应用状态。这是一个小范围的方法契约实验,不是通用生产接口,没有接入外部账号或数据库,也没有修改现有站点。 from http.client import HTTPConnection from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread import json state = {'item': None, 'events': []} allowed = 'GET, HEAD, PUT, DELETE, OPTIONS' class Handler(BaseHTTPRequestHandler): def reply(self, status, data=b'', extra=None, head=False): self.send_response(status) if status != 204: self.send_header('Content-Length', str(len(data))) for key, value in (extra or {}).items(): self.send_header(key, value) self.end_headers() if not head and status != 204: self.wfile.write(data) def do_GET(self): if self.path == '/item' and state['item'] is not None: self.reply(200, state['item']) else: self.reply(404, b'missing') def do_HEAD(self): data = state['item'] if self.path == '/item' else None self.reply(200 if data is not None else 404, data if data is not None else b'missing', head=True) def do_PUT(self): if self.path != '/item': self.reply(404, b'missing') return existed = state['item'] is not None state['item'] = self.rfile.read(int(self.headers['Content-Length'])) self.reply(204 if existed else 201, extra={'Location': '/item'} if not existed else None) def do_POST(self): if self.path != '/events': self.reply(405, extra={'Allow': allowed}) return state['events'].append(self.rfile.read(int(self.headers['Content-Length']))) self.reply(201, extra={'Location': f'/events/{len(state["events"])}'}) def do_DELETE(self): existed = self.path == '/item' and state['item'] is not None if existed: state['item'] = None self.reply(204 if existed else 404) def do_OPTIONS(self): self.reply(204, extra={'Allow': allowed}) if self.path == '/item' else self.reply(404) def log_message(self, *args): pass server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) thread = Thread(target=server.serve_forever) thread.start() try: observed = [] def request(method, path, body=None): connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5) try: connection.request(method, path, body) response = connection.getresponse() item = {'method': method, 'status': response.status, 'body': response.read().decode(), 'length': response.getheader('Content-Length'), 'location': response.getheader('Location'), 'allow': response.getheader('Allow')} observed.append(item) return item finally: connection.close() assert request('PUT', '/item', b'first')['status'] == 201 assert request('PUT', '/item', b'first')['status'] == 204 assert state['item'] == b'first' assert request('GET', '/item')['body'] == 'first' head = request('HEAD', '/item') assert head['body'] == '' and head['length'] == '5' assert request('POST', '/events', b'same')['location'] == '/events/1' assert request('POST', '/events', b'same')['location'] == '/events/2' assert len(state['events']) == 2 assert request('DELETE', '/item')['status'] == 204 assert request('DELETE', '/item')['status'] == 404 and state['item'] is None assert request('OPTIONS', '/item')['allow'] == allowed assert request('POST', '/item', b'wrong')['status'] == 405 print(json.dumps({'responses': observed, 'final_item': state['item'], 'event_count': len(state['events'])})) finally: server.shutdown() thread.join() server.server_close() 第一次 PUT 创建条目,返回二零一;重复完全相同的 PUT,返回二零四,但保存的值仍然是 first。GET 返回这五个字节,HEAD 没有响应内容,却报告 Content-Length 为五。该字段描述的是对应 GET 表示的长度,不能把它误解为 HEAD 响应实际携带了五字节正文。结果和消息语义需要一起阅读。 ...

十月 8, 2026 · 2 分钟 · XB Field Notes
重复请求维持相同的预期效果,但状态码与诊断记录可以不同。

HTTP 幂等让重试变安全

HTTP 幂等关注的是重复同一个请求时,服务器上预期发生的效果,而不是每次响应是否一模一样。服务器可能已经执行修改,却在客户端收到答案之前断开连接;这时重复请求可能合理,但前提是操作的真实语义允许重复,并且客户端仍遵守有界重试政策。不能把“可以重复”扩大成所有失败都应该立即重试。 相同效果,不代表相同响应 RFC 9110 用多次相同请求与单次请求的预期服务端效果来定义幂等。PUT、DELETE 和安全方法都属于幂等方法。第一次成功 PUT 可能创建资源并返回 201,再次发送则可能替换已有资源并返回 204;先删除存在的资源,再删除已经不存在的资源,也可以返回不同状态码,但最终都让目标保持不存在。 “安全”是另一种性质:客户端没有请求改变状态。GET、HEAD、OPTIONS、TRACE 被定义为安全方法,但允许附带访问日志等行为,因此不是说服务器任何地方都绝对不能写入。PUT 和 DELETE 可以符合幂等语义,却不是安全方法;把两个概念混用,会影响自动抓取与重试策略。 方法名称需要实现来兑现 把一个每次递增的接口命名为 PUT,不会让重复递增自动变成幂等。服务器需要真正兑现公开的方法语义。同样,用 GET 删除指定对象违反安全方法的约定,预取器或爬虫可能在没有删除意图时触发它。不能因为请求行上写着某个词,就跳过对业务状态变化的检查。 POST 通常不保证幂等,但某个具体 POST 操作可以通过应用层契约去重;它的用途也不局限于创建资源。自动重放之前需要理解实际契约,不能断言所有 POST 都不能重试,也不能反过来认定所有使用幂等方法名称的接口都已经正确实现。 天然可重复的写入与记住操作键,解决的是相关但不同的问题。用指定表示替换资源,是表达希望得到的状态;凭借幂等键重放订单创建,则是在识别某次特定行动。两者都不意味着每一个内部函数、访问日志或辅助步骤只会执行一次。 前置条件处理的是竞争修改 幂等本身不能阻止覆盖另一个客户端在两次请求之间写入的新内容。重复无条件替换,可能把自己的旧值重新盖到别人的新值之上。通过强实体标签与 If-Match,可以要求目标仍然是之前观察到的表示,才允许执行这次修改,而不是只比较方法名称。 条件重试也可能在原修改已经成功后失败:第一次写入完成却丢了响应,原先的标签已经不匹配。实验在这种情况下返回 412。RFC 9110 也允许服务器在判断所要求的修改已经应用时返回成功,因此不能把实验的一个选择当成所有服务必须采用的行为。 遇到结果不明确,应重新核对当前资源,或查询相应操作记录。一个状态码不足以还原多个写入者的全部先后历史;读到期望值,也不自动证明它只可能由自己那次请求产生。 坏掉的样子 盲目重试会放大负载、耗尽截止期限、重复不受保护的计费副作用,或覆盖中间写入。需要限制尝试次数和总耗时,保留原来的目标与请求意图,并遵守适用的服务端等待提示。幂等既不是并发控制方案,也不是无限重试的许可。 还应区分预期资源修改与附带日志、版本历史。出现两条诊断记录,本身不能证明业务效果发生了两次;反过来,两份看起来相同的成功响应,也不能证明只执行了一次业务行动。判断重复效果,需要读取有权威性的业务状态,而不是仅比对响应文本。 快速验证 保存为 methods_check.py,用 Python 3.10 以上版本执行 python3 methods_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码会向自己创建的回环 HTTP 服务发送真实请求,端口由系统分配,完成后停止服务;所有业务状态都留在测试进程内存中,不访问线上系统。 ...

八月 26, 2026 · 3 分钟 · XB Field Notes
时间戳与唯一编号共同确定动态信息流的下一页边界。

游标分页从明确边界继续读取

键集分页从客户端上一页实际收到的最后一个排序键继续。它不再从当前结果开头数过多少行,而是把候选行与一个具体边界比较。因此,信息流顶部新插入一条记录时,后面的页不会仅因位置整体后移就跟着错开一行。但这种方法不会冻结整个数据集,也不承诺后续每次请求都读取同一份记录。 坏掉的样子 单独使用时间戳,通常无法给出唯一边界。即使时间精度很细,多行也可能拥有相同创建时间。严格比较会跳过尚未返回的并列记录,包含等号的比较又可能重复已经展示的行。正确做法是增加一个唯一的决胜字段,并在排序与继续查询的谓词中始终使用完整键,不能一边按两个字段排序,另一边只保存其中一个。 排序字段可修改时,会产生另一类问题。已经展示过的行可能移到游标后面,于是再次出现;尚未展示的行可能移到游标前面,于是后续翻页一直看不到它。删除记录、改变过滤条件的匹配状态,也会改变下一次请求能够返回的内容。比较边界保持不变,并不能让持续变化的数据具有不可变快照的性质。 偏移分页也有明确用途:它表达当前查询结果中的编号位置,适合需要页码的界面。但在下一次偏移位置之前插入记录,可能造成重复;删除记录则可能造成遗漏。无论哪一种分页,只因为接口返回字段叫作“一页”,都不会自动获得跨请求的一致快照。需要先把产品希望保证的事情说清楚。 快速验证 连接一个可丢弃的 PostgreSQL 数据库,在同一个 psql 会话里执行下列代码。确认实验模式 article_cursor 没被使用,并保持自动提交。记录中的运行版本为 PostgreSQL 18.6。实验特意让每个时间戳对应两行,先保存实际返回的第一页,再向顶部插入新行,最后修改两个已有排序键: CREATE SCHEMA article_cursor; SET search_path=article_cursor,public; SET TIME ZONE 'UTC'; CREATE TABLE feed (id bigint PRIMARY KEY,published_at timestamptz NOT NULL); INSERT INTO feed VALUES (1,'2026-01-01 10:00:00+00'),(2,'2026-01-01 10:00:00+00'), (3,'2026-01-01 11:00:00+00'),(4,'2026-01-01 11:00:00+00'), (5,'2026-01-01 12:00:00+00'),(6,'2026-01-01 12:00:00+00'); CREATE INDEX feed_order ON feed (published_at DESC,id DESC); CREATE TEMP TABLE first_page AS SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3; SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM first_page; SELECT published_at AS cursor_time,id AS cursor_id FROM first_page ORDER BY published_at,id LIMIT 1 \gset SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM feed WHERE published_at<:'cursor_time'::timestamptz; PREPARE after_cursor(timestamptz,bigint) AS SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed WHERE (published_at,id)<($1,$2) ORDER BY published_at DESC,id DESC LIMIT 3 ) AS page; INSERT INTO feed VALUES (7,'2026-01-01 13:00:00+00'); SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3 OFFSET 3 ) AS page; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); UPDATE feed SET published_at='2026-01-01 09:00:00+00' WHERE id=6; UPDATE feed SET published_at='2026-01-01 14:00:00+00' WHERE id=3; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); DEALLOCATE after_cursor; DROP TABLE first_page; DROP SCHEMA article_cursor CASCADE; 第一个数组是 {6,5,4},因此游标来自编号四的时间戳与编号。代码先把真正返回的第一页保存到临时表,再从它取得边界,而不是另外查询一次正在变化的原表来猜测客户端看到了什么。在读写交错时,这个区别直接决定后续边界是否对应上一份响应。 ...

八月 19, 2026 · 2 分钟 · XB Field Notes