观察、决策与行动三块面板,旁边的标题强调续传前检查范围与表示。

续传前先核对范围与表示身份

范围请求要求获取选中表示的一部分,用于恢复中断传输或读取指定字节窗口。满足单个范围时,通常返回带有 Content-Range 的 206。服务器也可以忽略范围,返回完整表示和 200;如果范围无法满足,还可能返回 416。这些响应要求客户端采取不同动作,不能把所有“没有拿到预期范围”的情况都解释成同一种完整下载。 坏掉的样子 把每个成功响应都直接追加,会破坏下载文件。当服务器忽略 Range,或者 If-Range 验证器不再匹配时,200 响应包含完整表示。把它接在已有前缀后面,就会重复数据。HTTP 成功状态不能证明正文从请求的偏移开始,熟悉的文件名也不能证明表示身份。修改本地文件之前,必须先判断这次得到的是片段还是整份替代内容。 缺少合适验证器时,续传还可能把两个版本的字节组合成一个文件。If-Range 允许服务器仅在表示仍匹配时返回范围,否则发送完整表示。用于该条件的实体标签必须是强标签;弱标签不是字节身份承诺,也不是所有 Last-Modified 日期都能充当强验证器。是否适合用作续传条件,需要符合协议规则和服务器真实更新行为。 其他请求前提条件先于 Range 评估。本来应返回 304 或 412 的请求,不会因为同时带有 Range 就成为成功的范围传输。下面实验没有发送相互竞争的前提条件;把其预期状态用于下载器时,要保留这个限制,不能把它当成所有条件组合下的完整状态表。 范围坐标从零开始,末尾位置也包含在内。把末尾位置误当成长度,会产生差一个字节的问题。后缀范围表示最后若干字节,省略末尾则一直延伸到表示结尾。多个范围可能返回带独立元数据和边界的 multipart 正文;如果把整份正文当成连续文件片段写入,就会把分隔信息也混进下载内容,虽然状态码仍然是 206。 快速验证 将脚本保存为 check-ranges.py,准备 Python、Docker 和固定摘要的 nginx 镜像后执行。记录环境为 Linux arm64 上的 nginx 1.28.0。实验通过宿主回环提供一个可丢弃的 64 字节静态表示,不启用压缩,使范围位置始终对应摘要断言使用的原始表示,避免把内容编码变化混入基本范围测试: ...

十月 20, 2026 · 2 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Content-Type 与 Vary 要一起工作

同一个 URL 可以向 API 客户端返回 JSON,也可以向另一个读者返回纯文本。Content-Type 说明这次选中了什么表示,Vary 则说明哪些请求字段参与了选择。如果缓存不知道应该怎样匹配后续请求,即使正文的类型标签完全正确,也可能把属于另一种偏好的响应送给当前读者。 分清格式、选择与复用 如果服务器根据请求的 Accept 字段进行协商,就应在实际响应类型之外返回 Vary: Accept。写成 Vary: Content-Type 指定的是请求里的类型字段,并不是响应的媒体类型;它通常不能表达客户端希望收到哪一种格式。缓存无法仅通过查看已存响应的类型,倒推出服务器选择该表示时使用的规则。目标 URI 与相关方法语义本来就参与普通缓存匹配,Vary 额外约束的是请求头字段。 压缩还涉及另一组容易混淆的概念。经过 gzip 的 JSON 仍然是 JSON 媒体类型,Content-Encoding: gzip 描述对表示施加的内容编码,不是传输编码或消息分帧。如果源站依据 Accept-Encoding 选择是否压缩,这个请求字段就是有关的 Vary 维度。只有真正参与选择的维度才需要纳入;浏览器的实际视口宽度不会自动变成一个请求头,也不能仅凭“这是移动端页面”就推导出缓存键应该包含什么。 Vary 不授予缓存资格,也不让一个过期响应重新变新。存储和复用还必须满足状态码、方法、认证以及缓存指令等独立条件。它更不是访问控制边界:增加 Vary: Cookie 并不能证明个人账户数据适合进入共享缓存。应先确定谁有资格接收响应、哪些缓存允许保存,再描述这些可复用表示之间的差异,不能把权限问题缩减成几个字段名。 坏掉的样子 当响应本身可以缓存且仍然新鲜时,漏写 Vary 可能让第一个请求决定后续读者拿到哪一种内容。直接访问源站一切正常,但走缓存时,后来的客户端不再回源,直接收到不兼容的表示。状态码仍是成功,Content-Type 也如实描述正文,却没有告诉读者这份响应属于另一种请求偏好。诊断时需要同时对照请求 Accept、响应类型和缓存复用证据,不能只看接口是否返回成功。 ...

