调用方需要的不只是三位数字,还要知道是否收到 HTTP 响应、正文是否完整到达,以及应用结果是否满足预期操作。这是三个不同的问题。二百响应可以按既定契约携带应用层失败,也可能在响应头之后发生传输中断;没有收到响应之前就连接失败,则根本没有 HTTP 状态。诊断和指标应保留这些区别,不能把它们压成一个过于简单的成功标志。
按实际取得的证据分类
HTTP 错误响应仍然是一份响应,其中可能包含有用的表示、认证挑战、请求标识或 Retry-After 字段。有些客户端库会为四开头和五开头状态抛出异常,但仍允许读取这些信息。处理时应先捕获具体 HTTP 异常,再处理更宽泛的传输异常;否则,一个带明确限流提示的四二九,就可能被归成和连接失败完全相同的通用网络错误,丢失下一步判断依据。
HTTP 交换成功也可能只代表业务信封成功传递。例如批处理或 RPC 契约可以返回二百,同时在正文里说明某个子操作失败。应读取文档定义的结构,而不是默认这种响应里的业务全部成功,也不能断言所有此类接口都违反 HTTP。监控需要验证自己承诺监控的结果;如果业务契约要求检查正文,那么只读状态码就没有完成相应检查。
坏掉的样子
在收到响应头时记录状态,可能掩盖随后出现的正文截断。Content-Length、流结束、解压与解析,都影响最后是否得到可用表示。应把已经收到的状态与未完成的传输分别报告,不能因为状态指标已经计入二百,就把只有半份内容的响应视为应用成功。收到响应头,是比完整收到并理解业务结果更早的一个验证点。
是否重试也不能只看第一位数字。四二九表示请求受到限流,可以附带 Retry-After;五零三也可能建议等待。该字段可以使用延迟秒数或 HTTP 日期,但两种形式都没有保证服务器到时一定恢复。客户端应结合服务器提示、有界重试策略、退避和操作语义,避免一遇到错误就立即重放。等待提示不是无限重试的授权,也不是恢复成功的证据。
非幂等请求的响应丢失,可能让业务效果处于未知状态:客户端失去联系之前,服务器也许已经提交。没有合适的幂等契约就重复操作,可能产生重复工作。另一方面,有些四开头错误会随着身份、状态或限流条件改变而变得可恢复。按照整个类别制定“总是重试”或“绝不重试”,会抹掉真正需要用于决策的信息,而不是简化正确的处理逻辑。
状态码本身也不能说明究竟是哪一个组件生成了响应。源站、网关和中间层都可能返回错误,归因时应结合请求标识与相应日志。curl 的 000 一类诊断值,不是注册的 HTTP 响应状态,通常表示没有取得可用的 HTTP 状态。把它当成某种服务器错误,会把连接阶段的问题错误地归到已经收到响应之后的处理阶段。
快速验证
将脚本保存为 status-client.py,使用保留断言的 Python 3.14。五种回环响应和第六个已绑定但未监听的端点,用于检查分类。私有 urllib opener 只为实验关闭代理发现,不更改系统代理。第六个键为 no-response,只检查没有返回 HTTP 状态,不要求特定的拒绝连接错误。保留的 Mac 试验中,该端点始终由实验持有、没有监听,实际观察到的是超时。具体执行设置和证据限制见下文。
from http.client import IncompleteRead
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.error import HTTPError
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:
response = opener.open(url, timeout=3)
except HTTPError as error:
response = error
except OSError: # Includes URLError and socket timeout/connection errors.
return {'http_status': None, 'transport_failure': True}
with response:
result = {'http_status': response.code, 'retry_after': response.headers.get('Retry-After')}
try:
body = response.read()
except (IncompleteRead, OSError):
return {**result, 'complete_body': False}
return {**result, 'complete_body': True, 'payload': json.loads(body)}
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)) # Still owned, never listening: no HTTP response expected.
results['no-response'] = 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 all(results[path]['complete_body'] is True for path in
('/application-error', '/busy', '/limit', '/missing'))
assert results['/truncated'] == {'http_status': 200, 'retry_after': None, 'complete_body': False}
assert results['no-response'] == {'http_status': None, 'transport_failure': True}
print(json.dumps(results))
finally:
server.shutdown()
thread.join()
server.server_close()
预期应用信封返回 HTTP 二百与 ok: false。繁忙和限流端点分别返回五零三、四二九,并保留 Retry-After 的三秒和两秒文本值。缺失端点仍记录为 HTTP 四零四。这四种情况只有完成读取并成功解析 JSON,才能正常返回相应对象;脚本没有实际等待提示时长、进行重试,也没有宣称等待后一定成功。
截断端点声明一百字节,却只发送 partial。结果在 IncompleteRead 之后保留二百状态,并标记正文不完整。分类器在打开阶段先处理 HTTPError,再处理其他 OSError,从而保留已收到的错误响应。读取正文时遇到 IncompleteRead 或 OSError(包括 TimeoutError),则保留已经收到的状态和 Retry-After,同时设置 complete_body: false。JSON 解析不在这一捕获范围内;其他协议或解析失败仍可能向外抛出。这是小型分类示例,不能当成通用异常处理。
保留的 2026-10-18 试验在 macOS 26.5.1 arm64 上使用 Python 3.14.4,由外部监督器运行,启用 -I -B 并保留断言。上方中英文完整代码正文逐字节相同,分别作为 A-en、A-zh 各执行一次,均返回六种预期结果。另一个单请求的 B-positive 改编使用同一 inspect 函数,让 HTTP 四二九响应的正文保持未完成,直到读取阶段发生 TimeoutError;结果保留状态四二九、Retry-After 的 2,以及 complete_body: false。正文超时属于 B 的独立证据,不是上方六情况脚本新增的端点。四个精确负向变体分别在预期断言处被拒绝:丢弃正文超时后的元数据、把已收到响应当成没有响应、丢弃 HTTPError 元数据,以及把截断正文标为完整。
这是一次包含七个情况的批次,观察到三十二次 inspect 调用,在 19.1647 秒内完成;带观测工具的耗时不是性能基准。监督器继承了未修改的环境,但使用隔离解释器参数,因此不能据此证明默认环境或启动定制链的行为。记录只显示这一次运行的子进程自然退出和清理,没有抓包,也不证明生产行为。文章里的三秒套接字超时、shutdown() 和没有期限的 thread.join() 仍然不是整个进程的截止时间。分类不能验证服务器恢复或写操作重放安全。此前未修正代码、仅编译记录及既有诊断失败均保留为历史证据;本次修正版试验不能倒推证明那些旧可执行字节已经通过。
延伸阅读
来源
- RFC 9110: HTTP Semantics,状态解释、重试及 Retry-After 语义,已核验 2026-09-13。
- RFC 6585: additional HTTP status codes,429 Too Many Requests 的含义,已核验 2026-09-13。
- Python 3.14: urllib.request,HTTP 响应对象与错误处理,已核验 2026-09-13。
- Python 3.14: urllib.error,HTTPError 响应访问与 URLError 继承关系,已核验 2026-10-18。
- Python 3.14: http.client,响应读取与 IncompleteRead,已核验 2026-10-18。
- Python 3.14: assert statements,优化模式会移除断言检查,已核验 2026-10-18。
- Python 3.14: socket,TimeoutError/OSError 与依赖平台的超时行为,已核验 2026-10-19。