观察、决策、行动三个方框,旁边强调取消单条流后还要验证其余请求。

HTTP/2 多路复用消除排队

HTTP/2 通过多路复用解除 HTTP 响应的顺序约束,但不同流仍然共享 TCP 连接及其故障边界。取消就是一个值得单独验证的场景:终止某个请求,不一定需要破坏相邻请求或可复用连接。这是一项有明确作用范围的能力,不能扩写成所有流在任何条件下都互不影响。 流不是底层套接字 HTTP/2 在连接内部标识单条流。RFC 9113 定义的 RST_STREAM 用于终止某条流,既可以表达取消,也可以报告流错误;它并不是重置整个套接字的 TCP reset。只要连接本身仍然可用,实现就可以停止一次交换,同时让其他流继续推进,而不必为每次取消重新建立连接。 这与 HTTP/1.1 的持久连接并不矛盾,后者本来就允许一条连接承载多次交换。HTTP/2 新增的是交错传输与流级生命周期控制,而不是首次发明连接复用。TCP 的有序交付、拥塞控制、连接级 DATA 流控以及服务端资源仍然约束进度;即使一次请求取消没有影响其他流,连接故障也可能同时中断多个请求。 取消还不等于撤销已经完成的业务。服务器可能早已提交事务,或调用了另一个服务。客户端读取正文时得到取消错误,无法证明这些操作没有发生。因此,是否重试仍须依据业务语义、幂等保证或可查询的执行结果,不能仅凭网络层已经取消,就把再次提交认定为安全操作。 收到响应头不代表正文成功 成功状态码出现在正文之前。服务端可以先发送并刷新 HTTP 200,再长时间等待,或者在传输内容时失败。状态、正文完整性和业务结果应分别记录。Client.Do 返回了响应对象,只说明客户端已经拿到响应头,不能据此把整个响应标为读取成功,更不能省略后续读取错误的处理。 Go 的出站请求上下文控制取消;对于入站 HTTP/2 请求,一次取消可以让服务端对应请求的上下文结束,而不需要关闭整条连接。处理函数在等待或访问依赖时,应观察并传递该上下文。运行时不会自动撤销任意业务动作。Client.Timeout 还覆盖响应头到达之后的正文读取,不能把它理解成只约束握手或等候首字节。 坏掉的样子 如果几个请求共用一个父上下文,取消父上下文本来就会影响它们;关闭底层连接也比取消单个请求的范围更大。请求自己的时限应由请求自己的取消范围管理。不能对共享套接字设置整体期限后,又声称产生的超时只作用于一条 HTTP/2 流;排障时必须核对取消信号的真正拥有者。 遗弃正文可能保留资源或占用流控额度。适合完整消费的响应应读完,已经结束或取消的正文也必须关闭。未读数据是否妨碍相邻流,与库的缓冲和窗口更新策略有关。流数量上限、共享连接额度和后端容量仍然存在;一个小实验能成功取消,不代表高负载时也没有相互影响。 快速验证 保存为 cancel_probe.go,使用 Go 1.23 或更新版本运行 go run cancel_probe.go。本地服务使用经过验证的 TLS 1.3 协商 HTTP/2。两个处理函数先刷新 HTTP 200 响应头,再让正文等待。客户端随后只取消第一个请求;另一个响应保持打开,双方都使用同一条被编号的 TCP 连接。 ...

一月 16, 2027 · 3 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Cookie 与会话守住边界

