接收时的两种估计为 12 秒与 7 秒,取较大值 12,再加 8 秒本地驻留,得到当前年龄 20 秒。

Age 头告诉我们缓存已存在多久

Age 是从源站生成或成功验证响应以来,已经经过多少秒的估计值,包含缓存驻留和网络传输时间。它不是对象的创建时间戳,也不是访问次数,更不只是最后一台代理在磁盘上保存副本的时长。要判断这个数字意味着什么,必须同时查看响应的新鲜度策略和实际请求上下文,不能把年龄直接等同于是否允许复用。 先取接收时两种估计的较大值,再加本地驻留时间。新鲜度是严格的年龄比较,并不授权复用。 合并估计时不能重复计时 RFC 9111 定义了接收响应时的两种年龄估计。表观年龄用接收缓存的时钟减去 Date,如果得到负数,就按零处理。校正年龄从收到的 Age 数值出发,再加上发出请求到收到响应之间的时间。缺少 Age 时,这个输入按零参与计算,但并不意味着应当丢掉基于 Date 的另一种估计。 ...

十一月 6, 2026 · 2 分钟 · XB Field Notes
精确 /api/echo 路由经过代理到达 /echo,另一条同网络路径绕过代理。

反向代理只对真正经过它的流量执行入口策略

反向代理接收客户端请求,再与应用进行独立的上游交换。配置相应行为后,它可以集中路由、传输、请求头和准入策略,却不会自动隐藏所有后端接口、认证所有调用者或终止 TLS。真正有用的边界,是流量实际经过哪里、在那里执行什么策略,而不是代理在架构图上画在应用前面。描述能力时应对应配置和真实调用路径。 坏掉的样子 如果后端还能通过另一条路径到达,只放在代理上的控制就可能被绕过。不发布容器端口减少了一种暴露途径,但连接到相同网络的其他工作负载仍可能访问应用。如果后端授权完全依赖“所有流量已经经过公开代理”这个假设,内部直连请求就可能让假设失效。需要检查真正可达的调用者,而不是只检查公网端口列表。 URI 映射可能产生更隐蔽的绕过。代理阻止 /admin,并不代表宽泛的 /api/ 映射不会通过 /api/admin 暴露同一个后端处理器。应审阅最终上游 URI,而不只看客户端可见路径。公开一个明确端点,与在方便的前缀下公开所有处理器是不同决定,不能期待两个外部名称不同就天然对应不同权限边界。 转发元数据需要明确处理。客户端可能发送伪造地址或协议头,代理把它继续转发,并不会让它自动可信。应用需要知道可信一跳设置哪些字段,以及哪些调用者可以直接访问自己。浏览器响应头也不能替代请求授权或网络可达性控制。检查响应头存在,只能证明对应字段被发送,不能推断其他安全责任已经完成。 精确反向代理路由:机制与证据边界。 快速验证 保存为 check-proxy-entry.py;需要 Python、本机 Docker 和预先加载的固定镜像。作者报告使用 nginx 1.28.0 与可丢弃回显应用;精确历史输出不可用,本轮没有重跑。显式检查在优化模式下仍执行。只有代理绑定宿主回环,实验只涉及 HTTP。Docker 调用/构建分别限时 30/120 秒,就绪探测期限更短。清理检查自有容器、标签及网络;删除或 daemon 完成状态无法确认时都会失败: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,ipaddress,json,socket,subprocess,time,uuid def require(condition, message): if not condition: raise AssertionError(message) uncertain_commands = [] NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True,timeout=30): try: result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout) except subprocess.TimeoutExpired as error: uncertain_commands.append(args) raise RuntimeError('Docker command timed out: '+args[0]) from error except KeyboardInterrupt: uncertain_commands.append(args) raise if check:require(result.returncode==0,result.stderr) return result def command(*args): return docker(*args).stdout.strip() def cleanup_owned(groups): # Inspect exact owned names; absence is not inferred from a failed inspect. errors = [] for kind, names, format_string in groups: try: listing = command(kind, "ls", *( ["--all"] if kind == "container" else [] ), "--format", format_string).splitlines() present = sorted(set(names) & set(listing)) if present: command(kind, "rm", *( ["--force"] if kind == "container" else [] ), *present) except Exception as error: errors.append(f"{kind} removal: {error}") try: remaining = command(kind, "ls", *( ["--all"] if kind == "container" else [] ), "--format", format_string).splitlines() if set(names) & set(remaining): errors.append(f"{kind} remains: {sorted(set(names) & set(remaining))}") except Exception as error: errors.append(f"{kind} inventory unverified: {error}") if uncertain_commands: errors.append("A Docker command timed out or was interrupted; daemon-side completion remains uncertain") require(not errors, "Cleanup not verified: " + "; ".join(errors)) print(json.dumps({"cleanup": "owned names absent in final inventories"}), flush=True) 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,extra_ports=()): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend',name+'-check',name+'-direct'] print(json.dumps({"owned_containers":containers,"owned_tags":tags,"owned_network":name}),flush=True) 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('image','inspect',NGINX) if backend is not None:docker('image','inspect',PYTHON) 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','--pull=false','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root),timeout=120) docker('run','--pull=never','-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,timeout=5).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--pull=false','--network','none','-t',tags[0],str(root),timeout=120) docker('run','--pull=never','--rm','--name',containers[2],'--network',name,tags[0],'nginx','-t') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','--pull=never','-d','--name',containers[0],'--network',name,*published,tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];require(binding['HostIp']=='127.0.0.1','non-loopback binding');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;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} yield containers[0],request finally: cleanup_owned((("container", containers, "{{.Names}}"), ("image", tags, "{{.Repository}}:{{.Tag}}"), ("network", (name,), "{{.Name}}"))) def config(locations,extra=''): return ("worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\n" "log_format peer escape=json '{\"id\":\"$request_id\",\"peer\":\"$remote_addr\"," "\"supplied_xff\":\"$http_x_forwarded_for\"}';\n" "access_log /tmp/peer.log peer;\n"+extra+ "\nserver { listen 8080; root /srv;\n"+locations+"\n}\n}\n") def observed_peer(log_text,request_id,forged): require(bool(request_id), 'missing request ID') rows=[json.loads(line) for line in log_text.splitlines() if line.strip()] matches=[row for row in rows if row.get('id')==request_id] if not matches:return None require(len(matches)==1, 'duplicate request ID') row=matches[0] require(row.get('supplied_xff')==forged, 'wrong request matched') peer=str(ipaddress.ip_address(row['peer'])) require(peer!=forged, 'test peer collided with forged control') return peer backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def respond(self,body=b''): data=json.dumps({'path':self.path,'method':self.command,'body':body.decode(), 'host':self.headers.get('Host'),'forwarded_for':self.headers.get('X-Forwarded-For'), 'forwarded_proto':self.headers.get('X-Forwarded-Proto')}).encode() self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data))) self.end_headers();self.wfile.write(data) def do_GET(self):self.respond() def do_POST(self):self.respond(self.rfile.read(int(self.headers['Content-Length']))) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location = /api/echo { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://backend:8081/echo; } location /admin { deny all; } location / { return 404; } ''' extra='client_max_body_size 1k; add_header X-Content-Type-Options nosniff always; add_header X-Lab-Request-Id $request_id always;' bad='events {} http { xbcatm_invalid_candidate; }' with lab(config(locations,extra),{'invalid.conf':bad},backend) as (proxy,request): status,h,b=request('/api/echo',method='POST',body=b'hello',headers={'Host':'edge.example', 'X-Forwarded-For':'192.0.2.123','X-Forwarded-Proto':'https'}) data=json.loads(b) require(status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST','route/body mismatch') peer_rows=[] def peer_ready(): peer=observed_peer(docker('exec',proxy,'cat','/tmp/peer.log',timeout=5).stdout, h.get('x-lab-request-id'), '192.0.2.123') if peer is not None:peer_rows.append(peer);return True return False wait(peer_ready) require(data['host']=='edge.example' and data['forwarded_for']==peer_rows[0] and data['forwarded_proto']=='http','forwarded-header mismatch') require(h['x-content-type-options']=='nosniff','success header missing') results={'proxied_request':data,'proxy_observed_peer':peer_rows[0]} status,h,_=request('/admin') require(status==403 and h['x-content-type-options']=='nosniff','admin rejection mismatch') results['admin_at_proxy']=status require(request('/api/echo',method='POST',body=b'x'*2048)[0]==413,'body limit mismatch') require(request('/unknown')[0]==404 and request('/api/admin')[0]==404,'route rejection mismatch') results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404 details=json.loads(docker('inspect',request.network+'-backend').stdout)[0] require(not details['HostConfig']['PortBindings'],'backend published to host') code=('import json,http.client; c=http.client.HTTPConnection("backend",8081,timeout=5); ' 'c.request("GET","/admin"); r=c.getresponse(); ' 'print(json.dumps({"status":r.status,"data":json.loads(r.read())})); c.close()') internal=docker('run','--pull=never','--rm','--name',request.network+'-direct', '--network',request.network,PYTHON,'python','-c',code) direct=json.loads(internal.stdout) require(direct['status']==200 and direct['data']['path']=='/admin','direct backend control failed') results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['data']['path'] require(docker('exec',proxy,'cat','/srv/invalid.conf').stdout==bad,'candidate bytes changed') docker('exec',proxy,'nginx','-t','-c','/etc/nginx/nginx.conf') probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False) diagnostic='nginx: [emerg] unknown directive "xbcatm_invalid_candidate" in /srv/invalid.conf:1' require(probe.returncode==1 and diagnostic in probe.stderr.splitlines() and 'nginx: configuration file /srv/invalid.conf test failed' in probe.stderr.splitlines(), 'expected candidate-specific nginx rejection not established: '+probe.stderr) results['invalid_candidate_diagnostic']=probe.stderr require(request('/api/echo')[0]==200,'live config unavailable after candidate check') results['invalid_candidate_rejected_live_config_survives']=True # Results are printed only after the context manager's checked cleanup returns. print(json.dumps(results,sort_keys=True)) 被中断或超时的命令即使随后遇到空清单,状态仍然不确定。清理失败会通过异常链保留原始异常。再次中断或进程突然死亡可能阻止清理完成;最终清单无法排除延迟完成的 daemon 操作或并发变化。 ...

