诊断工具的公开 API 错误契约:让脚本、CI 和 Agent 都能读懂失败
诊断 API 如果要服务脚本、CI 和 AI Agent,就需要用稳定、可机器读取的错误结构区分参数错误、阻断目标、超时、限流和上游失败。
这篇文章按完整正文来整理,从背景、实现到用法都可以连起来读。
诊断 API 会以可预期的方式失败
公开诊断 API 天然是给自动化使用的。
人可以打开网页,阅读结果,然后判断下一步怎么做。但脚本、CI 任务、告警机器人或 AI Agent 需要更稳定的信号。它们需要知道一次请求失败,是因为输入非法、目标被安全策略阻断、探测超时、调用方被限流,还是上游目标不可达。
这些不是同一种失败。
如果它们全部返回一个泛泛的 500,再附上一段自然语言说明,API 就很难被安全地使用。客户端会开始解析文案、错误重试、隐藏真实失败,或者把安全阻断误判成临时网络问题。
所以公开诊断 API 需要错误契约。
错误结构是产品契约的一部分,不是实现细节。
简单稳定的 envelope 对所有调用方都有帮助
最有用的 API 结构,往往是朴素且一致的。
成功响应里,客户端可以预期拿到结果对象。可预期的失败里,客户端也应该拿到稳定的错误对象。
例如:
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry later.",
"retryAfter": 42
}
}具体字段可以演进,但原则不应该变:
success告诉调用方这次请求是否产生了可用结果error.code给自动化一个稳定分支error.message给人类一个安全解释retryAfter、field或evidence这类可选字段提供上下文,而不是逼客户端解析自然语言
这对诊断工具尤其重要,因为很多失败都是业务领域内的正常情况。错误域名、被阻断目标、超时、跳转和上游错误,都会经常出现。
它们不应该看起来像服务器崩溃。
参数错误应该是可修正的
参数错误通常是用户可以修正的。
如果调用方提交了空域名、非法 URL、不支持的记录类型或格式错误的 IP 地址,API 应该明确说明。
一个有用的参数错误,应该回答:
- 哪个输入不正确
- 违反了什么规则
- 调用方修改输入后是否可以重试
例如:
{
"success": false,
"error": {
"code": "INVALID_TARGET",
"message": "Enter a valid domain or HTTP URL.",
"field": "target"
}
}这样 Web UI 可以高亮具体字段。脚本可以快速失败,而不是反复重试。AI Agent 也能知道应该修正哪个输入。
安全阻断不是网络错误
SSRF 防护可能会在真正探测前拒绝目标。
这不应该被报告成“连接失败”。
如果目标使用了不支持的协议、解析到了非公网地址、URL 里包含账号密码,或者跳转到了被阻断的位置,API 应该说明请求是被策略拒绝的。
例如:
{
"success": false,
"error": {
"code": "TARGET_NOT_ALLOWED",
"message": "The target resolves to a non-public network address."
}
}这个区别很重要。
被阻断目标不应该被激进重试。它也不应该被当作“网站挂了”的证据。同时,响应也不应该暴露内部 resolver 细节、私有 IP、服务端网络路径或基础设施上下文。
公开错误信息应该足够解释规则,但不应该足够用来摸清服务器。
超时需要单独分类
诊断 API 里,超时很常见。
目标可能很慢,TLS 握手可能卡住,源站可能接受连接但不返回响应,重定向链也可能消耗完探测预算。
这和参数错误不同,也和策略阻断不同。
超时响应应该告诉调用方:探测没有在允许时间内完成。
{
"success": false,
"error": {
"code": "PROBE_TIMEOUT",
"message": "The target did not respond before the diagnostic timeout.",
"timeoutMs": 10000
}
}这样 CI 任务可以决定是标记为 inconclusive、重试一次,还是直接阻断发布。人也能理解:工具是用完了探测预算,而不是域名格式错误。
在诊断系统里,“因为超时所以未知”是一个真实状态。
它不应该被压缩成“失败”。
限流响应应该教客户端如何退避
限流既是保护,也是开发者体验。
当公开 API 因为调用方超过配额而拒绝请求时,响应应该包含稳定错误码和重试信息。
Header 很有用:
Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
JSON body 也应该可机器读取:
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry later.",
"retryAfter": 42
}
}这对脚本和 Agent 很重要。看到 RATE_LIMITED,调用方可以等待。看到 INVALID_TARGET,应该修正输入。看到 TARGET_NOT_ALLOWED,应该停止。
不同错误需要不同处理方式。
上游失败应该保留证据
有些诊断请求本身成功运行了,但发现目标网站不健康。
例如:
- DNS 解析失败
- TCP 连接被拒绝
- TLS hostname 匹配失败
- HTTP 返回
502 - 目标出现重定向循环
这些不一定是 API 失败。它们可能是一次成功诊断得到的负面发现。
这就需要另一种表达:
{
"success": true,
"status": "completed_with_findings",
"result": {
"target": "example.com",
"findings": [
{
"severity": "warning",
"code": "HTTP_5XX_OBSERVED",
"message": "The target returned a server error.",
"evidence": {
"statusCode": 502
}
}
]
}
}API 请求成功了。网站本身可能不健康。
这个区别能让自动化更诚实。监控脚本可以说“诊断完成,并发现目标返回 502”,而不是误报成“诊断 API 失败”。
错误响应不能泄露内部信息
好的错误契约,不是把原始异常直接序列化出去。
公开响应应该避免包含:
- stack trace
- 原始 resolver 路径
- 私有网络地址
- 服务器 hostname
- secret 名称
- 依赖库原始异常文案
- 内部账号或部署标识
公开 API 可以在私有日志中保留更详细的信息,但客户端响应应该只使用稳定、经过审查的字段。
对公开诊断工具来说,目标是清晰,而不是把 API 变成侦察入口。
契约应该在 route 边界测试
只测试 helper function 不够。
真正的契约,是 route 返回给外部调用方的内容:状态码、headers、JSON envelope、稳定错误码,以及非法或被阻断请求是否真的跳过了昂贵探测。
Route-level 测试可以抓住实际回归:
- 参数错误返回一致的客户端错误
- 被 SSRF 阻断的目标不会启动探测
- 限流响应包含
Retry-After - 超时不会变成泛泛的 500
- 可预期的上游失败会在合适场景下表现为诊断 finding
对公开 API 来说,wire response 本身就是功能。
OPC 模式下的经验
对一人产品来说,稳定错误契约是一种杠杆。
它能减少支持成本,让脚本更容易写,让 AI Agent 有更安全的分支,也能为之后的 API Key 或付费配额打基础,而不需要一开始就做很重的平台系统。
契约不需要很大。
它需要可预测:
- 尽早校验输入
- 明确拒绝不安全目标
- 区分超时和策略阻断
- 告诉被限流客户端什么时候重试
- 探测完成时保留诊断证据
- 不把内部信息暴露给公开响应
这样,一个小型公开 API 才能变得足够可靠,真正服务自动化。