十月 12, 2026 · 2 分钟 · XB Field Notes
“一个意图需要一份带作用域的记录”标题卡旁边排列观察、决策与行动面板。

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

一个幂等键只能在服务定义的作用域与保留窗口内标记一次逻辑行动。重复同一段字符串,不是放之四海皆准的“恰好执行一次”保证。服务需要把身份关联到正确的主体与操作,协调竞争中的请求,并保存足够权威的信息,以便稍后认出已经处理的行动。键只是入口,真实保证取决于这条处理链。 作用域是身份的一部分 如果全局只按客户端提交的键查找,就可能把不同租户的行动合并,甚至把别人的已存结果返回给当前调用方。查找或重放之前仍须完成认证与授权。应把可信作用域、稳定的操作标识、客户端行动键一起作为查找身份,并保留各组件边界,避免直接拼接字符串产生歧义。 究竟哪些维度属于作用域,要由 API 契约决定。EC2 明确区分地域级与可用区级幂等;按其文档规则,相同客户端令牌在不同地域或可用区可能允许执行不同的行动。因此,把重试切换到另一个地域,并不自动保留全局去重保证,不能只检查令牌文本是否一致。 请求含义也需要约束。同一个作用域内,原键配上不同参数时,必须按照已经声明的冲突规则处理。下面的实验只对一个正整数量生成比较表示,不比较任意 HTTP 正文,不执行认证,也不认为对正文取个哈希,就能自动推断业务语义是否相同。 让业务效果与结果记录一起提交 先查记录、再独立写入,会出现竞争:两个工作进程都没看见旧记录,于是都执行了业务。如果两份业务效果已经发生,之后只有一个结果记录插入成功,那么结果表上的唯一键本身并不能消除之前的重复效果。需要保护的是业务修改与记忆结果之间的完整边界。 两者都位于同一个事务数据库时,可以放在一个事务中提交。示例先执行 SQLite BEGIN IMMEDIATE,再查询记录,并在同一事务里写入业务行与结果记录。SQLite 同时只允许一个写事务,因此竞争连接会被串行处理;复合主键也明确表达了去重所依赖的身份组成。 事务保护止于这个数据库。外部支付、邮件发送或消息投递,不会因为本地事务失败就自动撤销;这些效果需要自己的端到端投递与去重契约。把整个流程命名为“恰好一次”,并不能凭空补出跨越多个系统的原子性,评审时必须沿真实副作用检查边界。 保留期也是正确性边界 如果旧请求仍可能回来,而记住它的键已经过期,那次请求就可能重新执行。Stripe API v1 文档明确说明,记录清理后的同键请求会成为新请求;其至少二十四小时的保留说明属于该提供方,并不是所有服务通用的计时器,更不能解释成永久去重。 恢复窗口应与保留期一致,还要区分临时去重记录和长期业务唯一性规则。删除结果记录,不会撤销原来的订单。如果业务禁止这笔订单在任何时候被重新创建,就需要一个比短期重试缓存更持久的权威约束,不能把希望都放在键还没过期这件事上。 恢复较旧的记录数据库、清空状态或切换到独立存储,也可能忘记已经完成的行动。恢复方案应保留业务数据与操作记录之间的关系,不能只证明各自的文件还能读取,却没有验证它们是否对应同一批已提交事实。 坏掉的样子 提交成功后丢失响应,会让客户端不知道结果,但不会自动删除已存记录。提交前事务失败,则应同时不留下业务效果与成功记录。这是两种不同情况,需要不同证据,不能把“客户端看到了错误”直接等同于“服务器没有执行”。 数据库竞争、存储不可用或未知结果,也不能被当成“查不到旧记录”。看到操作仍在执行,不应自动放行第二份业务效果。需要明确重复请求是等待、得到执行中响应,还是走另一条已有文档的恢复路径;成功结果重放只是可能状态之一,不覆盖所有中间阶段。 快速验证 保存为 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
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Content-Type 告诉读它的人这是什么

