1 分钟

用于清晰一致响应的 Go API 错误处理模式

Go API 的错误处理模式,规范化类型化错误、HTTP 状态码、请求 ID 和安全提示消息,避免泄露内部信息。

用于清晰一致响应的 Go API 错误处理模式

为什么不一致的 API 错误会让客户端沮丧

当每个端点以不同方式报告失败时,客户端会失去对你 API 的信任。一个路由返回 { \"error\": \"not found\" },另一个返回 { \"message\": \"missing\" },第三个则返回纯文本。即便语义接近,客户端代码也不得不去猜测发生了什么。

代价很快就会显现。团队构建出脆弱的解析逻辑,并为每个端点添加特例。重试变得危险,因为客户端无法判断“稍后重试”与“你的输入有问题”之间的区别。支持工单增加,因为客户端只看到模糊的信息,你们也无法轻松将其与服务器端的日志行对应起来。

一个常见场景:移动 App 在注册时调用三个端点。第一个返回 HTTP 400 和字段级错误映射,第二个返回 HTTP 500 和堆栈跟踪字符串,第三个返回 HTTP 200 和 { \"ok\": false }。App 团队为此发布了三套不同的错误处理器,而后端团队仍然收到类似“注册有时失败”的报告,却没有明确的起点。

目标是一个可预测的契约。客户端应该能够可靠地读取发生了什么,是他们的错还是你的错,是否值得重试,以及可以粘贴到支持工单中的请求 ID。

范围说明:本文聚焦于 JSON HTTP API(不包括 gRPC),但这些思想同样适用于任何你需要向其他系统返回错误的场景。

一个简单目标:每个端点都遵循同一个契约

为错误选择一个明确的契约并让每个端点遵守它。“一致”意味着相同的 JSON 形状、字段含义一致、无论哪个处理器失败都表现相同。一旦做到这一点,客户端就不再猜测,而是开始有条不紊地处理错误。

一个有用的契约能帮助客户端决定下一步应该怎么做。对于大多数应用来说,每个错误响应都应该回答三个问题:

  • 我能修正我的输入吗?
  • 我应该稍后重试吗?
  • 我需要联系支持吗?

一组实用规则:

  • 所有错误使用一个响应模式。
  • 统一的状态码策略(同一错误类型总是映射到相同的 HTTP 状态)。
  • 一个安全消息策略(哪些给用户看、哪些保留内部)。
  • 一个关联钩子(返回请求 ID,以便支持能定位失败)。

提前决定哪些内容永远不能出现在响应里。常见的“不可见”项包括 SQL 片段、堆栈跟踪、内部主机名、密钥和来自依赖的原始错误字符串。

保持清晰的分离:简短的面向用户的信息(安全、礼貌、可操作)和内部细节(完整错误、堆栈和上下文)只记录在日志中。例如,“无法保存你的更改,请稍后重试。”是安全的,而“pq: duplicate key value violates unique constraint users_email_key”就不应出现在响应里。

当每个端点都遵循相同的契约时,客户端可以只写一个错误处理器并在各处复用它。

定义客户端可以信赖的错误响应模式

只有当每个端点都以相同的形状回答时,客户端才能干净地处理错误。选择一个 JSON 包装并保持稳定。

一个实用的默认是包含 error 对象和顶层 request_id

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Some fields are invalid.",
    "details": {
      "fields": {
        "email": "must be a valid email address"
      }
    }
  },
  "request_id": "req_01HV..."
}

HTTP 状态表示广义类别(400、401、409、500)。机器可读的 error.code 给出客户端可以分支处理的具体案例。这个区分很重要,因为许多不同的问题共享同一个状态码。移动端可能针对 EMAIL_TAKENWEAK_PASSWORD 显示不同的 UI,即便它们都对应 400。

error.message 保持为对人类安全的文本。它应当帮助用户修复问题,但绝不泄露内部信息(SQL、堆栈、提供商名字、文件路径)。

