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

为什么不一致的 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_TAKEN 和 WEAK_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 | 结构可以解析,但业务验证不通过(邮箱格式、最小长度)。 |
| RateLimited | 429 | 请求在时间窗口内过多。 |
| 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_ARGUMENT、NOT_FOUND、UNAUTHORIZED、CONFLICT、INTERNAL)。用类型化错误包装它们,暴露仅安全的公共字段(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 往往能省去许多猜测时间。
发布前的快速检查清单
发布前做一次一致性检查:
- 全站统一的错误形状。每个端点返回相同的 JSON 字段(例如:
error.code、error.message、request_id)。 - 稳定的错误代码并覆盖常见情况。保持代码简短且规范(
VALIDATION_FAILED、NOT_FOUND、CONFLICT、UNAUTHORIZED)。添加测试以防止处理器意外返回未知代码。 - 统一的状态映射规则。决定每种错误类型映射到哪个 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 对象,包含 code、message 和可选的 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.code 与 request_id 的存在。添加新错误代码时不要改变旧含义,只新增可选字段,这样旧客户端仍能正常工作。