Cookie 是浏览器的存储与传输机制。服务端会话则是一种应用设计,用 Cookie 的值查找服务器保存的状态。分开这两种责任,登录、过期和退出才容易验证。本文沿着不透明会话标识的生命周期展开,不再重复主机、路径或 SameSite 匹配规则;这些发送条件不能回答旧会话是否已经真正失效。 持有标识仍可能获得权限 在这种设计中,服务器生成不可预测的标识,并把它关联到一条会话记录。客户端不需要理解该值,但持有它仍可能获得访问资格:窃取有效 bearer 标识的人,可以尝试直接重放。称它“只是指针”不能让读者以为它无害。不要把它放入 URL、日志、诊断截图或支持材料中,随机字符串也可能是实际凭据。 不能直接信任客户端控制的明文角色字段。不过,Cookie 中含有有意义的数据,也不等于必然可以伪造:经过正确认证和签名的会话格式属于另一种设计。签名保护完整性,不提供保密性;无状态方案仍须明确过期、密钥管理与撤销策略。应选择维护良好的会话框架,不要仅为了省一次服务端查询就自行发明格式。 不透明方案需要使用密码学安全随机生成器和足够熵值。未知标识应被拒绝,而不能用客户端任意指定的值建立特权状态。服务器解析会话后,还须根据当前权限授权每项操作。某个会话已经认证,不代表它可以读取每一个对象、操作每一个账号,或自动获得所有管理功能;会话存在与具体动作获准是两项检查。 两种寿命需要两处控制 Cookie 的 Max-Age 与 Expires 管理客户端存储,不会自动使服务端会话记录过期。非浏览器客户端可以保留这个值,并在浏览器本该删除后继续重放。浏览器会话 Cookie 也可能通过会话恢复机制保留。因此必须在服务端强制执行绝对期限与空闲期限,使用明确的边界比较和一致的时间来源,而不能靠标签页里的倒计时作为安全判断。 退出时既要作废服务端状态,也要按匹配的作用域清除浏览器 Cookie,否则复制出来的令牌仍可能有效。认证后与权限变化后轮换标识,有助于防止会话固定,但前提是旧标识确实失去权限。并发请求和分布式存储需要额外协调,避免一个较晚完成的旧请求悄悄恢复已经撤销的会话;只覆盖单进程正常路径远远不够。 Secure 与 HttpOnly 分别约束传输和脚本读取;HttpOnly 不会阻止注入脚本发起认证请求。SameSite 是浏览器语境防御,不是服务端过期或权限检查。Cookie 与请求头限制也因浏览器和服务器而异,并不存在普遍适用的八千字节请求头上限。保持标识紧凑可以减少开销,但不能把体积当成判断会话是否安全的唯一依据。 坏掉的样子 登录时升级了认证前记录,却保留攻击者已经知道的标识;退出时只删除 Cookie,却让复制的值继续有效;空闲超时只由 JavaScript 检查,直接客户端仍能访问。这些都是服务端生命周期故障,即使开发工具中的 Cookie 标记完全正确,也不能弥补这些逻辑问题。排查时要重放旧的虚构标识,而不只是确认响应发出了哪些属性。 不要假定定期活动就应该无限延长访问。空闲计时可以随接受的活动滑动,而同一个会话的绝对截止时间仍然固定。是否在重新认证或提权时开始新的生命周期,应由明确策略决定。尤其要检查令牌刷新是否意外不断重置原本的硬期限,否则名义上的绝对过期可能在活跃客户端面前永远不会到来。 快速验证 将代码保存为 session-lifecycle.py,使用 Python 3.11 或更新版本执行 python3 session-lifecycle.py。它使用虚构用户、可预测的夹具标识与传入的时间值,不打开套接字,也不提供任何真实认证服务。绝不能把这个标识生成器或内存模型用于真实应用;可预测取值只是为了让本地断言清楚可复现。 ...

一月 12, 2027 · 2 分钟 · XB Field Notes
双向箭头持续流过同一条连接。

WebSocket 是会话,不是页面