可选字段在保持可预测的情况下非常有用:

  • 验证错误:details.fields,字段到消息的映射。
  • 速率限制或临时问题:details.retry_after_seconds
  • 额外指引:details.docs_hint 作为纯文本(不是 URL)。

为了向后兼容,把 error.code 值视为 API 契约的一部分。添加新代码时不要改变旧含义。只添加可选字段,并假设客户端会忽略它们不认识的字段。

Go 中的类型化错误:为你的处理器提供清晰模型

当每个处理器都自己发明失败信号方式时,错误处理会变得混乱。一小组类型化错误可以解决这个问题:处理器返回已知的错误类型,一个响应层将它们转换为一致的响应。

一个实用的初始集合覆盖大多数端点:

  • ValidationError(坏输入)
  • NotFoundError(资源不存在)
  • ConflictError(唯一约束、状态不匹配)
  • UnauthorizedError(未登录或无权限)
  • InternalError(其他一切)

关键是在顶层保持稳定,即使根本原因变化也不影响。你可以包装底层错误(SQL、网络、JSON 解析),同时仍然返回中间件可以检测的公共类型。

type NotFoundError struct {
	Resource string
	ID       string
	Err      error // private cause
}

func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }

在处理器中,返回 NotFoundError{Resource: "user", ID: id, Err: err},而不是直接暴露 sql.ErrNoRows

检查错误时,优先使用 errors.As 来判断自定义类型,使用 errors.Is 来对比哨兵错误。哨兵错误(比如 var ErrUnauthorized = errors.New("unauthorized"))适用于简单场景,但在你需要安全上下文(例如哪个资源缺失)且不想改变公共响应契约时,自定义类型更有优势。

严格区分你附带的内容:

  • 公开的(对客户端安全):简短信息、稳定代码、有时还有字段名用于验证。
  • 私有的(仅日志):底层 Err、堆栈信息、原始 SQL 错误、令牌、用户数据。

这种分离让你在不暴露内部细节的情况下帮助客户端。

将错误类型一致地映射到 HTTP 状态码

一旦有了类型化错误,下一步是枯燥但必要的工作:相同的错误类型应始终产生相同的 HTTP 状态。客户端会基于此构建逻辑。

一个适用于大多数 API 的实用映射:

错误类型(示例)状态何时使用
BadRequest(格式错误的 JSON、缺少必需的查询参数)400请求在协议或格式层面无效。
Unauthenticated(无或无效的 token)401客户端需要认证。
Forbidden(无权限)403认证有效,但不允许访问。
NotFound(资源 ID 不存在)404请求的资源不存在(或你选择隐藏存在性)。
Conflict(唯一约束、版本不匹配)409请求格式正确,但与当前状态冲突。
ValidationFailed(字段规则不通过)422结构可以解析,但业务验证不通过(邮箱格式、最小长度)。
RateLimited429请求在时间窗口内过多。
Internal(未知错误)500程序错误或意外失败。
Unavailable(依赖不可用、超时、维护)503临时的服务器端问题。

两个区分能避免很多混淆:

  • 400 vs 422:当无法可靠解析请求(坏 JSON、类型错误)时使用 400;当能解析但值不接受时使用 422。
  • 409 vs 422:对字段级验证(例如密码太短)使用 422。当数据有效但因状态原因无法应用时(邮箱已被注册、订单已发货、乐观锁失败)使用 409。

重试指引很重要:

  • 通常可安全重试:503,有时 429(等待后)。
  • 通常不应在未改变条件下重试:400、401、403、404、409、422。
  • 如果操作是幂等的(相同体的 PUT,或带幂等键的 POST),即使在瞬时失败后重试也更安全。

请求 ID:调试客户端问题的最快方式

在重构中安全变更
在你完善错误模式时,通过快照与回滚快速迭代并保证安全变更。

请求 ID 是标识一次 API 调用的短唯一值。如果客户端能在每个响应中看到它,支持就变得很简单:"把请求 ID 发给我" 通常就足以找到确切的日志和失败位置。

这个习惯对成功和失败响应都很有价值。

生成与传播规则

