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

当每个端点以不同方式报告失败时,客户端会失去对你 API 的信任。一个路由返回 { \"error\": \"not found\" },另一个返回 { \"message\": \"missing\" },第三个则返回纯文本。即便语义接近,客户端代码也不得不去猜测发生了什么。
代价很快就会显现。团队构建出脆弱的解析逻辑,并为每个端点添加特例。重试变得危险,因为客户端无法判断“稍后重试”与“你的输入有问题”之间的区别。支持工单增加,因为客户端只看到模糊的信息,你们也无法轻松将其与服务器端的日志行对应起来。
一个常见场景:移动 App 在注册时调用三个端点。第一个返回 HTTP 400 和字段级错误映射,第二个返回 HTTP 500 和堆栈跟踪字符串,第三个返回 HTTP 200 和 { \"ok\": false }。App 团队为此发布了三套不同的错误处理器,而后端团队仍然收到类似“注册有时失败”的报告,却没有明确的起点。
目标是一个可预测的契约。客户端应该能够可靠地读取发生了什么,是他们的错还是你的错,是否值得重试,以及可以粘贴到支持工单中的请求 ID。
范围说明:本文聚焦于 JSON HTTP API(不包括 gRPC),但这些思想同样适用于任何你需要向其他系统返回错误的场景。
为错误选择一个明确的契约并让每个端点遵守它。“一致”意味着相同的 JSON 形状、字段含义一致、无论哪个处理器失败都表现相同。一旦做到这一点,客户端就不再猜测,而是开始有条不紊地处理错误。
一个有用的契约能帮助客户端决定下一步应该怎么做。对于大多数应用来说,每个错误响应都应该回答三个问题:
一组实用规则:
提前决定哪些内容永远不能出现在响应里。常见的“不可见”项包括 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 契约的一部分。添加新代码时不要改变旧含义。只添加可选字段,并假设客户端会忽略它们不认识的字段。
当每个处理器都自己发明失败信号方式时,错误处理会变得混乱。一小组类型化错误可以解决这个问题:处理器返回已知的错误类型,一个响应层将它们转换为一致的响应。
一个实用的初始集合覆盖大多数端点:
关键是在顶层保持稳定,即使根本原因变化也不影响。你可以包装底层错误(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 状态。客户端会基于此构建逻辑。
一个适用于大多数 API 的实用映射:
| 错误类型(示例) | 状态 | 何时使用 |
|---|---|---|
| BadRequest(格式错误的 JSON、缺少必需的查询参数) | 400 | 请求在协议或格式层面无效。 |
| Unauthenticated(无或无效的 token) | 401 | 客户端需要认证。 |
| Forbidden(无权限) | 403 | 认证有效,但不允许访问。 |
| NotFound(资源 ID 不存在) | 404 | 请求的资源不存在(或你选择隐藏存在性)。 |
| Conflict(唯一约束、版本不匹配) | 409 | 请求格式正确,但与当前状态冲突。 |
| ValidationFailed(字段规则不通过) | 422 | 结构可以解析,但业务验证不通过(邮箱格式、最小长度)。 |
| RateLimited | 429 | 请求在时间窗口内过多。 |
| Internal(未知错误) | 500 | 程序错误或意外失败。 |
| Unavailable(依赖不可用、超时、维护) | 503 | 临时的服务器端问题。 |
两个区分能避免很多混淆:
重试指引很重要:
请求 ID 是标识一次 API 调用的短唯一值。如果客户端能在每个响应中看到它,支持就变得很简单:"把请求 ID 发给我" 通常就足以找到确切的日志和失败位置。
这个习惯对成功和失败响应都很有价值。
使用一个明确规则:如果客户端发送请求 ID,就保留它;否则生成一个。
X-Request-Id)。把请求 ID 放在三处:
request_id)对于批量端点或后台任务,保留一个父级请求 ID。例如:客户端上传 200 行,12 行验证失败并入队处理。为整个调用返回一个 request_id,并在每个任务以及每个项级错误中包含 parent_request_id。这样,即便它扩散成许多任务,也能追踪“这次上传”。
客户端需要清晰、稳定的错误响应。你的日志需要包含混乱、真实的信息。把这两个世界分开:向客户端返回安全消息和公共错误代码,同时把内部原因、堆栈和上下文记录到服务器日志中。
为每次错误响应记录一条结构化事件,能通过 request_id 搜索到。
值得保持一致的字段:
把内部细节仅存储在服务器日志(或内部错误存储)中。客户端永远不应看到原始数据库错误、查询文本、堆栈跟踪或提供商消息。如果你运行多个服务,一个像 source(api、db、auth、upstream)之类的内部字段能加速排查。
注意噪声较大的端点和速率限制错误。如果某个端点每分钟可能产出成千上万次相同的 429 或 400,避免日志泛滥:对重复事件进行抽样,或对预期错误降低日志级别,同时在指标中继续计数。
指标比日志更早发现问题。按 HTTP 状态和错误代码分组跟踪计数,并在突增时告警。如果 RATE_LIMITED 在一次部署后暴增 10 倍,即使日志被抽样,你也会很快看到指标信号。
让错误一致的最简单方法是不在“每处”处理它们,而是把它们路由到一个小的流水线。该流水线决定客户端看到什么,服务器保留什么。
从一小组客户端可依赖的错误代码开始(例如:INVALID_ARGUMENT、NOT_FOUND、UNAUTHORIZED、CONFLICT、INTERNAL)。用类型化错误包装它们,暴露仅安全的公共字段(code、safe message、可选的 details,比如哪个字段不对),把内部原因保留私有。
然后实现一个翻译函数,把任意错误变成 (statusCode, responseBody)。在这里类型化错误映射到 HTTP 状态,未知错误退化为安全的 500 响应。
接着,添加中间件来:
request_idpanic 永远不应把堆栈跟踪输出给客户端。返回一个正常的 500 响应和通用消息,并用相同的 request_id 把完整的 panic 信息记录到日志中。
最后,把你的处理器改为返回 error 而不是直接写响应。一个包装器可以调用处理器,运行翻译器,然后以标准格式写出 JSON。
一个精简的检查清单:
金色测试很重要,因为它们锁定契约。如果以后有人更改了消息或状态码,测试会在客户端受到影响前失败。
想象有一个端点:客户端创建一个客户记录。
POST /v1/customers,JSON 如 { \"email\": \"[email protected]\", \"name\": \"Pat\" }。服务器始终返回相同的错误形状并包含 request_id。
邮箱缺失或格式不正确。客户端可以高亮该字段。
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
邮箱已存在。客户端可以建议用户登录或更换邮箱。
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
某个依赖不可用。客户端可以使用退避重试并显示平静的提示。
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
有了一个契约,客户端会一致地做出反应:
details.fields 标记字段request_id 作为支持用 ID对于支持来说,同样的 request_id 是在内部日志中找到真实原因的最快路径,而不会暴露堆栈跟踪或数据库错误。
最能惹恼 API 客户端的方式是让他们去猜。如果一个端点返回 { \"error\": \"...\" },另一个返回 { \"message\": \"...\" },每个客户端都会堆积特例,漏洞会潜伏数周。
一些反复出现的错误有:
code,这样一改措辞就会破坏客户端逻辑。request_id,导致无法把用户的报告与触发的成功调用关联起来。泄露内部信息是最容易犯的错。处理器出于方便返回 err.Error(),于是约束名或第三方消息被推到生产响应中。把对客户端的消息保持安全且简短,把详细原因放到日志中。
仅依赖文本也是长期隐患。如果客户端需要解析像“email already exists”这样的英文句子,你一旦改动措辞就会破坏逻辑。稳定的错误代码允许你调整和翻译消息,同时保持行为一致。
把错误代码视为公共契约的一部分。如果必须更改,新增一个代码并保持旧代码在一段时间内仍然可用,即便两者映射到相同的 HTTP 状态。
最后,在每个响应(成功或失败)中包含相同的 request_id 字段。当用户说“先前工作,后来失败”时,这个 ID 往往能省去许多猜测时间。
发布前做一次一致性检查:
error.code、error.message、request_id)。VALIDATION_FAILED、NOT_FOUND、CONFLICT、UNAUTHORIZED)。添加测试以防止处理器意外返回未知代码。request_id 并在每次请求的日志中记录,包括 panic 和超时。之后手动抽查几个端点。触发一次验证错误、一次缺失记录和一次意外失败。如果响应在端点间看起来不同(字段变化、状态码漂移、消息泄露内部),先修复共享流水线再继续新增功能。
一个实用规则:如果一条消息会帮助攻击者或让普通用户困惑,那么它应该存在于日志中,而不是响应里。
写下你希望每个端点遵循的错误契约(即状态、稳定错误代码、安全消息和 request_id),即使你的 API 已经上线。一个共享的契约是让客户端错误可预测的最快方式。
然后逐步迁移。保留现有处理器,但通过一个映射器把它们的失败转换为你的公共响应形状。这可以在不做高风险大改的情况下提高一致性,并防止新端点发明新格式。
维护一个小型错误代码目录并把它当作 API 的一部分。当有人想添加新代码时做一次快速评审:它真的是新的么?命名清晰吗?映射到的 HTTP 状态合适吗?
添加少量测试以捕获漂移:
request_id。error.code 存在且来自代码目录。error.message 保持安全,绝不包含内部细节。如果你从头构建 Go 后端,尽早锁定契约会有帮助。例如,Koder.ai (koder.ai) 提供了一个规划模式,你可以在其中预先定义像错误 schema 和代码目录这样的约定,然后在 API 增长时保持处理器一致。
使用相同的 JSON 结构返回每个错误响应,在所有端点保持一致。一个实用的默认是顶层的 request_id,加上一个 error 对象,包含 code、message 和可选的 details,这样客户端可以可靠地解析并采取相应措施。
把 error.message 作为简短、面向用户且安全的句子返回,把真实原因记录到服务器日志。不要返回原始数据库错误、堆栈跟踪、内部主机名或依赖方消息,即使在开发时看起来有帮助也不要这样做。
使用稳定的 error.code 作为机器可识别的逻辑依据,同时让 HTTP 状态码表示广义分类。客户端应当以 error.code(例如 ALREADY_EXISTS)进行分支判断,把状态码当作指导(例如 409 表示状态冲突)。
当请求无法可靠解析或解释(格式错误的 JSON、类型错误)时使用 400。当请求格式正确但违反业务规则(例如邮箱格式不正确、密码太短)时使用 422。
当输入有效但由于与当前状态冲突而无法应用(例如邮箱已被注册、版本不匹配)时使用 409。对于字段级验证(修改字段即可解决的问题)使用 422。
创建一小组类型化错误(validation、not found、conflict、unauthorized、internal),让处理器返回它们。然后用一个共享的翻译器把这些类型映射到状态码和统一的 JSON 响应格式,这样可以保持一致性。
在每个响应中(成功或失败)都返回 request_id,并在每条服务器日志中记录它。如果客户端报告问题,那个 ID 通常足以在日志中找到确切的失败路径。
只有在操作成功时才返回 200,错误应使用 4xx/5xx。把错误藏在 200 里的做法迫使客户端解析返回体字段,并导致端点之间行为不一致,这是不可取的。
默认情况下不要为 400、401、403、404、409 和 422 重试,因为在不做修改的情况下重试通常无效。对 503 可以重试,对 429 在等待后有时可以重试;如果支持幂等键,则在临时性失败时对 POST 进行重试会更安全。
用少量“金色”测试用例锁定合同:断言状态码、error.code 与 request_id 的存在。添加新错误代码时不要改变旧含义,只新增可选字段,这样旧客户端仍能正常工作。