Content-Type 描述发送方打算提供的表示,但它不会检查正文、转换字节,也不保证所有接收方都采取相同的处理方式。可靠的接口需要让序列化结果、媒体类型声明和消费者的预期相互吻合。这个要求不仅适用于成功响应,也适用于认证失败、代理报错和应用异常等不同的返回路径。 描述表示,而不是文件名 对于携带正文的普通响应,application/json 表示 JSON 数据,text/html 表示 HTML。类型描述的是解除内容编码之后的数据格式。因此,一份经过 gzip 压缩的 JSON 响应仍然声明 Content-Type: application/json,同时用 Content-Encoding: gzip 说明压缩。内容编码属于表示的属性,不等于 HTTP 消息如何分帧;例如分块传输编码处理的是另一层问题。修改文件扩展名或响应头都不会自动完成这些转换。 媒体类型由主类型、子类型以及可选参数构成。主类型和子类型不区分大小写,参数是否有意义则取决于对应媒体类型的定义。消费者应使用已有的头字段解析接口,不要把完整字段与一个固定字符串直接比较。同时必须单独检查字段是否存在,因为部分解析器会给缺失类型提供默认值,掩盖服务端漏发元数据的事实。多个相互冲突的类型字段也不是合法的候选列表,而是需要在源站或代理处修正的缺陷。 对于 UTF-8 编码的 HTML,text/html; charset=utf-8 同时描述格式与字符编码。但不能据此给每一种文本格式统一追加字符集参数。RFC 8259 要求非封闭生态系统之间交换的 JSON 使用 UTF-8,而 application/json 的媒体类型注册没有定义 charset 参数。接收方不能靠另加一个参数,就把不符合该交换约定的字节变成合规 JSON。类型参数必须服从格式本身的规定,而不是套用全站通用字符串。 坏掉的样子 常见故障是反向代理把 API 响应替换成 HTML 登录页,却仍返回成功状态码。客户端一上来就调用 JSON 解析器,只能看到语法错误,反而隐藏了“收到另一种表示”这个更有价值的线索。另一种情况恰好相反:应用错误处理器输出 HTML,却沿用了成功路径的 JSON 响应头。这时仅检查声明也会放行无效正文。所以,状态码、响应元数据和实际字节是三个需要一起检查的证据。 ...

十月 10, 2026 · 2 分钟 · 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
观察、决策与行动三块面板,旁边的标题说明重定向链增加请求。

重定向链增加后续请求