使用一个明确规则:如果客户端发送请求 ID,就保留它;否则生成一个。

  • 接受来自单个头的传入 ID(选一个并记录,例如 X-Request-Id)。
  • 如果头缺失或为空,在边界(中间件)创建新 ID 并把它放入请求上下文。
  • 永远不要在请求过程中改变 ID。通过上下文或头把它传给下游调用(DB、其他服务)。

把请求 ID 放在三处:

  • 响应头(使用你接受的同一头名)
  • 响应体(在标准模式中作为 request_id
  • 日志(作为每条日志行的结构化字段)

批量与异步工作

对于批量端点或后台任务,保留一个父级请求 ID。例如:客户端上传 200 行,12 行验证失败并入队处理。为整个调用返回一个 request_id,并在每个任务以及每个项级错误中包含 parent_request_id。这样,即便它扩散成许多任务,也能追踪“这次上传”。

日志与指标,同时避免泄露内部信息

客户端需要清晰、稳定的错误响应。你的日志需要包含混乱、真实的信息。把这两个世界分开:向客户端返回安全消息和公共错误代码,同时把内部原因、堆栈和上下文记录到服务器日志中。

为每次错误响应记录一条结构化事件,能通过 request_id 搜索到。

值得保持一致的字段:

  • request_id
  • user_id 或 account_id(当已认证时)
  • 公共错误代码和 HTTP 状态
  • handler/route 名称和方法
  • 内部错误详情(被包装的原因、验证字段错误、上游超时)

把内部细节仅存储在服务器日志(或内部错误存储)中。客户端永远不应看到原始数据库错误、查询文本、堆栈跟踪或提供商消息。如果你运行多个服务,一个像 source(api、db、auth、upstream)之类的内部字段能加速排查。

注意噪声较大的端点和速率限制错误。如果某个端点每分钟可能产出成千上万次相同的 429 或 400,避免日志泛滥:对重复事件进行抽样,或对预期错误降低日志级别,同时在指标中继续计数。

指标比日志更早发现问题。按 HTTP 状态和错误代码分组跟踪计数,并在突增时告警。如果 RATE_LIMITED 在一次部署后暴增 10 倍,即使日志被抽样,你也会很快看到指标信号。

逐步实现:在 Go 中建立一致的错误流水线

发布可预测的错误
用稳定的错误代码原型化验证、冲突和未找到的流程。

让错误一致的最简单方法是不在“每处”处理它们,而是把它们路由到一个小的流水线。该流水线决定客户端看到什么,服务器保留什么。

这个流水线的 5 个实用步骤

从一小组客户端可依赖的错误代码开始(例如:INVALID_ARGUMENTNOT_FOUNDUNAUTHORIZEDCONFLICTINTERNAL)。用类型化错误包装它们,暴露仅安全的公共字段(code、safe message、可选的 details,比如哪个字段不对),把内部原因保留私有。

然后实现一个翻译函数,把任意错误变成 (statusCode, responseBody)。在这里类型化错误映射到 HTTP 状态,未知错误退化为安全的 500 响应。

接着,添加中间件来:

  • 确保每个请求都有 request_id
  • 从 panic 中恢复

panic 永远不应把堆栈跟踪输出给客户端。返回一个正常的 500 响应和通用消息,并用相同的 request_id 把完整的 panic 信息记录到日志中。

最后,把你的处理器改为返回 error 而不是直接写响应。一个包装器可以调用处理器,运行翻译器,然后以标准格式写出 JSON。

一个精简的检查清单:

  • 定义带有安全字段和稳定代码的类型化错误。
  • 在一处把错误翻译到状态码和响应 JSON。
  • 添加请求 ID 和 panic 恢复中间件。
  • 让处理器返回错误而不是直接写响应。
  • 为翻译器和包装器添加金色测试。

金色测试很重要,因为它们锁定契约。如果以后有人更改了消息或状态码,测试会在客户端受到影响前失败。

示例:一个端点、三种失败、可预测的响应

想象有一个端点:客户端创建一个客户记录。

POST /v1/customers,JSON 如 { \"email\": \"[email protected]\", \"name\": \"Pat\" }。服务器始终返回相同的错误形状并包含 request_id

1)验证错误(400)

邮箱缺失或格式不正确。客户端可以高亮该字段。

{
  "request_id": "req_01HV9N2K6Q7A3W1J9K8B",
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Some fields need attention.",
    "details": {
      "fields": {
        "email": "must be a valid email address"
      }
    }
  }
}