十一月 5, 2026 · 4 分钟 · XB Field Notes
URL 内高亮查询令牌分支传播到日志、复制链接与地址记录;下方独立 URL 和请求头色条表示字段分离。

让可复用访问令牌远离请求地址

可复用的持有者访问令牌把约定访问能力交给持有它的人;发送者约束令牌还要求提供与发送者绑定的证明。把令牌放进地址,会让这项能力进入平时就会复制地址的系统,例如访问日志、监控记录、浏览器导航历史、书签和分享链接。具体暴露范围取决于请求及策略,但查询参数的名称看起来像秘密,并不会自动让它受到保护。传输位置本身就是凭据披露边界的一部分,应当结合它会经过哪些记录和分享环节判断。 对于 OAuth 持有者令牌请求,应采用 Authorization 请求头;RFC 9700 现已禁止客户端按照 RFC 6750 的查询方式,在 URI 查询参数中传递访问令牌。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。 分清减少暴露与绝对保密 某些访问日志只记录方法、请求目标和状态。在这种格式里,请求头令牌不会出现在目标字段中,而查询令牌会出现。但其他系统可能采集请求头,因此授权字段仍需在日志、链路和错误报告配置中被明确处理。应验证真实管线究竟保存了什么,不能假定请求头可以绕过全部日志层。检查时使用无效的合成值即可,不应为了演示而把真正凭据再次写入更多记录。 引用来源策略能够减少源地址向后续目标披露,却不会删除已经发给目的地的查询内容,也不会抹去浏览器记录或改写已复制的链接。同样,禁止缓存响应并不是要求清除全部访问日志和导航历史的通用指令。把令牌移到片段,会改变 HTTP 请求发送哪些内容,但值仍可被页面脚本和浏览器地址状态访问,也不是适合所有场景的凭据存储方案。 坏掉的样子 团队可能把接口认证从查询参数改成请求头,却让旧的带令牌地址继续留在报告和共享书签中。传输方式改变并不会撤销此前暴露,需要调查受影响的副本,并在系统支持时使已经暴露的可复用凭据失效。另一种误判是把有效期很短的链接称为泄露也无所谓;在有效期间,任何取得这项能力的人仍然可能使用它。缩短时间窗口只能减少风险,不能取消披露的后果。 当实际客户端流程需要时,签名地址可以是有意设计的、范围很窄的资源访问方式,但允许访问的资源、操作、期限和处理规则都应明确审阅。到期失效与只能使用一次并不相同,要阻止到期之前重复使用,需要真正强制执行消费状态的机制。简单的回调接口或浏览器导航,也不会自动构成把无限制可复用接口凭据放进查询的理由,应根据真实客户端选择合适的认证流程。 请求头位置减少 URL 暴露,但请求头仍须保护。签名资源 URL 是独立限定的设计,不是 OAuth 例外。运行与技术检查仍受阻。 快速验证 保存代码为 token-placement.py,执行 python3 token-placement.py --serve,打开打印出的页面。原文报告使用 Python 3.14.4 和 Chrome 152,本轮源码复核没有复现。显式 Python 检查在优化模式下仍会执行;全部演示值都是明确无效的固定标记,不能替换为真实凭据。回环服务只是观察实验,不是认证服务;它记录请求目标以及示例授权头是否存在,不记录该请求头的值。Python 已禁用代理发现并拒绝重定向,浏览器代理策略仍须单独控制。服务模式默认保留 120 秒,--serve-seconds 接受 1–300;控制键加 C 也会请求清理。只有单独的清理结果确认服务线程停止且监听关闭;有时限的 join 未能确认结束就会报错。临时事件只保存在进程内存,前面的检查通过不证明清理已经完成。 ...