客户端跟随重定向时,需要先读取 Location,解析目标地址,再发出后续请求,才能取得需要的表示。几个单独看来合理的规则经常组合成长链:规范主机名、切换到 HTTPS、补齐末尾斜线、迁移旧文章。检查时应从用户实际访问的旧地址出发,沿完整路径观察,因为每一个中间环节都会增加依赖。 请求次数不等于连接次数 实际从网络读取的新重定向通常增加一次顺序发生的请求与响应交换。客户端要等前一个响应给出目标,才能继续访问,所以它会推迟最终内容的获取。但“再发请求”不等于“必须重新建立 TCP 连接”。同源 HTTP/1.1 可以复用持久连接,HTTP/2 和 HTTP/3 则有各自的连接与流机制。切换主机可能额外触发域名解析或建连,是否真的发生还取决于已有连接和缓存。 浏览器内部升级和缓存重定向也会改变网络轨迹。HSTS 可能直接把 HTTP 地址升级为 HTTPS,不必先请求明文入口;永久重定向也可能已经存储在缓存。因此,不能为每一跳规定固定耗时,更不能直接断言三个重定向就让整页加载慢三倍。图片、脚本及渲染都可能占据更多时间,必须结合真实瀑布图判断关键路径。 状态码首先表达语义。301 和 302 的历史处理允许把 POST 改为 GET,307 与 308 要求保留方法,303 则指向另一个资源的检索。永久性与可缓存性同样需要考虑。为了把请求列表变短而统一改成永久跳转,可能让客户端长期记住一次错误配置,即使服务器随后修复也不能立即消除影响。 Location 可以使用相对引用,必须结合当前请求地址解析后,再判断目标源和路径是否符合预期。只记录原始响应头可能掩盖问题:相同的相对值从不同目录返回,最终会指向不同位置。故障记录应同时保留原始值和解析结果。 坏掉的样子 两个规则可能互相反向,例如一个规则添加斜线,另一个又删除斜线。分别检查两个端点都能收到响应,并不能证明访问最终会结束。客户端通常有跳转次数上限,但触及上限依然意味着用户任务失败。修改共享规则后,需要记录访问过的目标,并覆盖具有代表性的旧地址、带参数地址及不同入口。 中间地址失效时,最终页面即使健康也无法保证整条路径可达。可以把内部链接更新为最终规范地址,并在语义允许时让历史地址直接跳过去。修改过程中仍要保留有业务意义的查询参数和编码,不能因为少一跳就丢失筛选条件。也不要接受完全不受约束的用户目标地址,或把身份凭据无条件转发到另一个源。 抓取程序同样需要处理额外请求,但重定向链并不能换算成固定的搜索排名损失。搜索引擎有独立的处理规则和上限,本地实验没有测量这些行为。可报告的是跳转数量、是否循环、最终状态及目标是否一致;这些证据比对排名作没有依据的保证更适合指导修复。 快速验证 将下面程序保存为 redirect-chain.py,运行 python3 redirect-chain.py。程序使用 Python 3 创建 HTTP/1.1 回环服务器,并始终使用同一个 HTTPConnection。每个响应都有明确长度,客户端完整读完后可以继续复用连接。响应设置 no-store,使实验观察实际网络交换,而不是依赖缓存里的跳转记录。 ...

十月 4, 2026 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

安全响应头需要可观察的浏览器行为

安全响应头让支持它们的浏览器执行具体规则,价值来自这些规则改变了什么行为,而不是检查清单有多长。即使是静态站点,也涉及文档加载、脚本或样式、页面嵌入、导航和传输等边界。应根据真实页面及交付路径选择策略,同时验证预期限制和合法功能。不能把响应里出现一串熟悉字段,当成已经证明页面完整、安全且不会破坏正常集成。 内容安全策略可以限制资源加载与脚本执行,嵌入祖先指令规定哪些祖先能够包含受保护文档,引用来源策略控制适用请求携带多少源地址信息,权限策略则约束选定的浏览器功能。CSP 的 frame-ancestors 必须通过 HTTP 响应头交付;写在 meta 元素里不会建立这个嵌入策略。这些控制相互关联,但没有哪一个只凭字段存在,就能建立经过认证的业务权限。适合某类响应的规则,放到另一类响应上可能毫无意义,也可能妨碍页面原本需要的行为。 理解每个响应头负责的边界 严格传输安全策略告诉浏览器,对适用的主机使用 HTTPS。浏览器从有效的 HTTPS 响应学习该策略,普通 HTTP 响应不能建立它。包含子域名的设置还会影响当前应用之外的主机,因此这些主机需要支持预期的安全传输方式。这个响应头不会签发证书,也不会修复已经过期的证书;在依赖浏览器长期记住的策略之前,传输配置本身必须已经可靠。 跨源开启者策略影响浏览上下文和开启者隔离,跨源资源策略则限制某些跨源资源加载。把二者统称为隔离所有窗口的通用开关,会混淆各自约定。CORS 响应暴露又是另一套机制。修改相关策略时,应验证依赖弹出窗口、嵌入资源或跨源访问的实际集成,而不是把能找到的所有响应头复制到同一条规则中,再期待它们自动组合成正确结果。 坏掉的样子 nosniff 让浏览器处理资源时尊重声明的内容类型。对于脚本和样式表目标,如果响应的 MIME 类型不适合该目标,浏览器会拒绝它。因此,一个把 JavaScript 当成纯文本交付的部署,可能在启用该响应头后停止工作。应修复实际交付的字节、类型和状态,不能只删除限制,直到现象消失。策略暴露了交付错误,并不说明策略本身就是错误的。 另一种常见问题,是脚本或样式路径实际返回了 HTML 错误页。正确策略可能让这个原本存在的路由错误更容易被发现。应检查经过重定向、缓存和路由之后真正收到的响应,包括状态与内容类型。只验证安全头是否存在,无法知道收到的是不是目标资源;首页请求成功,也不能证明错误响应和静态文件都遵循了各自的交付约定。 快速验证 保存代码为 nosniff.py,执行 python3 nosniff.py –serve。先打开打印出的地址并加上 ?mode=correct,再分别测试 ?mode=strict 和 ?mode=loose。此前记录使用 Python 3.14.4 与 Chrome 152;本轮没有重跑浏览器实验。临时回环服务在三种情况下返回完全相同的经典脚本字节,只改变内容类型及 nosniff 响应头。页面报告脚本是否执行、加载是否失败,结束后按控制键加 C 关闭服务。 ...

