验证器匹配可以省去正文传输,但不变的文件元数据也可能掩盖已经变化的字节。

nginx 验证器是否可靠,取决于表示身份是否准确

验证器让接收方询问:自己保存的表示,是否仍与当前选中的表示相符。条件 GET 验证成功时,可以返回没有正文的 304,避免重新传输内容。新鲜度是另一个决定,它控制已存响应何时能直接复用而不发送这次请求。ETag 本身不指定缓存寿命,304 也不是一份替代正文;客户端需要保留原响应,才能利用成功验证的结果。 坏掉的样子 把所有 ETag 都叫作正文哈希,会掩盖 nginx 的一个重要实现细节。在这里测试的 1.28.0 静态文件实现中,生成的标签组合了修改时间和内容长度,并不计算文件内容哈希。如果替换字节时保留这两个输入,就可能保留同一个标签。于是,持有不同旧字节的客户端仍收到看似成功的验证,错误不能仅靠“响应里有 ETag”来排除。 强弱验证器适用于不同比较。对于需要字节身份的用途,强验证器必须能区分表示数据变化;弱验证器则可以把字节不同、但被认为语义等价的表示归在一起。W/ 并不是“接受字节等价变化”的标志,也不意味着这个标签适用于所有前提条件或字节范围续传。使用哪种比较,需要依据具体请求条件,而不是凭标签外观猜测。 条件请求不保证省掉应用计算。服务器可能先生成表示,之后才判断验证器是否匹配。这个静态 nginx 示例根本没有应用渲染器。较小的响应在这里证明节省了正文传输,却不能证明数据库查询、渲染成本或回源请求数必然减少。评估优化效果时,应把实际减少的数据传输和实际减少的后台工作分别测量。 快速验证 把脚本保存为 check-validators.py,先取得固定摘要的 nginx 镜像,再用 Python 和 Docker 运行。它通过宿主回环提供一个可丢弃的四字节静态文件,实测 nginx 版本为 1.28.0。实验只控制容器内文件的时间属性,不修改宿主时间,也不修改共享 Docker 虚拟机时钟: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' files={'asset.txt':'AAAA','noetag.txt':'AAAA'} with lab(config('location = /noetag.txt { etag off; }'),files) as (proxy,request): def timestamp(value):docker('exec',proxy,'touch','-m','-d','@'+str(value),'/srv/asset.txt') timestamp(1577836800) status,headers,body=request('/asset.txt');assert status==200 and body==b'AAAA' tag=headers['etag'];modified=headers['last-modified'];results={'etag':tag,'last_modified':modified} cases={ 'matching_tag':({'If-None-Match':tag},304), 'weak_comparison':({'If-None-Match':'W/'+tag},304), 'matching_date':({'If-Modified-Since':modified},304), 'tag_takes_precedence':({'If-None-Match':'"different"','If-Modified-Since':modified},200), 'weak_if_match_rejected':({'If-Match':'W/'+tag},412), } for name,(conditions,expected) in cases.items(): status,_,body=request('/asset.txt',headers=conditions);assert status==expected,(name,status) if status==304:assert body==b'' results[name]=status docker('exec',proxy,'sh','-c','printf BBBB > /srv/asset.txt');timestamp(1577836800) status,current,body=request('/asset.txt');assert status==200 and body==b'BBBB' and current['etag']==tag status,_,body=request('/asset.txt',headers={'If-None-Match':tag});assert status==304 and body==b'' results['changed_bytes_preserved_metadata']=status timestamp(1577836802) status,current,body=request('/asset.txt',headers={'If-None-Match':tag}) assert status==200 and body==b'BBBB' and current['etag']!=tag and current['last-modified']!=modified results['changed_metadata']={'status':status,'etag':current['etag']} status,current,_=request('/noetag.txt');assert status==200 and 'etag' not in current print(json.dumps(results,sort_keys=True)) 最初文件内容是 AAAA,修改时间固定为 2020-01-01,产生 ETag "5e0be100-4"。GET 在 If-None-Match 中携带这个标签时,得到正文为空的 304。把请求标签加上 W/ 仍返回 304,因为该条件采用弱比较。在本次配置中,携带相等 If-Modified-Since 日期,也会得到 304。这些结果展示标签与日期两条验证路径。 ...