十一月 1, 2026 · 3 分钟 · XB Field Notes
左侧 Alice 与 Bob 共用一个 IP 预算:Alice 的两次准入耗尽上限二,Bob 被拒绝。右侧按已验证账号分别计数,为 Bob 保留独立额度。两侧对比网络地址归组与业务主体归组。

限流需要一个稳定的客户端标识

限流键定义了哪些请求共用一份预算。账号、API 凭据、地址、租户和已认证会话是不同身份,不是“客户端”的几种可互换写法。选错归组方式,可能让无关用户相互挤占额度,也可能让同一份工作只改一个字段,就拿到新的使用机会。 图解使用原文已经完成认证及路由解析的测试请求,计数范围为一个测试窗口。稳定账号归组不证明按人公平,也不验证真实认证、可信代理、并发计数或分布式执行。历史有限范围执行仍为通过,本次没有重跑示例。 使用政策真正承诺的身份 如果规则是按账号限流,就应从认证结果中取得账号,而不是相信调用方提交的账号请求头或查询参数。仅仅看见一个凭据,并不能证明它有效;认证层必须先验证凭据、解析主体,再让账号级准入决定使用这个结果。限流键的可信程度不能高于它的来源。 按凭据限流也可以是合理的产品契约,但行为与按账号限流不同。轮换凭据或再创建一份凭据,可能得到新的凭据键,却仍属于同一个账号。应先决定这是否应当带来新额度,而不是等出现滥用或投诉后,才发现实现按的身份与产品描述并不一致。 一个账号不一定只对应一个人或一个应用,一个人也可以拥有多个账号。稳定账号键改善的是归组的一致性,并不证明公平,不阻止批量养号,也不限制整个服务的总需求。其他层级的限制应由明确需求支持,不能认为“已经按账号计数”就涵盖所有容量风险。 地址表示的是网络路径 企业 NAT、运营商网络或 VPN 后面的多个账号,可能共用一个公网地址;按 IP 的桶会把它们的流量合在一起。反过来,更换地址的调用方可能进入一个新桶。这两种结果并不说明地址解析错误,而是说明网络地址与业务主体本来就在表示不同对象。 没有已验证账号的公开接口或认证前入口,仍可以用地址级限制保护资源。但应按地址政策评估阈值,包括正常共享网络的流量,不能把它描述成精确的个人限制,也不能假设一个地址规则一定拦得住分散在许多网络上的抓取者。 地址在成为键之前需要语法规范化。同一个 IPv6 地址的不同合法写法,不应创建多份额度。是否按网段聚合,或把特定地址族形式视为同一个对象,是另外的政策选择,需要权衡误伤范围;规范化文本本身并不能替你回答这些业务问题。 转发身份必须有信任边界 随意提交的 Forwarded、X-Forwarded-For 或类似字段,不是客户端来源的证明。RFC 7239 明确指出,客户端和沿途节点都能改变转发信息。是否可信取决于直接连接的对端、配置的代理链,以及这条链如何移除或安全处理用户原先提交的字段。 NGINX 的真实 IP 模块需要明确配置可信来源地址和递归处理规则。“永远取转发字段里的第一个地址”不是适用于所有网络的正确方法。如果代理接收后原样转发了不可信声明,看似稳定的身份键就可能变成由调用者控制的新预算生成器。 ...