多数 HTTP 请求是一次性的:客户端问,服务器答,连接也许就结束了。WebSocket 升级一条连接,让任一侧随时都能发送。在它和轮询之间选择,是在决定更新该被「推」还是被「拉」。 升级买到什么 WebSocket 的前半段,是一次普通 HTTP 请求去请求「升级」;服务器答允之后,原本一问一答的连接就变成一条持久、双向的通道。这次升级真正买来的,是不再非要等客户端问一次才答——只要数据一有变化,服务端就能主动把消息推过去。由此语义也变了:同一条连接上流动的不再是一串相继的往返,而是连续许多条消息,所以聊天、实时看板那类场景,不必每条都重走一遍请求链,只靠一条低延迟管道就能一直保持。 轮询仍是一个正当的基线 轮询依然是一个完全正当的基线,在下决心用 WebSocket 之前,应当先想想它。如果事件难得出现、又允许略晚到达,那么用普通间隔去读一次数据,会更简单、更贴近缓存、也更方便运营,远没有一套握手与长连接那么多环节。每一次推送都要付出消息与握手的代价,还要让负载均衡器和网络中间件容忍长期空闲的「挂起」。真正需要服务端主动推送、又要低延迟时,再选择 WebSocket,而不是为了追新而追新。 让会话保持健康 一条会话一旦长期打开,你就要负责让它保持健康。定时发心跳或 ping 消息,免得空闲连接被中间层的中间件踢掉;掉线后按照「指数退避」重连,并把断掉的会话当作「页面关闭」来善后——清理状态、取消订阅。更重要的是身份验证必须在那次握手时完成,因为升级之后再没有一次新请求能验明正身。经营一条 websocket 干线,意味着重连、心跳、清理与身份这些准备,远在故障之前就该就绪。 坏掉的样子 空闲连接最先被代理杀死。公司代理、CDN 与负载均衡器常常把静默六十秒的连接直接掐断,而两端都还以为会话好好活着。这种失败是静默的:没有 close 帧到达,客户端把下一条消息推进死连接,服务端还留着一个永远不再读的订阅者。没有心跳,就无法区分「安静的对端」与「已死的对端」,服务端只能慢慢堆积幽灵连接,白白占用文件描述符,还替无人消费的广播买单。 扩容会暴露第二种失败。会话状态长在接受握手的那台机器上:订阅集合、鉴权结果、积压队列都在本地。若粘性会话丢失,重连落到另一台机器,客户端必须从头订阅;若它误以为旧订阅还在,就会静默丢消息。身份是同一个陷阱:令牌只在握手那一刻验过一次,升级之后再也不会随消息同行。 还有一种慢性失败是背压失控。推送端只管往连接里写,从不看对端读得多慢,慢消费者会把服务端内存一点点顶满;反过来客户端也可能被每秒数百条行情淹没,界面卡死还误以为是渲染问题。会话是双向的,任何一侧都该有队列上限与丢弃策略:慢订阅者要么降频采样,要么主动断开,绝不能让一条慢连接拖垮整台机器。 恢复可能补上最后一击。服务端重启,或代理一次掐断数千条连接,所有客户端立刻同时重拨,重试洪峰变成重连风暴,正好打在鉴权与订阅链路上——那恰恰是容量最低的时刻。修法故意无聊:心跳间隔短于代理空闲超时,连续两次错过 pong 就关闭并清理,重连必重新鉴权,订阅必须能用一条消息重建,重连一律指数退避加抖动。 快速验证 把下面程序存为 ws_session_probe.py,运行 python3 ws_session_probe.py。它只用标准库,只绑回环地址,不改进程之外的任何东西。回环 http.server 校验原始升级请求,socketpair 模拟心跳期限。 """Probe WebSocket session semantics with stdlib only: handshake + heartbeat.""" import base64 import hashlib import socket import threading from http.server import BaseHTTPRequestHandler, HTTPServer GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 section 1.2 example key and its expected accept value. KEY = "dGhlIHNhbXBsZSBub25jZQ==" EXPECTED_ACCEPT = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" def compute_accept(key: str) -> str: return base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode() class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): key = self.headers.get("Sec-WebSocket-Key") upgrade = (self.headers.get("Upgrade") or "").lower() connection = (self.headers.get("Connection") or "").lower() ok = ( self.path == "/chat" and key is not None and upgrade == "websocket" and "upgrade" in connection ) if not ok: body = b"expected websocket upgrade" self.send_response(400) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return accept = compute_accept(key.strip()) self.send_response(101, "Switching Protocols") self.send_header("Upgrade", "websocket") self.send_header("Connection", "Upgrade") self.send_header("Sec-WebSocket-Accept", accept) self.send_header("Content-Length", "0") self.end_headers() def log_message(self, *args): pass def read_http_response(sock: socket.socket) -> str: data = b"" sock.settimeout(5) while b"\r\n\r\n" not in data: chunk = sock.recv(4096) if not chunk: break data += chunk return data.decode("latin-1") def main() -> None: # 1. The accept derivation itself must match the RFC 6455 test vector. assert compute_accept(KEY) == EXPECTED_ACCEPT, "accept mismatch" print(f"accept vector ok: {EXPECTED_ACCEPT}") # 2. A real loopback handshake: raw request, assert 101 + accept echo. server = HTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: port = server.server_address[1] with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: sock.sendall( ( "GET /chat HTTP/1.1\r\n" "Host: 127.0.0.1\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" f"Sec-WebSocket-Key: {KEY}\r\n" "Sec-WebSocket-Version: 13\r\n" "\r\n" ).encode() ) head = read_http_response(sock) assert "101" in head.splitlines()[0], head assert f"Sec-WebSocket-Accept: {EXPECTED_ACCEPT}" in head, head print("handshake ok: 101 + Sec-WebSocket-Accept matches key") # 3. A wrong path is not a session: it must stay plain HTTP. with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: sock.sendall(b"GET /page HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") head = read_http_response(sock) assert "400" in head.splitlines()[0], head print("non-upgrade ok: plain GET gets 400, no session created") # 4. Heartbeat semantics over a socketpair: a pong in time keeps the # session, silence past the deadline marks a ghost connection. alive, peer = socket.socketpair() with alive, peer: alive.settimeout(2) peer.settimeout(2) alive.sendall(b"ping-1") assert peer.recv(1024) == b"ping-1" peer.sendall(b"pong-1") assert alive.recv(1024) == b"pong-1" print("heartbeat ok: ping/pong round-trip keeps session alive") peer.settimeout(0.2) try: peer.recv(1024) raise AssertionError("expected silence, got data") except socket.timeout: print("ghost detection ok: missed pong past deadline = stale session") finally: server.shutdown() server.server_close() print("PASS: handshake, rejection, heartbeat, and ghost detection hold") if __name__ == "__main__": main() 脚本断言四条会话语义。接受值推导与 RFC 6455 示例向量一致;格式正确的升级返回 101 并回显匹配的 Sec-WebSocket-Accept,而普通 GET 只得 400 且不创建会话。ping 与 pong 往返让会话存活,超过期限的沉默即判为幽灵连接。运行成功会逐行打印向量、握手、心跳与幽灵判定,最后输出 PASS;任一断言失败即抛异常并以非零状态退出,可直接当作回归门禁。以上断言在 macOS、Python 3.14 下运行通过。该夹具只覆盖握手与心跳逻辑,不复现代理空闲超时、TLS 终结或浏览器重连行为,上线前请在真实链路上复测空闲与风暴场景。 ...

