错误分类、日志与排查

本节目标

把“API 调用失败”拆成可行动的错误层级;设计稳定的异常边界和安全日志;面对 HTML 错误页、无效 JSON、schema 变化、业务拒绝和网络故障时,保留足够证据而不泄漏凭据。

分层,而不是一个 except Exception

一次调用至少有六层可能失败:

层级例子是否自动重试
本地配置base URL 错、缺少环境变量否,修配置
请求构造对象不能 JSON 序列化、参数校验失败否,修代码/输入
传输DNS、拒绝连接、连接/读取超时取决于阶段与幂等性
TLS 身份验证证书链、主机名或信任配置失败否;核对 CA、主机名、系统时间和代理,不能用重试或 verify=False 掩盖
HTTP401、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 原始响应全部写日志。

日志记录什么

推荐结构化字段:

字段例子说明
eventapi_request_finished稳定事件名
servicevector_store逻辑服务名,不一定记录完整 host
methodPOSTHTTP 方法
route/v1/jobs/{id}路由模板,避免高基数字段
status_code503无响应时为空
duration_ms842单次尝试或总耗时要标清
attempt2当前尝试次数
request_id服务端提供的 ID支持工单关联
error_kindread_timeout稳定分类
retry_wait_ms1200若将重试

不要记录:Authorization、Cookie、API key、完整签名 URL、用户隐私、未经脱敏的 prompt/文档、超大 body。header 白名单比“记录全部再删敏感项”更安全,因为未来可能增加新的敏感字段。

指标与日志的分工

  • 日志回答“这一次发生了什么”,适合 request ID 和错误上下文;
  • 指标回答“总体是否异常”,例如请求量、成功率、各状态码、延迟分位数、重试率、限流率;
  • trace把 Agent、模型、工具和多个 API 调用串成一次端到端任务。

不要把用户 ID、完整 URL、request ID 当作指标 label,否则会产生高基数成本。可将 endpoint 归一化成 /items/{id}。

排查顺序

  1. 复现边界:哪个环境、endpoint、方法、时间段、是否每次发生?
  2. 本地证据:错误类别、状态码、耗时、尝试次数、服务端 request ID;隐藏凭据。
  3. 请求契约:base URL、API 版本、认证 header、content type、参数类型、必填字段。
  4. 响应证据:状态码、content type、文档化错误码、限流 header;只保存必要片段。
  5. 依赖状态:官方状态页或内部服务监控;不要仅凭一次 503 宣称全局故障。
  6. 最小复现:去掉 Agent 和框架层,用固定非敏感 payload 直接调用。
  7. 对比变化:SDK/配置/权限/网络/代理是否刚变化?
  8. 形成结论:区分已证实原因、合理推断和仍未知项。

典型症状

  • 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 模板、状态码、耗时和尝试次数。
  • 自动把所有失败升级为用户可见堆栈,泄漏本机路径与实现细节。

练习与自测

  1. 为 401、429、无效 JSON、schema 缺字段和读取超时分别映射异常类型。
  2. 设计一条结构化失败日志,确保移除 token、query 中的签名和请求正文。
  3. 将 except Exception 的示例改为“具体异常 → 客户端异常 → 上层决策”的三层结构。
  4. 解释为什么 request_id 适合日志字段,却通常不适合指标 label。
  • 我能区分传输、HTTP、格式、schema 和业务错误。
  • 我能设计少量稳定的客户端异常。
  • 我知道日志、指标和 trace 各自回答什么问题。
  • 我能在不暴露凭据的情况下给出可复现证据。

参考资料

获取日期:2026-07-22。下一步:实战:可靠 API 客户端。