十月 29, 2026 · 2 分钟 · XB Field Notes
每行代表携带 /a、/b 的一条持久客户端连接。TCP 行把两者一起交给示意后端 A;HTTP 行分支让 /a 到 A、/b 到 B。A 仅示意一次 TCP 选择,并非所有连接都选 A。

传输层均衡保持整条连接,HTTP 路由可以拆分其中的请求

传输层均衡与 HTTP 均衡的实用区别,在于什么位置可以作出路由决定。TCP 转发可以为一条连接选择后端,然后不解释 HTTP 请求,直接转发字节流。HTTP 代理则解析请求,并能根据路径或请求头选择上游。因此,同一个客户端连接里的多个请求,可能获得不同路由结果。选择策略前,应先明确需要按连接还是按请求决定目标。 坏掉的样子 如果期待 TCP 连接选择还能分别路由内部每个 URL,请求就可能到达错误应用。字节流已经转发到一个后端后,后续 HTTP 路径不会自动触发重新选节点。反过来,如果把 HTTP 路由当成永久连接亲和,也可能让应用误以为同一客户端连接的每个请求都会到达同一份本地状态。两种假设都需要通过实际连接复用案例验证。 层级名称也不能完整说明 TLS 行为。TLS 终止与 HTTP 解释是不同能力。stream 代理可以终止 TLS,却不成为 HTTP 路径路由器;也可以不解密应用数据,只查看部分 ClientHello 元数据。按 SNI 选择目标,并不等于能看到加密 URL、请求头或响应正文。应描述实际配置行为,而不是认为产品标签已经证明所有这些性质。 性能结论同样需要谨慎。用户态 TCP 转发与内核包转发具有不同资源路径,即使两者都被称为传输层均衡。HTTP 代理会执行解析和策略工作,但相对吞吐取决于实现与负载。仅选择 L4 或 L7 这两个术语,不能推出通用的线速能力或固定额外开销;需要以真实路径和代表性流量测量,而不是用层级名称代替基准。 按连接或按请求选择后端。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 本轮复核没有重新运行这些 Docker 监听器;文中记录的路由结果仍属于此前观察。 ...

