调用方需要的不只是三位数字,还要知道是否收到 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 四零四,而不是传输错误。这四份正文在本实验中均完整且可解析,因而能够进一步判断其业务字段;脚本没有实际等待这些延迟,也没有宣称等待后一定成功。
截断端点声明一百字节,却只发送 partial。响应头已经提供二百状态,读取正文时仍会抛出 IncompleteRead,结果必须记录内容不完整。拒绝连接的案例则没有 HTTP 状态。最终断言要求六种分类保持区别,避免把“取得完整否定回答”“取得不完整内容”和“根本没有响应”混在同一类故障里。
这些结果验证的是分类,不是服务器恢复速度,也没有验证写操作重放安全。应把已收到的状态、传输完成情况与应用结果传给负责决定下一步的组件。这样,操作人员才可以依据证据区分拒绝、交付中断和完整错误回答,而不必从一个已经丢失细节的布尔值反推实际发生了什么。
延伸阅读
来源
- 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。