使用 httputil.ReverseProxy 代理 LLM API 调用时,客户端断连会触发 http.ErrAbortHandler panic,回调函数中的计费逻辑不执行。
我维护了一个代理服务,用来追踪每个调用者在 LLM API 调用上的花费,并在达到限额时切断请求。在往里面加任何新功能之前,我想先确认它报告的数字是否真的准确——不是那种"能不能编译"的正确,而是重启之后正确、两百个请求同时到达时也正确。

我翻阅了一个做类似工作的大型项目的 issue 追踪器,找出了其中某些人生产环境中费用数字出错的案例,然后针对我自己的代码写了对应的测试。
第一次跑的时候,有三个测试失败了。
这个代理用的是 httputil.ReverseProxy。我的记账逻辑紧跟在请求处理之后:
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.rp.ServeHTTP(w, r)
// record usage
}
这在客户端中途挂断之前都没问题。但如果在响应过程中客户端断开了,ReverseProxy 不会正常返回。它会抛出 http.ErrAbortHandler 异常——net/http 能识别这个值,会把它当作连接关闭而不是程序崩溃处理。这个 panic 会直接 unwind 掉,跳过 ServeHTTP 之后的所有代码。
所以一个请求可能被服务了,花了真钱,却完全没有留下任何记录。没有日志,没有崩溃,没有被计入。
修复方法是使用 defer,因为 deferred 的函数调用在 panic unwind 时仍然会执行,而且是在 stdlib 自己处理每个请求的 recovery 之前:
defer p.finish(r, state, start)
p.rp.ServeHTTP(w, r)
我没有对 panic 做 recovery。net/http 已经正确处理了 ErrAbortHandler,没有必要自己重新实现。
另一个单独的测试,问题更严重。我向一个全新的 SQLite 数据库发了两百个并发请求,然后数了数行数。大约有 29% 消失了。
SQLite 只允许一个写操作。Go 的 database/sql 默认会打开一个连接池,这对几乎所有数据库都是正确的选择,但在这里是错的——多个 writer 的连接池只是让更多连接去抢同一把锁。超过 busy_timeout 之后就会报 SQLITE_BUSY,而调用方只能把它记为日志。
db.SetMaxOpenConns(1)
写操作会排队而不是冲突。这也使得插入操作快了大约 30 倍,因为一个困在锁上的连接池会做很多昂贵的等待,而队列则跳过了这些。
这不是我构建的项目特有的问题。任何在并发情况下向 SQLite 写入的 Go 服务默认都有这个问题,只是失败得很安静。
更小的问题。一条显示 $0 的记录可能意味着请求是免费的,或者响应没解析成功,或者模型不在定价表里。这几种情况在磁盘上一模一样。仪表盘读取时无法区分"什么都没发生"和"发生了什么但我不知道花了多少钱",这比单纯报错更糟糕,因为它看起来不像是个问题。
我加了一列来记录具体是哪种情况。
预算检查发生在请求之前,成本记录发生在请求之后,而到 provider 的往返行程夹在中间。足够多的并发请求同时到达边界时,它们看到的都是旧的总费用,都通过了。我测到的最坏情况:100 个并发请求,限额 $1.00,却记录了 $2.99。
真正修复这个问题意味着在进入时预留一个估计值,出去时修正它,并在所有没有正常完成的路径上释放预留——断开连接、上游错误、进程死亡。这比这里其他任何东西都更大更危险,所以我把边界写死了一点,然后开了一个 issue。