2)冲突(409)

邮箱已存在。客户端可以建议用户登录或更换邮箱。

{
  "request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
  "error": {
    "code": "ALREADY_EXISTS",
    "message": "A customer with this email already exists."
  }
}

3)暂时性失败(503)

某个依赖不可用。客户端可以使用退避重试并显示平静的提示。

{
  "request_id": "req_01HV9N3X8P2J7T4N6C1D",
  "error": {
    "code": "TEMPORARILY_UNAVAILABLE",
    "message": "We could not save your request right now. Please try again."
  }
}

有了一个契约,客户端会一致地做出反应:

  • 400:使用 details.fields 标记字段
  • 409:引导用户采取安全的下一步
  • 503:提示重试并展示 request_id 作为支持用 ID

对于支持来说,同样的 request_id 是在内部日志中找到真实原因的最快路径,而不会暴露堆栈跟踪或数据库错误。

常见陷阱:让错误处理更糟糕的做法

最能惹恼 API 客户端的方式是让他们去猜。如果一个端点返回 { \"error\": \"...\" },另一个返回 { \"message\": \"...\" },每个客户端都会堆积特例,漏洞会潜伏数周。

一些反复出现的错误有:

  • 使用 HTTP 200 搭配错误体,或在端点间切换多种错误模式。
  • 在用户消息中暴露内部信息,如 SQL 错误、堆栈跟踪、IP、依赖主机名或文件路径。
  • 仅用人类可读文本作为唯一标识,而不是稳定的 code,这样一改措辞就会破坏客户端逻辑。
  • 随意更改错误代码(或为不同问题重用相同代码),从而破坏依赖于旧行为的客户端。
  • 只在失败时添加 request_id,导致无法把用户的报告与触发的成功调用关联起来。

泄露内部信息是最容易犯的错。处理器出于方便返回 err.Error(),于是约束名或第三方消息被推到生产响应中。把对客户端的消息保持安全且简短,把详细原因放到日志中。

仅依赖文本也是长期隐患。如果客户端需要解析像“email already exists”这样的英文句子,你一旦改动措辞就会破坏逻辑。稳定的错误代码允许你调整和翻译消息,同时保持行为一致。

把错误代码视为公共契约的一部分。如果必须更改,新增一个代码并保持旧代码在一段时间内仍然可用,即便两者映射到相同的 HTTP 状态。

最后,在每个响应(成功或失败)中包含相同的 request_id 字段。当用户说“先前工作,后来失败”时,这个 ID 往往能省去许多猜测时间。

发布前的快速检查清单

更快构建 Go API
描述你的 Go API 并生成遵循统一错误响应格式的处理器。

发布前做一次一致性检查:

  • 全站统一的错误形状。每个端点返回相同的 JSON 字段(例如:error.codeerror.messagerequest_id)。
  • 稳定的错误代码并覆盖常见情况。保持代码简短且规范(VALIDATION_FAILEDNOT_FOUNDCONFLICTUNAUTHORIZED)。添加测试以防止处理器意外返回未知代码。
  • 统一的状态映射规则。决定每种错误类型映射到哪个 HTTP 状态,并在共享位置实现它。
  • 请求 ID 双向存在。始终返回 request_id 并在每次请求的日志中记录,包括 panic 和超时。
  • 默认使用安全消息。面向用户的消息应简短、清晰、可操作,绝不包含堆栈、SQL 或供应商名。

之后手动抽查几个端点。触发一次验证错误、一次缺失记录和一次意外失败。如果响应在端点间看起来不同(字段变化、状态码漂移、消息泄露内部),先修复共享流水线再继续新增功能。

一个实用规则:如果一条消息会帮助攻击者或让普通用户困惑,那么它应该存在于日志中,而不是响应里。

下一步:现在就标准化,并在之后保持一致

写下你希望每个端点遵循的错误契约(即状态、稳定错误代码、安全消息和 request_id),即使你的 API 已经上线。一个共享的契约是让客户端错误可预测的最快方式。

然后逐步迁移。保留现有处理器,但通过一个映射器把它们的失败转换为你的公共响应形状。这可以在不做高风险大改的情况下提高一致性,并防止新端点发明新格式。

维护一个小型错误代码目录并把它当作 API 的一部分。当有人想添加新代码时做一次快速评审:它真的是新的么?命名清晰吗?映射到的 HTTP 状态合适吗?

添加少量测试以捕获漂移:

  • 每个错误响应都包含 request_id
  • 状态码与错误类型匹配(而不是与错误文本匹配)。
  • error.code 存在且来自代码目录。
  • error.message 保持安全,绝不包含内部细节。
  • 未知错误降级为带通用消息的 500。

如果你从头构建 Go 后端,尽早锁定契约会有帮助。例如,Koder.ai (koder.ai) 提供了一个规划模式,你可以在其中预先定义像错误 schema 和代码目录这样的约定,然后在 API 增长时保持处理器一致。

常见问题

什么样的“统一错误响应”应该是什么样?

使用相同的 JSON 结构返回每个错误响应,在所有端点保持一致。一个实用的默认是顶层的 request_id,加上一个 error 对象,包含 codemessage 和可选的 details,这样客户端可以可靠地解析并采取相应措施。

我如何避免在 API 错误中泄露内部细节?

error.message 作为简短、面向用户且安全的句子返回,把真实原因记录到服务器日志。不要返回原始数据库错误、堆栈跟踪、内部主机名或依赖方消息,即使在开发时看起来有帮助也不要这样做。

如果我已经有 HTTP 状态码,是否还需要错误代码?

使用稳定的 error.code 作为机器可识别的逻辑依据,同时让 HTTP 状态码表示广义分类。客户端应当以 error.code(例如 ALREADY_EXISTS)进行分支判断,把状态码当作指导(例如 409 表示状态冲突)。

什么时候应该使用 HTTP 400 而不是 422?

当请求无法可靠解析或解释(格式错误的 JSON、类型错误)时使用 400。当请求格式正确但违反业务规则(例如邮箱格式不正确、密码太短)时使用 422。

什么时候应该使用 HTTP 409 而不是 422?

当输入有效但由于与当前状态冲突而无法应用(例如邮箱已被注册、版本不匹配)时使用 409。对于字段级验证(修改字段即可解决的问题)使用 422。

Go 中的类型化错误如何帮助保持响应一致?

创建一小组类型化错误(validation、not found、conflict、unauthorized、internal),让处理器返回它们。然后用一个共享的翻译器把这些类型映射到状态码和统一的 JSON 响应格式,这样可以保持一致性。

我应该如何生成并返回请求 ID?

在每个响应中(成功或失败)都返回 request_id,并在每条服务器日志中记录它。如果客户端报告问题,那个 ID 通常足以在日志中找到确切的失败路径。

为什么使用 `{ "ok": false }` 且返回 HTTP 200 是个坏主意?

只有在操作成功时才返回 200,错误应使用 4xx/5xx。把错误藏在 200 里的做法迫使客户端解析返回体字段,并导致端点之间行为不一致,这是不可取的。

客户端哪些错误应该重试,哪些不应该重试?

默认情况下不要为 400、401、403、404、409 和 422 重试,因为在不做修改的情况下重试通常无效。对 503 可以重试,对 429 在等待后有时可以重试;如果支持幂等键,则在临时性失败时对 POST 进行重试会更安全。

我如何防止随着 API 演进导致错误响应漂移?

用少量“金色”测试用例锁定合同:断言状态码、error.coderequest_id 的存在。添加新错误代码时不要改变旧含义,只新增可选字段,这样旧客户端仍能正常工作。

Related posts