HTTP 语义、状态码与协商:让请求在代理链上仍保持原意
HTTP 最难的部分不是记住状态码,而是让客户端、缓存、网关、源站和重试器对同一个请求形成一致判断。一个返回 200 的错误对象、一个用 GET 修改数据的接口、一个缺少 Vary 的多语言响应,都能在直连测试中工作,却会在代理链上破坏缓存、重试和安全策略。RFC 9110 定义的是跨实现可理解的语义;HTTP/1.1、HTTP/2、HTTP/3 只是承载这些语义的不同消息机制。
请求指向资源,响应携带表示
资源不是数据库中的一行,也不是某个 JSON 文件,而是由 URI 标识的概念。服务器在某一时刻选择它的表示:JSON、HTML、图片、压缩字节或语言版本。状态码描述本次请求的结果,Header 携带表示元数据与控制信息,内容才承载选中的表示或错误细节。
成功与失败必须由状态码先表达。若余额不足仍返回 200,网关监控会把失败计入成功率,缓存可能保存错误体,通用客户端也无法按类别处理。4xx 表示请求侧条件不满足,5xx 表示服务器未能履行请求;业务错误码可以细分原因,但不能取代 HTTP 层类别。
重定向也不是一类相同动作。永久与临时、是否允许方法改写,决定客户端能否保持原请求语义。设计写请求跳转时要明确使用能保留方法与内容的状态,而不能依赖客户端对历史 302 行为的偶然兼容。
safe、idempotent 与 cacheable 是三条不同轴
安全方法表示客户端不请求改变服务器状态;幂等表示同一意图执行一次或多次,预期效果相同;可缓存表示响应在规则允许时能被复用。GET 通常安全、幂等且可缓存,PUT 通常不安全但幂等,POST 通常三者都不能默认推断。日志增长、计费指标等附带效果不要求完全相同,幂等约束的是用户请求的预期效果。
幂等并不意味着客户端应无限重试。RFC 9110 明确限制自动重试非幂等请求,除非应用能证明其语义幂等,或客户端知道原请求未被应用。连接在写入后复位时,客户端通常不知道服务端是否提交,所以必须依靠业务幂等键或状态查询,而不是看到异常就重发。
RFC 10008 定义 QUERY,用于请求内容参与查询且仍需表达安全、幂等语义的场景。它解决的是方法语义表达,不保证现有代理、网关和客户端已完整支持;采用前必须验证整条链路,不能只看源站框架能否路由。
条件请求把并发控制放进协议
强 ETag 标识字节级等价表示。客户端先 GET 得到 ETag: "v1",更新时发送 If-Match: "v1";若资源已变为 "v2",服务端返回 412 Precondition Failed,从而阻止丢失更新。读取缓存则用 If-None-Match:仍相同返回 304 Not Modified,客户端复用已有内容。
examples/backend-development/network-web/http-semantics/HttpConditionalDemo.java 在本地源站演示 200 → 304 → 412 → 204;HttpMethodSemanticsDemo.java 对比重复 POST 的副作用与重复 PUT 的稳定最终值:
javac --release 17 --add-modules jdk.httpserver -Xlint:all -Werror examples/backend-development/network-web/http-semantics/HttpConditionalDemo.java examples/backend-development/network-web/http-semantics/HttpMethodSemanticsDemo.java
java --add-modules jdk.httpserver -cp examples/backend-development/network-web/http-semantics HttpConditionalDemo
java --add-modules jdk.httpserver -cp examples/backend-development/network-web/http-semantics HttpMethodSemanticsDemoget=200 notModified=304 staleWrite=412 updated=204 version="v2"
postEffects=2 putValue=42 repeatedPutStable=true条件写入必须原子地比较版本并提交,不能先查版本、再在另一个事务更新,否则检查与写入之间仍有竞态。ETag 也不能泄露敏感内部版本或使用可预测标识引出跨租户信息;可以由规范化内容、版本号与秘密盐生成不透明值。
时间型验证器 Last-Modified/If-Modified-Since 精度有限,多个写入落在同一时间粒度时可能无法区分;强一致写冲突更适合强 ETag。弱 ETag 可用于语义等价但字节不完全相同的缓存验证,不适合需要字节范围精确性的场景。
缓存保存的不只是响应体
RFC 9111 规定 HTTP 缓存行为。新鲜度由显式指令与时间计算决定,验证器负责过期后的再验证。no-cache 不是“不存储”,而是复用前必须验证;敏感内容真正禁止保存应使用 no-store,并同时审计 CDN、浏览器和应用缓存层。
内容协商使同一 URI 根据 Accept、Accept-Language、Accept-Encoding 等选择表示。只要响应会随请求字段变化,共享缓存就需要正确的 Vary。源站按 Origin 返回不同 CORS 响应却漏掉 Vary: Origin,可能把租户 A 的许可响应交给租户 B。Vary: * 又会基本破坏复用,因此协商维度要少而稳定。
缓存键必须包含真正影响表示的维度,但不能把未经规范化的任意 Header 全塞进去制造缓存碎片。网关改写路径、Host 或查询参数后,源站与缓存对“同一资源”的判断也必须一致。涉及授权时,默认假设响应不可共享,只有在权限模型明确且缓存键包含安全上下文时才开放共享缓存。
消息边界是安全边界
HTTP/1.1 依赖明确的消息长度与连接规则。RFC 9112 对消息解析和错误处理作出约束。若前置代理与后端对 Content-Length、Transfer-Encoding 或非法空白的解释不同,攻击者便可能制造请求走私:代理认为第一个请求结束,后端却把残余字节解释为第二个请求。
防御核心不是堆一个 WAF 规则,而是让整条链使用一致、严格的解析:拒绝冲突长度字段;拒绝歧义的 transfer coding;限制 Header 数量、单项大小和总大小;规范化之前不做授权;代理转发时重新生成明确边界;升级协议时清除不再适用的 hop-by-hop Header。HTTP/2/3 使用帧边界,不代表与 HTTP/1.1 网关互转时自动免疫走私。
契约要能被机器验证
每个端点至少明确方法属性、成功与错误状态、请求和响应媒体类型、最大内容长度、缓存策略、条件请求、授权范围、超时与幂等策略。错误响应应使用稳定媒体类型和字段,例如 type、title、status、detail、instance 与可机器处理的业务原因,避免客户端解析自然语言。
监控不能只按 URL 统计平均耗时。应按规范化路由、方法、状态类别、缓存命中/验证、协商结果、请求/响应大小和重试次数分解;原始查询参数、Authorization、Cookie 和个人数据不能成为高基数标签。对 412 的增长要区分真实并发冲突与客户端长期持有旧版本,对 304 则同时观察节省字节和再验证延迟。
HTTP 契约真正的强度,体现在中间层可以根据标准语义安全地缓存、重试、拒绝和观测,而不是每个调用方都知道某个接口的私下约定。
Range、压缩与表示版本必须一致
范围请求允许客户端只取表示的一部分,但字节范围依赖一个确定版本。下载中断后若直接请求剩余 Range,却不携带 If-Range 或等价版本条件,源站对象已经改变时,客户端可能拼接两个版本。服务端要返回正确的 206 与 Content-Range,无法满足时返回 416;客户端完成后还应验证长度或内容摘要。
压缩发生在表示传输层。缓存键若未包含 Accept-Encoding,可能把 gzip 字节交给不支持的客户端;反向代理解压后又保留原 Content-Length,则破坏消息边界。ETag 是针对选定表示还是底层未压缩资源,也要在架构中统一,否则不同层生成的验证器会互相否定。
API 演进不能依赖“客户端应该同步升级”
新增可选响应字段通常向后兼容,删除字段、改变类型或收紧枚举则可能破坏旧客户端。请求端新增必填字段会让旧调用失败;响应端把未知枚举值视为崩溃条件,会阻止服务端扩展。契约测试应覆盖旧客户端对新服务端、新客户端对旧服务端,并验证网关是否保留未知 Header、查询参数和状态码。
版本可放在媒体类型、Header、路径或资源模型中,但任何方式都要回答缓存键、文档发现、弃用信号、并行维护和最终下线。不要为每个字段变化复制整套 /v2;也不要用一个永不版本化的 JSON 默许破坏性修改。弃用应有调用方清单、流量证据和明确退出条件。
限流状态也属于协议契约
过载时源站或网关应返回能够区分限流、临时不可用和永久拒绝的状态,并在可判断时提供恢复提示。客户端不能把所有 429、502、503、504 统一立即重试:502 可能来自代理握手失败,504 表示代理等待上游超时,原业务结果仍可能未知。错误响应要保留 trace 关联与重试安全信息,同时避免泄露内部拓扑。
验证消息语义时,除了正常样例,还要送入重复长度字段、冲突 framing、超大 Header、未知方法、无内容却带内容、条件竞争和缓存变体。目标不是让框架“尽量解析”,而是让所有入口对歧义请求一致拒绝,避免前后端解析差异成为攻击通道。