九月 30, 2026 · 2 分钟 · XB Field Notes
标题为「ETag 与条件请求避免多余工作」的封面,下方是观察、判断与行动三个面板。

ETag 与条件请求避免多余工作

只有旧表示确实仍可替代当前表示,304 才有价值。ETag 提供的是源站对版本的声明,不会独立检查这个声明是否诚实。错误的标签会让条件请求高效地保留错误内容。因此在把传输减少当作性能改善之前,既要测试未变化的数据,也要测试有意义的内容变更,不能只追求更多的 304。 校验器标识版本 实体标签是带引号的不透明标识。客户端应按收到的形式带回它,不应擅自当作时间戳解释,也不应移除弱标签标记。生成器可以使用版本编号或抗碰撞摘要。当最终字节已经存在时,摘要比较方便;如果每次为了返回 304,仍要先完整渲染并计算一份昂贵文档的哈希,源站的计算成本可能并没有明显下降。省掉正文传输不等于省掉所有工作。 强与弱是不同的承诺 所选表示的数据变化时,强验证器必须变化。带缩进的 JSON 与紧凑 JSON 即使解析成同一个对象,字节也不同;gzip 与未编码形式同样具有不同表示数据,强标签必须保留这种区别。长度相同,或者底层数据库的粗粒度时间戳相同,都不能证明逐字节一致。因此验证器的设计要贴近真正返回的表示,而不是只观察某个间接指标。 带 W/ 的弱标签可以把源站认为可互换的表示归为一组。若 JSON 接口契约不关心排版,一个语义版本可以同时覆盖紧凑和缩进序列化。实际字段值变化后,如果旧对象已不再是可接受的替代品,就必须更新该版本。弱验证表达的是明确的等价策略,不是任意变化都允许沿用标签,更不是可以永远不维护版本信息。 If-None-Match 使用弱比较完成缓存验证。条件 GET 匹配时返回不带正文的 304,不匹配时通常返回当前表示与 200。If-Match 则使用强比较,这关系到更新前置条件;客户端也不能用弱标签作为 If-Range 的验证器。这几个字段的契约不同,先去掉 W/ 再统一做字符串比较,会把本来不同的语义混成一件事。 坏掉的样子 变化数据搭配固定标签,可能产生错误的 304,让客户端继续使用旧副本。另一个方向的错误,是完全相同的数据每次生成新标签,导致多余正文传输。添加很长的 max-age 无法修复这两种问题。新鲜度决定何时需要验证,验证决定已有响应是否仍然可用;成功的 304 也不保证应用解析、渲染或业务计算自然消失。 比较必须发生在选择表示和正常访问检查之后,匹配的验证器不是授权凭证。协商响应还需要保留对应的 Vary 维度,并正确区分表示数据。标签列表应交给真正的解析器,因为合法的带引号标签内部可以包含逗号;直接按逗号拆字符串,会破坏本来有效的标识。下面的测试会同时检查这一点,而不仅验证最简单的单个标签。 快速验证 准备 Python 3.11 或更新版本和 Werkzeug 3.1.8,创建临时环境后,用 "$etag_lab/venv/bin/python" 执行 Python 代码。测试客户端在进程内执行 WSGI 应用,不监听端口,也不发网络请求;只有安装依赖需要联网。故意写错的端点是反例测试,不能作为实际部署实现。 ...

九月 26, 2026 · 2 分钟 · XB Field Notes