错误分类、日志与排查
本节目标
把“API 调用失败”拆成可行动的错误层级;设计稳定的异常边界和安全日志;面对 HTML 错误页、无效 JSON、schema 变化、业务拒绝和网络故障时,保留足够证据而不泄漏凭据。
分层,而不是一个 except Exception
一次调用至少有六层可能失败:
| 层级 | 例子 | 是否自动重试 |
|---|---|---|
| 本地配置 | base URL 错、缺少环境变量 | 否,修配置 |
| 请求构造 | 对象不能 JSON 序列化、参数校验失败 | 否,修代码/输入 |
| 传输 | DNS、拒绝连接、连接/读取超时 | 取决于阶段与幂等性 |
| TLS 身份验证 | 证书链、主机名或信任配置失败 | 否;核对 CA、主机名、系统时间和代理,不能用重试或 verify=False 掩盖 |
| HTTP | 401、404、429、503 | 按状态码与 API 约定 |
| 表示格式 | 预期 JSON 却收到 HTML、JSON 损坏 | 通常先排查网关/契约 |
| schema/业务 | 缺少字段、业务错误码、任务状态 failed | 按业务规则 |
分类的价值是让上层做正确决策:用户输入错误应立即提示,认证错误应更新配置,429 可排队稍后执行,结果未知的创建操作需查询状态,服务端临时故障才进入有限重试。
设计自己的异常边界
不要让业务层依赖 requests.exceptions 的全部细节。客户端模块可转换为少量稳定异常:
class ApiClientError(Exception): # 定义业务层只需认识的稳定客户端异常基类。
"""本客户端可识别错误的基类。""" # 文档字符串说明所有子类都表示已分类的 API 调用失败。
class ApiTransportError(ApiClientError): # 表示 DNS、连接或读取失败,尚未获得可用 HTTP 响应。
"""未取得可用 HTTP 响应。""" # 上层可根据操作幂等性决定是否有限重试。
class ApiHttpError(ApiClientError): # 表示服务端已返回 HTTP 状态和可安全记录的机器信息。
def __init__(self, status: int, code: str | None, request_id: str | None): # 接收状态码、机器错误码和服务端请求 ID。
super().__init__(f"API 返回 HTTP {status}, code={code!r}") # 构造不含响应正文和凭据的稳定错误消息。
self.status = status # 保存 HTTP 状态,供上层选择重试或提示策略。
self.code = code # 保存文档化机器错误码;不要依赖自然语言 detail 分支。
self.request_id = request_id # 保存服务端诊断 ID,便于安全关联支持工单。
class ApiResponseError(ApiClientError): # 表示 media type、JSON 语法或 schema 与客户端契约不符。
"""响应格式或 schema 不符合契约。""" # 通常优先排查网关、版本和服务端实现,而不是盲目重试。上层可以只捕获 ApiClientError 做统一展示,同时根据具体子类决定是否重试、报警或请求用户修正。原异常可用 raise ... from exc 保留因果链,调试日志记录堆栈,但面向用户的消息不暴露内部路径和响应正文。
HTTP 错误 body
RFC 9457 定义 application/problem+json,常见字段包括 type、title、status、detail、instance,API 还可添加扩展字段。它提供通用表达方式,但并非所有 API 都采用;客户端仍要按目标契约解析。
{
"type": "https://api.example.com/problems/invalid-cursor",
"title": "Invalid cursor",
"status": 400,
"detail": "Cursor has expired",
"request_id": "req_123"
}JSON 不能加入合法行尾注释:type 是稳定的问题类型 URI,title/detail 面向人但不宜作程序分支,status 与 HTTP 状态一致,request_id 可安全地关联服务端诊断。真实错误对象还可能有目标 API 自己定义的机器错误码。
- 程序优先使用 status、
type或文档化错误码分支; detail面向人,可能变化,不应靠字符串包含关系控制逻辑;- 服务端不应把堆栈、SQL、内部路径或 secret 放入 detail;
- 客户端显示 detail 前要考虑其中是否包含敏感业务数据。
安全地解析响应
from typing import Any # 表示经 JSON 解码后的 object 字段值可以是多种类型。
import requests # 用 Requests 的 Response 类型与 JSONDecodeError 进行明确处理。
def parse_json_object(response: requests.Response) -> dict[str, Any]: # 把已取得的 HTTP 响应收窄为可信的 JSON object。
media_type = ( # 提取 header 的主 media type。
response.headers.get("Content-Type", "") # 缺失时使用空字符串,后续一律拒绝。
.split(";", 1)[0] # 去掉 charset 等参数。
.strip() # 移除可能出现的边界空白。
.lower() # 统一大小写方便比较。
)
if media_type != "application/json" and not media_type.endswith("+json"): # 接受标准和 vendor `+json` 响应。
raise ApiResponseError(f"预期 JSON,实际 Content-Type={media_type!r}") # 不将 HTML 或纯文本伪装为业务 object。
try: # 即使 header 正确,响应体仍可能被截断或不是合法 JSON。
value = response.json() # 调用 Requests 解码响应 body。
except requests.exceptions.JSONDecodeError as exc: # 捕获库定义的 JSON 解码失败。
raise ApiResponseError("响应声明为 JSON,但内容无法解析") from exc # 保留原始异常因果,但对外暴露稳定分类。
if not isinstance(value, dict): # 这个客户端契约不接受数组、字符串或 null 作为顶层响应。
raise ApiResponseError("预期 JSON object") # 在字段读取前阻止类型错误和语义漂移。
return value # 返回通过最低表示层检查的 object,调用方继续做字段级验证。真实项目还需要字段级 schema 校验,可使用 dataclass、TypedDict 加手工检查,或 Pydantic/JSON Schema。校验失败应记录安全的字段路径与契约版本,不要把数 MB 原始响应全部写日志。
日志记录什么
推荐结构化字段:
| 字段 | 例子 | 说明 |
|---|---|---|
event | api_request_finished | 稳定事件名 |
service | vector_store | 逻辑服务名,不一定记录完整 host |
method | POST | HTTP 方法 |
route | /v1/jobs/{id} | 路由模板,避免高基数字段 |
status_code | 503 | 无响应时为空 |
duration_ms | 842 | 单次尝试或总耗时要标清 |
attempt | 2 | 当前尝试次数 |
request_id | 服务端提供的 ID | 支持工单关联 |
error_kind | read_timeout | 稳定分类 |
retry_wait_ms | 1200 | 若将重试 |
不要记录:Authorization、Cookie、API key、完整签名 URL、用户隐私、未经脱敏的 prompt/文档、超大 body。header 白名单比“记录全部再删敏感项”更安全,因为未来可能增加新的敏感字段。
指标与日志的分工
- 日志回答“这一次发生了什么”,适合 request ID 和错误上下文;
- 指标回答“总体是否异常”,例如请求量、成功率、各状态码、延迟分位数、重试率、限流率;
- trace把 Agent、模型、工具和多个 API 调用串成一次端到端任务。
不要把用户 ID、完整 URL、request ID 当作指标 label,否则会产生高基数成本。可将 endpoint 归一化成 /items/{id}。
排查顺序
- 复现边界:哪个环境、endpoint、方法、时间段、是否每次发生?
- 本地证据:错误类别、状态码、耗时、尝试次数、服务端 request ID;隐藏凭据。
- 请求契约:base URL、API 版本、认证 header、content type、参数类型、必填字段。
- 响应证据:状态码、content type、文档化错误码、限流 header;只保存必要片段。
- 依赖状态:官方状态页或内部服务监控;不要仅凭一次 503 宣称全局故障。
- 最小复现:去掉 Agent 和框架层,用固定非敏感 payload 直接调用。
- 对比变化:SDK/配置/权限/网络/代理是否刚变化?
- 形成结论:区分已证实原因、合理推断和仍未知项。
典型症状
200但.json()失败:可能收到 HTML 登录页/代理页,先看 content type 和最终 URL。- 偶发 401:可能是 token 刷新竞争、时钟或错误环境,不应简单重试掩盖。
- 延迟升高且重试率升高:重试可能正在放大服务端压力,先看首轮请求指标。
- 429 只有批任务出现:检查全局并发和每个 worker 是否各自限速。
- POST 超时后出现重复资源:确认是否复用了幂等键,以及服务端去重窗口。
常见错误
except Exception: return None,把“未找到”和“服务挂了”混成同一结果。- 只调用
raise_for_status(),不保留安全的机器错误码与 request ID。 - 为排查直接输出所有 headers 和 body。
- 仅靠错误 message 字符串决定重试。
- 让客户端自动跟随未知重定向,随后把最终响应误当成原 endpoint 的成功结果。
- 日志写“失败了”,却没有 endpoint 模板、状态码、耗时和尝试次数。
- 自动把所有失败升级为用户可见堆栈,泄漏本机路径与实现细节。
练习与自测
- 为 401、429、无效 JSON、schema 缺字段和读取超时分别映射异常类型。
- 设计一条结构化失败日志,确保移除 token、query 中的签名和请求正文。
- 将
except Exception的示例改为“具体异常 → 客户端异常 → 上层决策”的三层结构。 - 解释为什么
request_id适合日志字段,却通常不适合指标 label。
- 我能区分传输、HTTP、格式、schema 和业务错误。
- 我能设计少量稳定的客户端异常。
- 我知道日志、指标和 trace 各自回答什么问题。
- 我能在不暴露凭据的情况下给出可复现证据。
参考资料
获取日期:2026-07-22。下一步:实战:可靠 API 客户端。