十月 28, 2026 · 3 分钟 · XB Field Notes
Go 实验的写计数在 GET 与 HEAD 后仍为零;两次 PUT new 后增至二;65 字节 PUT 被 64 字节上限拒绝后仍为二。

HTTP 方法在描述意图

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

十月 24, 2026 · 3 分钟 · XB Field Notes
原文前三次请求复用连接一;服务端关闭、显式空闲回收与禁用复用随后产生连接二至五。

HTTP/1.1 默认允许持久连接

持久连接允许多次请求和响应共享同一条连接,减少重复的 TCP 建立和 TLS 协商。但这是协议提供的能力,不是某个套接字永远可用的承诺。下一次请求能否真正复用,还取决于消息边界是否完整、客户端连接池策略、服务端行为和连接当前状态;单看请求成功或一次响应较快,都不足以证明走了复用路径。 默认允许,不代表永不关闭 HTTP/1.1 默认允许持久连接,客户端通常不需要发送 Connection: keep-alive 才能启用它。Connection: close 则表示当前交换之后不再继续使用这条连接。HTTP/1.0 的持久连接规则不同,不能把旧版本的协商方式复制到所有现代请求里,也不能把“没有 keep-alive 字段”直接判成配置错误。 复用首先需要明确的消息结束位置。准确的 Content-Length、分块编码,或无正文响应规则,都可以界定边界。若响应只能依靠关闭连接来表示结束,自然无法留下这条连接继续使用。接收方必须先消费完当前消息,再把后续字节解释成下一条;只收到 HTTP 200 状态,还不能证明响应正文已经完整到达。 已建立的 HTTPS 连接被复用时,不会重新进行 TLS 握手;使用缓存的 TLS 会话建立另一条连接,则仍然需要握手。TCP keepalive 又是另一回事,它的空闲探测不会刷新 HTTP 消息边界,也不能否定应用主动关闭连接的决定。HTTP/2 同样可以复用连接,但并不使用 HTTP/1.x 的 Connection 字段语义。 分别管理连接池与一次操作 空闲套接字仍占用描述符、缓冲区和实现相关的状态。Go 的 MaxIdleConnsPerHost 控制每个主机保留多少空闲连接,MaxConnsPerHost 则同时限制正在拨号、活动和空闲的连接总数。前者不是总并发上限。可复用连接池由共享的 Transport 持有,连续调用若各自建立无关的 transport,就会丢掉原本可以复用的连接资源。 ...