二月 17, 2028 · 2 分钟 · XB Field Notes
传输协议版本和响应内容编码是两项需要分别检查的事实。

gzip、HTTP/2 与 Brotli 需要分别验证

HTTP/2 改变 HTTP 传输分帧和多路复用方式;gzip 与 Brotli 编码的是表示数据。启用 HTTP/2 不会自动压缩响应正文,启用 gzip 也不能证明客户端协商出了 HTTP/2。HTTP/2 的头部压缩又是另一种机制。可靠检查应分别记录协商协议、响应 Content-Encoding、传输正文字节数,以及实际解码后得到的内容,不能用其中一项代替其他结果。 坏掉的样子 发送 Accept-Encoding: gzip, br,然后把结果保存成 .br,并不能证明使用了 Brotli。服务器可能选择 gzip,也可能发送仍被请求接受的原始表示。真实编码由响应头说明。根据请求列表或输出文件名猜测,会导致使用错误解码器,也可能让报告声称获得了实际上根本没有发生的压缩结果。文件名只是本地命名,不是协议证据。 Brotli 支持还属于部署能力,并不是安装 nginx 后自然具备的功能。Brotli 过滤和预压缩文件处理由独立模块提供,需要针对实际二进制与配置确认可用性、构建兼容性。添加不受支持的指令可能让配置验证失败,而客户端声明接受 Brotli 不会替服务器安装功能。检查模块时,应以当前程序行为为准,不能从另一台机器的配置推断。 客户端自动解码会制造另一种测量陷阱。curl --compressed 收到较小编码正文后,可以保存成较大的解码文件。把这个文件大小与原始压缩输出比较,实际比较的是不同对象。反过来,只使用 --http2 却不检查协商结果,也可能把允许的协议回落误当成 HTTP/2 已经成功验证。命令选项表达请求意图,结果字段才说明本次发生了什么。 快速验证 将脚本保存为 check-h2-coding.py,准备 Docker、Python、OpenSSL 和支持 HTTP/2 的 curl 后运行。记录客户端为带 nghttp2 的 curl 8.7.1,服务器为 Linux arm64 上的 nginx 1.28.0。脚本生成短期本地证书,并通过 --cacert 显式信任该证书,只发布宿主回环端口,不需要连接真实公网服务: ...

一月 28, 2028 · 3 分钟 · XB Field Notes
客户端跳转产生另一次请求,内部重写则改变当前请求里的处理路径。

先决定让 nginx 跳转客户端,还是在内部重写

nginx 跳转与内部重写改变请求流程的不同部分。跳转返回状态和 Location,由客户端决定是否发起新请求;内部重写则在处理现有请求时改变 nginx 当前 URI。两者的选择决定客户端是否看到新地址、location 是否重新搜索,以及最终处理器收到怎样的路径和查询。不能因为都能让某个 URL 得到内容,就把它们当成同一种操作。 坏掉的样子 本来需要内部映射,却改成跳转,会增加客户端往返并暴露新 URL;本来需要公开永久迁移,却只做内部重写,客户端仍会继续使用旧地址。return 和 rewrite 也不能被当成总是默认相同跳转行为的通用缩写。明确状态码与重写标志,才能让预期转换可审阅,避免一行看似简单的配置隐藏行为差异。 状态选择影响方法语义和缓存。307、308 在自动跳转时保留请求方法,301、302 则允许历史上的 POST 改为 GET。永久不意味着每个客户端立刻更新所有已保存链接,临时也不意味着响应绝对不能缓存。缓存指令、请求上下文和客户端行为仍然重要,不能用状态名称替代明确的复用策略。 内部重写还可能选择错误处理器,或反复匹配自己。last 会为改变后的 URI 开始新 location 搜索;break 结束当前重写序列后,继续在现有 location 处理。查询参数可以按替换语法追加或丢弃。把这些区别视为可以互换,可能导致读取错误文件、重复参数,或进入返回 HTTP 500 的循环。 快速验证 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建并检查可丢弃配置,只发布回环端口,不自动跟随跳转地验证真实响应,最后清理自身资源。一个小型静态文件让 break 所用处理器可以与目标 location 清楚区分: ...

