政府API用参数值1返回空结果而非报错,导致永远报「无制裁记录」;另有工具链静默返回默认值。API返回200不代表逻辑正确,这类静默失败比报错更难定位。
上周我写了一篇关于为 AI Agent 交付付费 API 并在一周内获得两个真实调用的文章。本来这篇 follow-up 应该是关于产品修复的,结果变成了别的东西——因为在修复它的过程中,我连续遇到了三个 bug,它们有一个共同点:
它们都没有报错。三个看起来都是成功的。
事实证明,这种失败模式是最耗时间的,但我没看到有太多讨论,所以把它们写出来。
我在给商业验证 API 添加禁止采购记录——也就是针对被禁止参与政府采购的公司的公开制裁记录。接口接受一个叫 inqryDiv 的参数,我传了 1。
它返回了 HTTP 200,空结果集。没有错误,没有警告,没有任何提示。直观来看:这个公司没有制裁记录。
正确的值应该是 2。用 1 的话,API 回答的是一个没人问过的问题,然后永远对每个公司都返回空。
如果我这样上线了,我的 API 会面无表情地以 200 状态码告诉用户:每个韩国的公司都"没有找到制裁记录"。这不是一个坏掉的特性。这是一个自信地说谎的特性。
救了我的是:我恰好用一个我知道被制裁的公司做了测试。如果我用一家随机公司测试——那也会返回零,而且恰好是对的——我永远不会发现这个问题。
我写了一个定时任务来丰富索引中的注册号信息。用 Cloud Scheduler 注册了它。在信任这个定时任务之前手动运行了一次。
Exit code 0。耗时:不到一秒。Cloud Run 控制台:绿色对勾。
什么都没发生。
入口函数有一个 guard 用来检测模块是否被直接运行,它手工构建了比较 URL:
// Works on my machine. Not on Linux.
if (import.meta.url === `file:///${process.argv[1]}`) {
main();
}
在 Windows 上,process.argv[1] 看起来像 C:\path\to\file.js,所以前面加 file:/// 是对的。在 Linux 上它已经是 /app/dist/job.js,所以你得到的是 file:////app/dist/job.js——四个斜杠——永远匹配不上。guard 静默地求值为 false,main() 没跑,进程干净地退出了,平台报告成功。
修复只有一行(pathToFileURL(process.argv[1]).href),但这不是有趣的部分。有趣的部分是:调度面板里的绿色对勾不是你代码运行了的证据。它只是容器启动后没崩的证据。这是两个完全不同的说法。
如果我当时信任了定时任务而不是手动跑了一次,我会在接下来一周每天早上都看到绿色对勾,而索引纹丝不动,然后我会在 API、凭据、数据——除了决定程序是否启动的那一行之外的任何地方——去找问题。
任务修复之后,我用 Git Bash 的 curl 测试韩国公司名称的搜索。零结果。每次都是。英文名称没问题。
我差一点就要去查归一化 pipeline 了——韩语文本有自己的编码陷阱,那里出 bug 是完全合理的——然后我换成从本地 Node 脚本执行同样的查询。结果有数据。
服务器没问题。是 Git Bash 在 URL 离开我电脑之前就把 UTF-8 搞坏了。
这一个花费时间最少,但教训最尖锐:测试失败时,测试环境也是嫌疑人之一。我一直把终端当作中性观察者。它不是。
三个不同的层面——政府 API、容器平台、shell——每次都是同样的形态。有东西出错了,而我所有能看到的信号都说一切正常。
它们的共同点是:每一个回答的都不是我问的那个问题。
我问的是"这个公司有没有制裁记录?"API 回答的是"你不想查的那个查询类型的结果":零。我问的是"我的 enrichment 任务跑了吗?"平台回答的是"容器退出时没崩吗?":是的。我问的是"搜索能用吗?"我的终端回答的是"当输入被损坏时搜索能用吗?":不能。
每一层都诚实地回答了。没有一个回答了我的问题。
三条规则,写进项目文档里,这样未来的我就没法狡辩了:
用已知阳性数据验证,而不是随机样本。用一家没有制裁记录的公司测试制裁查询什么都证明不了,因为坏掉的实现和正确的实现给出完全相同的输出。你需要一个正确答案不是空的情况。
Exit code 0 不是工作了的证据。要检查产物。文件的修改时间变了吗?行数增加了吗?摘要日志打印了吗?调度面板告诉你的只是容器生命周期的情况,不是你程序的意图。
注册定时任务之后,立刻手动跑一次。"已注册"和"实际在工作"之间的 gap 就是静默失败存活数天的地方。现在花五分钟 versus 之后一周的绿色对勾。
我一直在构建一个面向 AI Agent 而非人类的 API,这个问题在那个场景下只会更严重,不会更好。一个人类用户看到一家他明知被制裁的公司返回"没有找到制裁记录",会发邮件给你。一个 Agent 会把这个写进报告然后继续。
Agent 没有能捕捉到错误但合理答案的直觉。它们分不清"自信地空"和"正确地空"。不管你返回什么,它们都会拿去行动。
这意味着对于面向 Agent 的 API,"空"和"坏掉"的区别不是你错误处理里的 nice-to-have,它是产品本身。我最后把它显式编码了:真正的无匹配返回 200 加空数组加一条说明这是确定性结果而非错误的注释;上游失败返回 503 加错误码。同样的数据缺席,两种完全不同的含义,消费者必须能不靠猜测就分辨出来。
三个说"成功"的 bug 教会了我这些。我宁愿从别人的文章里学到它,这就是我写这一篇的原因。