十月 24, 2026 · 3 分钟 · XB Field Notes
文章的新鲜响应样例均带 max-age=300。矩阵每格表示存储和直接复用:private 在私有缓存为是/是、共享缓存为否/否;public 都为是/是;no-cache 都为是/否;no-store 都为否/否。同一浏览器切换账户仍可能匹配旧的私有条目。

Cache-Control:private 还是 shared

存储范围不是身份系统。Cache-Control: private 要求遵守协议的共享缓存不存储这条响应,却不会教会浏览器识别正文属于哪个应用账户。当一个人退出登录,另一个账户在同一浏览器中登录,然后访问相同网址时,这个区别就会变成实际的数据隔离问题,不能只凭响应头里出现 private 就判定安全。 文章的新鲜响应样例将存储资格与无需验证即可复用分开。另一条账户边界说明,私有缓存仍可能匹配旧账户响应。请求字段匹配不能证明当前授权,本图不新增运行时证据。 有资格被缓存不代表能共享 不带字段名参数的 private 允许单用户缓存保存响应,但仍受其他缓存条件约束。浏览器是常见实现,并不是唯一实现。public 则明确允许某些原本可能受到限制的响应进入缓存,例如满足条件的带授权请求响应。已经符合缓存条件的普通响应不一定需要 public;出现 public 也不意味着所有缓存都必须保存它,存储许可和实际是否存储是不同事实。 不带参数的 no-cache 与 no-store 回答的是另一个问题。前者允许存储,但每次复用前必须成功验证;后者禁止私有和共享 HTTP 缓存存储及随后复用该响应。它们都不是加密、访问控制检查,也不承诺清除应用日志或用户之前保存的副本。本文讨论不带字段名参数的形式,不把实现支持较弱的按字段限定形式混进实验。 新鲜度还需要单独判断。错误的个人响应如果按共享键保存,即使只给五秒钟有效期,也可能有五秒钟把数据交给另一个人。缩短 max-age 不能修复身份边界。反过来,s-maxage 只是控制共享缓存的新鲜度,并不是 private 的另一种写法。先决定响应可在哪一层保存,再讨论可直接复用多久,才能避免用性能参数代替安全条件。 ...

十月 23, 2026 · 2 分钟 · XB Field Notes