一月 21, 2028 · 2 分钟 · XB Field Notes
一个规定响应能否被缓存、缓存多久、为谁缓存的状态头。

Cache-Control 是缓存与源站之间的约定

缓存策略需要行为测试,而不只是检查头部是否存在。两个响应即使都带 max-age,也可能因为其中一个要求验证、另一个禁止存储而表现不同。请求本身同样影响结果:浏览器可以绕过已有副本,却不修改源站的响应策略,也不删除更早保存的缓存条目。要知道用户真正拿到什么,必须观察完整的重复请求过程。 双方都要读同一份约定 Cache-Control 指令具有方向性。请求指令不会自动复制到响应里,RFC 9111 还明确将请求指令描述为建议性的要求;响应指令则约束遵守协议的缓存。中间层即使自己不实现缓存,也必须在转发中保留相应缓存指令。部署配置可能另有覆盖规则,但“代理按自己的配置做了”不等于已经证明源站声明的策略得到执行。 应拆开三个问题:这条响应能否存储,当前年龄是否仍在适用的新鲜度寿命内,这次请求是否允许不经验证直接复用。no-store 禁止 HTTP 缓存存储;no-cache 允许存储,但复用前必须成功验证。max-age 设定新鲜度,must-revalidate 则禁止响应过期后未经验证就复用。响应仍然新鲜时,must-revalidate 本身并不要求每次回源,不能把它误解成 no-cache。 private 将存储限制到单用户缓存,public 在其他条件允许时明确授予缓存许可,并不是每一条可缓存响应都必须写 public。s-maxage 覆盖共享缓存的新鲜度,不适用于浏览器私有缓存。Vary 则提供请求匹配维度。这些机制都不会识别应用当前登录的是哪个账户,也不会因为有效期很短,就自动让个人数据变得适合共享。 坏掉的样子 测试时每次添加新的查询参数,会改变缓存键,可能完全掩盖原本的复用行为。在开发者工具中禁用缓存,也可能造成相同误判;某些自动化请求拦截机制同样会禁用 HTTP 缓存。有效测试应在一个隔离上下文中重复访问完全相同的网址,一边统计源站实际收到的请求,一边断言返回正文,而不是仅凭页面看起来没变化就判断命中。 Fetch 的 cache 选项属于浏览器接口,不是响应头。它的 no-store 模式跳过缓存查找,也不把新响应写回 HTTP 缓存;reload 跳过查找,但可以更新缓存。两者都不等于抹除全部旧副本。源站修改响应头,也不能迫使尚未过期的条目立刻回源。浏览器历史恢复、Service Worker 自行保存的内容又是其他机制,不属于下面实验的覆盖范围。 no-cache 也不保证获得 304。只有存在合适的验证器,并且所选表示仍然有效,才有机会节省正文传输。没有 ETag 或 Last-Modified 时,每次访问可能仍返回完整的 200 响应。因此要同时测量回源次数和传输正文,不能看到“每次都请求了源站”就认为重验证一定已经发挥性能收益。 ...

十二月 20, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调建立隧道之后仍要验证目标端点。

CONNECT 方法打开一条裸隧道