一月 8, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调探针各有用途且检查成本必须受限。

一个为用途而建的健康检查值得保留

健康端点是一份会驱动后续动作的契约。处理器能够返回 200,并不证明实例可以完成有价值的业务请求。写代码之前应先明确观察者要据此做什么:等待初始化、接入新流量,还是重启一个无法自行恢复的进程。问题不同,需要的证据就不同,不能把它们压成一个名字叫“健康”的布尔值。 区分进程恢复和流量准入 存活检查应该对应重启能够改善的本地故障,例如无法继续工作的死锁。共享数据库不可用,通常不能靠把所有调用方重启一遍来修复。就绪检查回答这个实例现在是否适合接收它负责的流量,只纳入这条业务路径必需的依赖;可选的推荐功能失败,不应自动把仍能结算的实例整个摘出流量池。 启动检查保护耗时初始化不被过早判定为故障。在 Kubernetes 中配置 startup probe 后,存活和就绪探针要等启动探针成功才运行。就绪失败影响流量准入;启动或存活探针连续失败达到配置容忍度后,才可能结合重启策略触发重启。单独收到一份 503,并不代表重启、摘流或恢复已经在控制面完成。 状态契约必须以真实消费者为准。Kubernetes 的 HTTP 探针把大于等于 200、小于 400 的响应视作成功,所以误接入登录跳转的探针可能显示绿色。下面的三个 GET 健康路由刻意只返回 200 或 503,不靠正文里的“正常”字样决定健康,也不允许通过自动跟随重定向掩盖原始结果。 给探针引入的工作设预算 选择便宜、只读而且确实代表关键依赖的操作。取得连接或者完成一次小请求,只能证明那条具体路径,不代表所有业务事务都成功。内部期限应短于外层调用者的超时,并把取消信号传到下游;响应体和计时器都要释放。外层提前返回错误、内部请求却继续排队和执行,并不算真正限制了探针成本。 总负载还取决于实例数量、独立观察者数量和轮询频率。不要每次探测都展开整张依赖图,也不要让健康检查抢光业务恢复所需的连接。如果项目已有后台检查器,可以复用其结果,但必须定义结果年龄和失败含义,不能把很久以前的绿色无限期当成当前事实。中间缓存同样不能把旧成功响应发给负载均衡,让已经失败的实例继续被接入。 坏掉的样子 永远返回成功的端点,会在数据库断连、工作池耗尽或初始化尚未完成时继续放行流量;相反,把共享依赖故障挂到存活检查上,会让整批实例进入无效重启循环。探针自己也可能消耗稀缺连接,或者因容忍阈值过低,在短暂暂停时同时摘掉过多容量。应当用实际失败场景证明状态含义,而不是仅仅证明路由可以访问。 测试至少覆盖未启动、依赖明确失败、依赖不响应、主动排空以及恢复。本设计中的存活路由不能被共享依赖拖着失败。排空首先禁止新流量准入,之后才能等已有请求结束;但一个健康端点本身不会等待负载均衡传播,也不会替应用完成连接排空。公开响应保持简短,不返回内部依赖地址、凭据或堆栈,详细原因放在受控的运维观测中。 快速验证 保存下面程序为 health_probe.go,运行 go run health_probe.go。两个回环服务分别模拟应用及其唯一必需依赖,依赖返回 204 才算通过。应用通过一百毫秒的子 context 限制下游请求,外层测试客户端允许两秒;启动与排空由内存标志表示,不会改动真实服务、负载均衡或集群配置。 package main import ( "context" "fmt" "io" "net/http" "net/http/httptest" "runtime" "sync/atomic" "time" ) func must(err error) { if err != nil { panic(err) } } func check(ok bool, message string) { if !ok { panic(message) } } func main() { var mode, dependencyCalls atomic.Int64 var started, draining atomic.Bool canceled := make(chan struct{}, 1) dependency := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dependencyCalls.Add(1) switch mode.Load() { case 1: w.WriteHeader(503) case 2: <-r.Context().Done() canceled <- struct{}{} default: w.WriteHeader(204) } })) defer dependency.Close() transport := &http.Transport{} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 2 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if r.Method != "GET" { w.Header().Set("Allow", "GET") w.WriteHeader(405) return } ready := false switch r.URL.Path { case "/livez": ready = true // No shared-dependency restart policy. case "/startupz": ready = started.Load() case "/readyz": if started.Load() && !draining.Load() { ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond) defer cancel() request, err := http.NewRequestWithContext(ctx, "GET", dependency.URL, nil) must(err) response, err := client.Do(request) if err == nil { ready = response.StatusCode == 204 must(response.Body.Close()) } } default: w.WriteHeader(404) return } if !ready { w.WriteHeader(503) return } w.WriteHeader(200) })) defer server.Close() probe := func(method, route string, status int, calls int64) { before := dependencyCalls.Load() request, err := http.NewRequest(method, server.URL+route, nil) must(err) response, err := client.Do(request) must(err) _, readError := io.Copy(io.Discard, response.Body) closeError := response.Body.Close() must(readError) must(closeError) check(response.Proto == "HTTP/1.1" && response.StatusCode == status, "wrong probe status") check(response.Header.Get("Cache-Control") == "no-store", "probe response must not be cached") check(dependencyCalls.Load() == before+calls, "unexpected dependency work") if status == 405 { check(response.Header.Get("Allow") == "GET", "method contract missing") } fmt.Printf("%s: %d, dependency calls=%d\n", route, status, dependencyCalls.Load()) } probe("GET", "/livez", 200, 0) probe("GET", "/startupz", 503, 0) probe("GET", "/readyz", 503, 0) started.Store(true) probe("GET", "/startupz", 200, 0) probe("GET", "/readyz", 200, 1) mode.Store(1) probe("GET", "/livez", 200, 0) probe("GET", "/readyz", 503, 1) mode.Store(2) probe("GET", "/readyz", 503, 1) select { case <-canceled: fmt.Println("deadline canceled the stalled dependency request") case <-time.After(time.Second): panic("dependency did not observe cancellation") } mode.Store(0) probe("GET", "/readyz", 200, 1) draining.Store(true) probe("GET", "/readyz", 503, 0) probe("GET", "/livez", 200, 0) draining.Store(false) probe("GET", "/readyz", 200, 1) probe("POST", "/readyz", 405, 0) probe("GET", "/unknown", 404, 0) check(dependencyCalls.Load() == 5, "only five readiness checks should query the dependency") fmt.Println("PASS: fourteen probes; live, starting, unready, stalled, draining, and recovered") fmt.Println(runtime.Version()) } 十四次探测只产生五次依赖请求。启动前,startup 和 readiness 返回 503,liveness 仍为 200;初始化完成后 readiness 变为成功。依赖明确返回 503 时,只影响就绪结果,不影响存活。依赖停止响应时,子期限使就绪检查返回 503,依赖服务确实观察到请求取消;接下来一次健康请求成功,证明失败之后仍能恢复,而不是残留一个占着连接的检查。 ...

十二月 31, 2026 · 3 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 HTTP 响应检查。

用 curl 做第一轮 HTTP 探测

命令行 HTTP 请求提供一个范围较小、容易检查的服务视角:不用经过浏览器渲染,就能看到状态、响应头、正文和耗时。开始之前先明确问题,是检查原地址是否正确跳转、最终页面能否读取,还是某种请求方法是否被接受。不同问题需要不同探测方式。一条 curl 命令成功,只能说明这次请求满足了对应条件,不能证明网站所有功能都正常,也不能直接代替用户实际访问路径的验证。 分开保存四类证据 默认情况下,curl 输出响应正文,即使 HTTP 表达错误,进程也可能成功退出。命令退出状态描述传输工具的结果,HTTP 状态则描述已经收到的响应,两者都应该保留。收到正文为“missing”的四零四响应,与根本没有取得 HTTP 响应的连接失败,并不是同一种故障。监控脚本需要明确判断自己关心的响应契约,不能只靠 shell 有没有返回非零状态。 -D 用于保存响应头,-o 指定传输输出去哪里,-w 则单独打印选定的元数据。-sS 隐藏进度条,同时保留错误信息;--max-time 限制传输时间,跟随跳转的探测还应限制最多跳转次数。保存结果时记录所用选项,之后比较才知道方法、地址和请求头是否相同。把这些维度混在一起,往往会把客户端条件变化误认为服务器行为变化。 耗时指标也受连接复用、代理路径和跳转影响。复用连接可能根本不发起新的 DNS 查询;对比之前应保证条件相当,不能因为查询时间一项下降,就判断 DNS 服务性能已经改善。 坏掉的样子 一开始就加 -L,可能让健康的最终页面掩盖错误的第一跳。排查路由时先看原始状态和 Location,验证目标内容时再明确选择跟随。跳转循环、意外的目标主机或方法变化,都可能有问题,即便链条后面某个页面返回二百。不能把“总是跟随跳转”当作所有探测都适用的成功定义;跳转行为本身有时就是要验证的契约。 -I 发送的是 HEAD,不是 curl 发出 GET 后简单丢弃正文。服务器、代理和授权规则可能对这两种方法处理不同。HEAD 失败、GET 成功时,应先检查方法差异,不要立即判定资源不可用。同样,修改 User-Agent 请求头,也不会让 curl 自动具备浏览器的 JavaScript、排版、Cookie 规则或 CORS 执行能力。这些行为仍需要在对应环境里分别检查。 ...

十二月 11, 2026 · 2 分钟 · XB Field Notes
文章标题旁排列着由箭头相连的 OBSERVE、DECIDE、ACT 方框,下方有圆形指针和引导线。

Link 头把相邻的资源关联起来

Link 响应头描述当前资源与其他资源之间的关系。一个返回 JSON 的接口可以用它给出下一页,让客户端不必解析 HTML,也不必猜测分页参数。这个字段提供目标地址和关系类型,是否访问仍由客户端决定。它是可供理解的元数据,不是强制浏览器下载资源、要求爬虫收录页面或授予访问权限的指令。 链接包含上下文与目标 例如 </items?cursor=b>; rel="next",表示从当前上下文资源指向指定目标。没有显式的 anchor 参数时,上下文通常是被请求的资源。相对目标应根据重定向之后的有效请求地址解析,不能根据程序工作目录或想象中的接口根路径拼接。只包含查询串的目标会替换查询参数而保留路径,这与以斜杠开头、从站点根部解析的地址不同。 rel 指出关系类型,注册关系包括 next、prev、alternate 和 canonical,扩展关系还可以使用 URI 标识。type="application/json" 等参数描述的是目标资源,不会改写当前响应实际的内容类型,也不保证目标可信。如果一个链接通过 anchor 改变了上下文,无法处理这种上下文的客户端就不应擅自把它解释成当前页面的关系,否则链接含义已经改变。 不同关系还对应不同消费者。爬虫可能参考 canonical 选择优先收录地址,支持相应机制的浏览器可能根据 preload 提示提前获取资源,但这两种关系都不会把当前请求变成重定向。需要将字段语法验证与目标消费者的行为验证分开:普通 HTTP 库完全可以正确返回这个头,却不主动跟随或下载任何目标。 分页沿服务端契约前进 分页接口由服务器判断是否存在后续结果,并给出下一次应当请求的完整目标。游标可能编码排序、筛选条件和快照位置,应当作为不透明值使用。客户端自己递增页码、解码后改写游标或丢掉返回链接上的查询条件,都可能跳过数据、重复读取或使请求失效。跟随服务器提供的目标,才能让客户端适应接口内部分页方式的调整。 在具体接口的分页契约下,没有 next 通常意味着本次遍历结束,但不能据此推断整个集合从此不再变化。若没有快照语义,并发插入与删除依然可能改变两次请求之间的成员。采集程序应设置请求上限,并识别反复出现的续页目标,以免服务端错误导致无限循环。遇到限流或失败也不能直接当成最后一页,否则得到的是被误标为完整的残缺集合。 坏掉的样子 一个响应可以用多条 Link 字段,也可以在同一字段中用逗号分隔多个链接。直接按每个逗号切分会出错,因为带引号的参数值、甚至 URI 引用本身都可能包含逗号。应使用符合 Web Linking 语法的解析器,保留重复字段并正确处理关系标记。如果代理把多条字段覆盖成最后一条,正文虽然仍然有效,下一页信息却可能已经丢失。此时应比较源站和代理后的原始头部。 ...

十二月 4, 2026 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 HTTP 客户端结果的判断。

结合传输与业务结果读取状态码

调用方需要的不只是三位数字,还要知道是否收到 HTTP 响应、正文是否完整到达,以及应用结果是否满足预期操作。这是三个不同的问题。二百响应可以按既定契约携带应用层失败,也可能在响应头之后发生传输中断;没有收到响应之前就连接失败,则根本没有 HTTP 状态。诊断和指标应保留这些区别,不能把它们压成一个过于简单的成功标志。 按实际取得的证据分类 HTTP 错误响应仍然是一份响应,其中可能包含有用的表示、认证挑战、请求标识或 Retry-After 字段。有些客户端库会为四开头和五开头状态抛出异常,但仍允许读取这些信息。处理时应先捕获具体 HTTP 异常,再处理更宽泛的传输异常;否则,一个带明确限流提示的四二九,就可能被归成和连接失败完全相同的通用网络错误,丢失下一步判断依据。 HTTP 交换成功也可能只代表业务信封成功传递。例如批处理或 RPC 契约可以返回二百,同时在正文里说明某个子操作失败。应读取文档定义的结构,而不是默认这种响应里的业务全部成功,也不能断言所有此类接口都违反 HTTP。监控需要验证自己承诺监控的结果;如果业务契约要求检查正文,那么只读状态码就没有完成相应检查。 坏掉的样子 在收到响应头时记录状态,可能掩盖随后出现的正文截断。Content-Length、流结束、解压与解析,都影响最后是否得到可用表示。应把已经收到的状态与未完成的传输分别报告,不能因为状态指标已经计入二百,就把只有半份内容的响应视为应用成功。收到响应头,是比完整收到并理解业务结果更早的一个验证点。 是否重试也不能只看第一位数字。四二九表示请求受到限流,可以附带 Retry-After;五零三也可能建议等待。该字段可以使用延迟秒数或 HTTP 日期,但两种形式都没有保证服务器到时一定恢复。客户端应结合服务器提示、有界重试策略、退避和操作语义,避免一遇到错误就立即重放。等待提示不是无限重试的授权,也不是恢复成功的证据。 非幂等请求的响应丢失,可能让业务效果处于未知状态:客户端失去联系之前,服务器也许已经提交。没有合适的幂等契约就重复操作,可能产生重复工作。另一方面,有些四开头错误会随着身份、状态或限流条件改变而变得可恢复。按照整个类别制定“总是重试”或“绝不重试”,会抹掉真正需要用于决策的信息,而不是简化正确的处理逻辑。 状态码本身也不能说明究竟是哪一个组件生成了响应。源站、网关和中间层都可能返回错误,归因时应结合请求标识与相应日志。curl 的 000 一类诊断值,不是注册的 HTTP 响应状态,通常表示没有取得可用的 HTTP 状态。把它当成某种服务器错误,会把连接阶段的问题错误地归到已经收到响应之后的处理阶段。 快速验证 将脚本保存为 status-client.py 并执行 python3 status-client.py。临时回环服务器提供五种响应情况;客户端使用真正的 urllib 响应对象和异常机制,并为实验关闭代理路由,不进行自动业务重试。另一个已经绑定但没有开始监听的回环端口用于产生拒绝连接,不需要访问外部主机,也不会依赖某个远端服务恰好离线。 from http.client import IncompleteRead from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from urllib.error import HTTPError, URLError from urllib.request import ProxyHandler, build_opener import json import socket class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/application-error': status, data, retry = 200, b'{"ok":false,"error":"demo"}', None elif self.path == '/busy': status, data, retry = 503, b'{"error":"unavailable"}', '3' elif self.path == '/limit': status, data, retry = 429, b'{"error":"limited"}', '2' else: status, data, retry = 404, b'{"error":"missing"}', None if self.path == '/truncated': self.send_response(200) self.send_header('Content-Length', '100') self.end_headers() self.wfile.write(b'partial') return self.send_response(status) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(data))) if retry: self.send_header('Retry-After', retry) self.end_headers() self.wfile.write(data) def log_message(self, *args): pass opener = build_opener(ProxyHandler({})) def inspect(url): try: try: response = opener.open(url, timeout=3) except HTTPError as error: response = error with response: result = {'http_status': response.code, 'retry_after': response.headers.get('Retry-After')} try: body = response.read() except IncompleteRead: return {**result, 'complete_body': False} return {**result, 'complete_body': True, 'payload': json.loads(body)} except URLError: return {'http_status': None, 'transport_failure': True} server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) thread = Thread(target=server.serve_forever) thread.start() try: origin = f'http://127.0.0.1:{server.server_port}' results = {path: inspect(origin + path) for path in ('/application-error', '/busy', '/limit', '/missing', '/truncated')} with socket.socket() as reserved: reserved.bind(('127.0.0.1', 0)) # Bound without listen: no HTTP server on this port. results['connection-refused'] = inspect(f'http://127.0.0.1:{reserved.getsockname()[1]}/') assert results['/application-error']['http_status'] == 200 assert results['/application-error']['payload']['ok'] is False assert results['/busy']['http_status'] == 503 and results['/busy']['retry_after'] == '3' assert results['/limit']['http_status'] == 429 and results['/limit']['retry_after'] == '2' assert results['/missing']['http_status'] == 404 assert results['/truncated'] == {'http_status': 200, 'retry_after': None, 'complete_body': False} assert results['connection-refused'] == {'http_status': None, 'transport_failure': True} print(json.dumps(results)) finally: server.shutdown() thread.join() server.server_close() 应用信封返回 HTTP 二百与 ok: false。繁忙和限流端点分别返回五零三、四二九,并保留 Retry-After 的三秒和两秒文本值。缺失端点仍被记录成 HTTP 四零四,而不是传输错误。这四份正文在本实验中均完整且可解析,因而能够进一步判断其业务字段;脚本没有实际等待这些延迟,也没有宣称等待后一定成功。 ...

十二月 3, 2026 · 2 分钟 · XB Field Notes
不可变资源与内容指纹标题卡旁边排列观察、决策与行动三个面板。

不可变资源与内容指纹

资源指纹把文件字节的摘要放进地址。样式表发生变化,构建就为新内容生成另一个地址,页面因此能够选择新版本,而不用等待所有旧缓存同时过期。这项方法依赖发布流程遵守承诺:某个地址一旦对外提供,就不能随意替换成与名称摘要不符的内容。文件名看起来像摘要,不代表服务器已经落实这种约束。 名称配合新鲜度契约 构建程序可以对生成文件计算完整的 SHA-256,再把页面中的逻辑名称替换成相应地址。同样的字节保留相同名称,变化的字节通常得到不同名称;摘要截断越短,碰撞风险越需要认真评估。浏览器不会因为名称含有一串十六进制字符,就自动计算文件摘要验证完整性。实际缓存键还受到请求上下文和 Vary 等因素影响,命名方法不能取代 HTTP 缓存规则。 公开指纹资源常用的响应头是 Cache-Control: public, max-age=31536000, immutable。前者给出一年新鲜期,后者承诺该表示在新鲜期内不会改变,因此 RFC 8246 建议客户端避免不必要的条件请求。这不是永久保存许可:缓存可能被淘汰,用户可以强制刷新,客户端也可能不支持扩展。规范还建议在 HTTPS 等经过认证的上下文之外忽略 immutable,不能把它当作内容可信性的替代品。 负责引用这些资源的 HTML 需要独立缓存策略,例如较短新鲜期或合适的重新验证,让用户能够发现新版本。发布顺序应先让新资源可用,再暴露引用它们的页面或清单。旧页面、旧标签页和回退版本仍可能使用旧地址,资源保留期限必须覆盖这些真实使用方式。仅有指纹,不能让整个多文件发布自动成为原子事务。 public 只适合允许共享的表示。某份个性化下载即使带有内容摘要,也不会因此变成可供共享缓存保存的公开资源。响应策略仍然必须服从身份鉴别与保密要求;摘要只是命名依据,不能充当授权或隔离用户数据的措施。 坏掉的样子 覆盖旧指纹路径会制造两套互相矛盾的事实:已有用户继续从缓存读取红色样式,新客户端却在同一个地址读到蓝色字节。清理一个 CDN 节点,也不能把所有浏览器里的私有副本同步删除。发布检查应核对真实文件字节和记录的摘要,在开放流量之前拒绝这种变更,而不是依赖上线后提醒用户刷新。 立即删除上一版资源会产生另一类故障。缓存中的页面还可能引用昨日脚本,回退后的 HTML 也可能依赖已经移除的文件。保留源码提交不等于保留可直接恢复的构建产物。即使所有摘要都正确,先发布页面、后上传文件仍会造成暂时的缺失资源;多节点传播期间也需要检查依赖是否已经就绪。 快速验证 把下面代码保存为 immutable.py,使用 Python 3 执行 python3 immutable.py。程序启动回环服务,检查样式字节的摘要和缓存响应头,故意改写旧地址背后的内容,确认摘要不匹配,然后恢复原文并关闭服务。预期三个契约检查标志为真,而 browser_cache_tested_by_cli 为假。这个命令读取了真实 HTTP 响应,但没有实现浏览器缓存,因此不能单凭命令结果宣称已经验证客户端复用行为。 ...

十一月 25, 2026 · 2 分钟 · XB Field Notes