CONNECT 请求代理建立隧道,并不让代理从数据路径上消失。准入成功之后,代理仍然向两个方向转发字节,并管理套接字、缓冲区和故障处理。CONNECT 本身不提供加密;HTTPS 是在这条隧道内部另行建立并验证 TLS 会话,不能把两个阶段的成功混为一谈。 在正确的边界切换语义 HTTP/1.1 的 CONNECT 目标是 server.example:443 这样的主机与端口组合,而不是普通路径或完整 HTTPS URL。端口必须明确给出。成功的 2xx 响应在响应头结束后立即切换到隧道模式;非成功响应则表示隧道尚未建立,此时不能直接开始把后续内容当作目标服务的数据。 成功的 CONNECT 响应不能携带 Content-Length 或 Transfer-Encoding。随后出现的字节属于隧道,而不是一份用长度界定的 HTTP 响应正文。自己实现中继时,这一点尤其重要:HTTP 解析器已经缓冲的字节也可能属于隧道,不能因为切换到原始套接字,就把此前预读的数据丢掉。 客户端随后可以穿过中继,与目标协商 TLS。代理返回 200 只说明允许并建立了隧道,不负责证明目标证书有效,也不证明应用请求成功。客户端仍须检查信任链、主机名与应用响应。TLS 拦截属于另一种部署方式,需要客户端信任会终止 TLS 的中间实体,不能把它与普通盲转发混为同一种安全模型。 看不见正文,仍然占用资源 盲中继不必解释加密的应用内容,但仍能观察目标地址、字节量、时序和关闭行为。它可以落实连接预算、超时、流量限制,以及撤销准入之后的断开操作。无法读取 TLS 内部的 HTTP 消息,不等于失去所有运营控制,更不等于不再承担带宽和描述符成本。 真正的正向代理应验证调用方身份,并限制目的地。任意主机和端口访问,可能暴露内部服务,或被用于其他协议的滥用。按主机名放行时,还必须考虑地址解析策略:通过名称检查,并不自动证明最终拨号的 IP 地址符合约束。本地实验只允许一个预先确定的环回地址与端口,因此没有引入任意目标的解析和转发能力。 ...

十二月 13, 2027 · 3 分钟 · XB Field Notes
告诉缓存一份已存应答何时还算新鲜的 HTTP 头。

缓存的新鲜度要对着时钟判断

响应经过另一个缓存,不代表自动获得一份全新的新鲜度额度。缓存必须计入交付路径上已经积累的年龄,再增加自己的本地停留时间。否则,每一层看起来都只缓存六十秒,串在一起却可能把旧内容直接复用远超源站预期的时间。判断新鲜度需要明确时间来源,不能只把配置中的秒数当成最终交付保证。 先算清年龄 Age 估计从响应生成或成功验证以来经过的秒数,其中包括缓存停留与网络传输。收到的 Age 是计算当前年龄的输入,不是在本地存放期间保持不变的计数器。Date 提供响应生成时间;把这些信息联系起来时,时钟同步和实际测得的请求、响应时刻都可能影响结果。只截取一组响应头,不能看到它随后在缓存里又停留了多久。 RFC 9111 给出两条年龄估计路径:接收时刻减去 Date,负数归零,得到表观年龄;收到的 Age 加响应延迟,得到校正年龄值。保守算法取两者较大值,再加本地停留时间。例如请求时刻为一百秒,接收为一百零二秒,Date 为九十五秒,收到的 Age 为十秒,当前时刻为一百零七秒,表观年龄是七秒,校正值是十二秒,当前年龄最终为十七秒。只读原始 Age 就漏算了七秒。 新鲜度寿命也有优先顺序。共享缓存存在 s-maxage 时先用它,否则 max-age 优先于 Expires 减 Date;私有缓存忽略 s-maxage。没有显式过期信息时,在允许的条件下可能使用启发式寿命,但并不存在统一固定时长。只有适用的新鲜度寿命严格大于当前年龄,响应才是新鲜的;恰好相等时已经到达陈旧边界,而不是再多送一次的许可。 坏掉的样子 新鲜不代表适合任意请求。存储许可、网址与 Vary 匹配、验证要求仍然有效。即使寿命设得很长,no-cache 也要求复用前验证。ETag 是验证器,不是免于询问源站的许可。成功重验证可以更新保存的元数据而不重新传正文,但仅仅从另一个缓存收到响应,不能把年龄清零,否则每经过一层就会错误地延长直接复用期限。 提供陈旧响应需要另外的许可,不能把源站故障当成自动授权。must-revalidate 禁止过期后未经成功验证就复用,即使网络断开也不例外。stale-while-revalidate 可以允许在有界的陈旧窗口内一边返回旧内容、一边验证,但仍受适用限制约束,也不会把旧内容重新变成新鲜内容。RFC 9111 已废弃 Warning 字段,因此没有旧式 Warning: 110 不能作为新鲜度证据。 ...

十二月 3, 2027 · 2 分钟 · XB Field Notes
有界令牌额度按经过时间回填,只允许成本能够被当前额度支付的工作。

令牌桶平滑请求速率

令牌桶限制获准进入的工作量,同时允许积累的额度在短时间内集中使用。它不会自动把请求均匀隔开,不保证成功完成的吞吐,也不限制仍在执行的操作数量;这些分别属于调度和容量问题。桶的两个参数描述的是预算,不是服务一定能够履行的处理能力承诺。 先补充额度,再扣除工作成本 设桶容量 B 为最多能保存的令牌数,回填速率 r 为每秒新增令牌数。每次请求到来,先将额度更新为 min(B, 上次额度 + r × 经过时间)。只有工作成本不超过当前额度才准入,然后扣除成本。本文采用的严格规则下,被拒绝的工作不消耗令牌。 这种计算可以在请求到达时按需进行,不需要后台定时器不断实际投放令牌。长时间空闲也不能积累无限额度,因为容量会截断回填结果。初始额度是一项策略选择:本例从满桶开始,因此允许立即突发;从空桶开始则会得到不同的初始行为,不能漏写这个条件。 突发额度不是峰值速率 对于这个理想严格模型,在长度为 t 的时间区间内,获准工作的总成本不超过 B + r × t。时间越长,突发项的相对影响越小。这不意味着每个一秒窗口都最多允许 r 个请求,也没有额外给出一个独立的瞬时峰值速率上限。 例如容量为三、每秒回填二时,三个单位成本请求可以在同一时刻全部通过,第四个被拒绝;半秒之后又有一枚令牌可用。如果已获准的工作必须均匀离开入口,还需要实际调度器、有界等待队列或其他节奏控制,不能指望令牌桶自动将同时到达的请求排成匀速流。 准入速率也不等于执行并发量。慢请求尚未结束时,令牌仍会继续回填,允许更多工作进入。需要通过合适的并发限制和超时策略保护工作线程、数据库等稀缺资源。同样,已经准入的请求仍可能在下游失败,所以回填速率不能被写成保证成功完成的吞吐。 预算单位和作用范围要一致 一枚令牌不一定对应一个请求,也可以用加权成本代表字节或预计工作量,但容量与回填速率必须使用相同单位。一个成本超过 B 的请求,无论等待多久都装不进这个严格模型的桶。应明确处理这种情况,而不是给出一个实际上永远不能兑现的重试时间。 还要明确预算按谁区分、由谁持有。四个互相独立的满桶,每个保存三枚令牌,可以同时允许十二个请求;它们不是一个全局三令牌桶。共享限流器必须把回填、比较和扣减协调成一次原子决定,仅共享一个计数器而分开执行这些步骤,并不足以保证配额正确。 本地回填应依据经过时间的时钟,不能让墙上时钟校准凭空增加额度,或使记账时间倒退。Python 的单调时钟适合比较两次读取的差值,但其参考原点不是可以在无关机器之间交换的通用时间戳。分布式时钟和状态归属仍需要自己的明确契约。 坏掉的样子 即使长期速率看起来保守,满桶释放的一次突发仍可能压垮脆弱的下游。无界等待队列会把超额需求转化为内存增长与延迟;直接拒绝和延迟整形是两种不同结果,接口应说明调用者会得到哪一种,不能把所有行为都笼统叫作“平滑”。 应一起观察准入成本、拒绝成本、排队、并发量和下游延迟。重试需要遵守限流策略,而不是立即重新制造被拒绝的负载。按尝试工作计费还是按完成工作计费,也是需要明确的选择;失败后退还令牌会改变模型,并引入额外记账要求,不能悄悄当成同一套公式。 托管产品的限制还可能弱于理想算法。Amazon API Gateway 明确说明限流和配额是尽力而为的目标,可能被超过。因此,不能把这里的数学上界宣传成该服务已经验证的硬保证、费用上限,或者任何未经测试部署的严格承诺。 快速验证 保存为 bucket_check.py,用 Python 3.10 以上版本执行 python3 bucket_check.py。本例已在 Python 3.14.4 上运行。标准库 Fraction 与注入的非递减时间戳让边界计算保持精确,不依赖真实等待。这是单线程准入模型,不是网络限流服务或分布式存储实现。 ...

十二月 1, 2027 · 2 分钟 · XB Field Notes
描述客户端愿意接收哪些媒体类型的一系列 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