"请求失败不扣费"听起来很简单:捕获异常,然后把钱退回去。但真正做过大模型 API 网关的人会发现,fetch() 抛错只说明网关没有拿到完整响应,并不能证明上游没有执行,更不能证明上游没有产生账单。
上一篇已经用 Cloudflare Worker + D1 做了"余额预留 → 实际 Token 结算"。这一篇不再讲正常路径。我故意构造三类失败,验证系统在每种情况下应该释放预留、继续冻结,还是进入对账。
本文是可核对的参考实现,示例中的"记账单位"均为整数。真实项目还要补齐鉴权、计价版本、上游协议适配和审计权限。
先把"失败"拆成三类
| 实验 | 网关知道什么 | 正确动作 |
|---|---|---|
| A:请求尚未发出就失败 | 可以证明上游未收到请求 | 释放全部预留 |
| B:上游明确拒绝且确认未计费 | 有上游响应或状态查询作为证据 | 释放全部预留 |
| C:请求已发出,但响应超时/连接断开 | 不知道上游是否完成、是否计费 | 标记 unknown,保留预留并对账 |
最危险的实现是:
ts
try {
return await fetch(upstreamUrl, init);
} catch {
await refund(requestId); // 错:网络异常不等于上游未执行
}
如果请求已经到达上游,只是返回途中断线,这段代码会把预留退给用户;稍后上游账单又会扣到平台账户,网关就承担了差额。攻击者甚至可以反复制造客户端断开或超时,放大这类漏洞。
状态机要表达"不知道"
不要只设计 success/failed 两种状态。最小状态机应该保留中间态:
text
reserved ──准备发送──> sent ──拿到可信 usage──> settled
│ ├──确认未计费──> released
└──确定未发送──> released
└──结果不明──> unknown
├──对账有费用──> settled
└──对账无费用──> released
这里的关键不是错误码,而是证据强度:
released:有证据证明没有费用;settled:有可信用量,可以计算实际费用;unknown:既不能结算,也不能退款,必须保留现场。
用户界面也要说清楚:unknown 是"金额暂时冻结、正在核对",不是"已经扣费",更不是"失败后拒不退款"。
先定义可审计的结果类型
不要把所有异常都压成一个 Error。调用层至少返回三类结果:
ts
type Usage = { inputTokens: number; outputTokens: number };
type ProviderResult =
| { kind: 'success'; providerRequestId: string; usage: Usage }
| { kind: 'verified_unbilled'; providerRequestId?: string; evidence: string }
| { kind: 'unknown'; providerRequestId?: string; reason: string };
type RequestState =
| 'reserved' | 'sent' | 'unknown' | 'settled' | 'released';
evidence 不能随便写成"HTTP 400"。不同上游对失败请求、工具调用、异步任务的计费规则并不相同。只有供应商协议明确说明不计费,或状态查询接口明确返回未执行/未计费,才能归为 verified_unbilled。
实验 A:转发前失败,可以立即释放
先预留 40 单位,随后在构造请求体时触发本地校验错误:例如消息数组为空、序列化失败、模型未在白名单中。此时还没有把状态从 reserved 改为 sent,所以能证明上游未收到请求。
ts
async function handleBeforeSendFailure(
env: Env,
requestId: string,
buildBody: () => string,
) {
try {
const body = buildBody(); // 可能抛出本地错误
const changed = await markSent(env, requestId);
if (!changed) throw new Error('request is not reservable');
return body;
} catch (error) {
const row = await getRequest(env, requestId);
if (row?.state === 'reserved') {
await releaseVerified(env, requestId, 'local_failure_before_send');
}
throw error;
}
}
测试结果:账户从 100 预留到 60,确认仍是 reserved 后释放,余额回到 100。重复执行释放不会再次加钱,因为更新语句必须限制旧状态并检查 meta.changes === 1。
实验 B:上游明确拒绝,也不能只看状态码
第二个实验让上游返回拒绝响应。网关已经执行 markSent(),所以不能像实验 A 那样根据本地状态直接退款。我们需要解析供应商响应,并按固定版本的规则表判断它是否为"明确未计费"。
ts
async function classifyResponse(response: Response): Promise<ProviderResult> {
const providerRequestId = response.headers.get('x-request-id') ?? undefined;
if (response.ok) {
const data = await response.json<{
usage: { input_tokens: number; output_tokens: number };
}>();
return {
kind: 'success',
providerRequestId: providerRequestId ?? 'missing',
usage: {
inputTokens: data.usage.input_tokens,
outputTokens: data.usage.output_tokens,
},
};
}
const body = await response.text();
const evidence = verifyUnbilledRejection(response.status, body);
if (evidence) {
return { kind: 'verified_unbilled', providerRequestId, evidence };
}
return {
kind: 'unknown',
providerRequestId,
reason: `unclassified_http_${response.status}`,
};
}
注意:401、429、5xx 是否计费不能由网关想当然决定。规则要绑定供应商与版本,并保存原始状态码、上游请求 ID 和证据摘要。无法分类就进入 unknown,不要为了"看起来退款快"而猜测。
实验结果:只有模拟上游返回"明确未执行"的证据时,40 单位预留才被全部释放;普通 500 被保留为 unknown。
实验 C:超时最棘手,不能自动退款
第三个实验在请求已发送后让连接超时。Cloudflare Workers 的 Request 支持 signal,可以通过 AbortController 取消等待;但"停止等待响应"不等于"撤销已经到达上游的请求"。
ts
async function callProvider(
env: Env,
requestId: string,
upstreamUrl: string,
init: RequestInit,
): Promise<ProviderResult> {
const changed = await markSent(env, requestId);
if (!changed) throw new Error('duplicate or invalid state');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort('upstream_timeout'), 25_000);
try {
const response = await fetch(upstreamUrl, {
...init,
signal: controller.signal,
});
return await classifyResponse(response);
} catch (error) {
return {
kind: 'unknown',
reason: error instanceof Error ? error.name : 'network_error',
};
} finally {
clearTimeout(timer);
}
}
处理结果时,只允许单向状态迁移:
ts
async function applyResult(env: Env, requestId: string, result: ProviderResult) {
if (result.kind === 'success') {
const actualUnits = priceUsage(result.usage);
return settle(env, requestId, actualUnits, result.providerRequestId);
}
if (result.kind === 'verified_unbilled') {
return releaseVerified(env, requestId, result.evidence);
}
return markUnknown(env, requestId, result.reason, result.providerRequestId);
}
实验结果:余额从 100 预留到 60 后暂时保持 60,请求状态变成 unknown。这不是最终扣费;后台对账确认结果后,才结算实际费用或把 40 全部退回。
unknown 怎样自动对账
对账任务可以由 Cron Trigger 调用 Worker 的 scheduled() 处理器。每次只扫描一小批超过等待窗口的 unknown 请求,避免单次任务过大。
ts
export default {
async scheduled(_controller: ScheduledController, env: Env) {
const rows = await env.DB.prepare(`
SELECT request_id, provider_request_id
FROM requests
WHERE state = 'unknown' AND updated_at < ?
ORDER BY updated_at
LIMIT 100
`).bind(Date.now() - 5 * 60_000).all<{
request_id: string;
provider_request_id: string | null;
}>();
for (const row of rows.results) {
const status = await queryProviderStatus(row.provider_request_id);
if (status.kind === 'success') {
await settle(env, row.request_id, priceUsage(status.usage), status.id);
} else if (status.kind === 'verified_unbilled') {
await releaseVerified(env, row.request_id, status.evidence);
}
// 仍查不到就保持 unknown;超过告警阈值后转人工处理
}
},
};
Cloudflare 官方文档说明,Cron Trigger 会调用 scheduled();D1 的预处理语句可通过 run()/all() 返回结果和元数据。这里仍要保证 settle()、releaseVerified() 的 SQL 带旧状态条件,防止多个对账任务重复退款。
如果供应商根本没有请求状态查询接口,就不能伪造确定性。可选方案只有:延长冻结窗口、用供应商账单明细核对、设人工工单,或者把该上游从"失败自动退款"的承诺中排除。
我会重点监控这 7 个字段
每次请求至少记录:
- 网关
request_id; - 上游
provider_request_id; payload_hash,防止同一幂等键更换请求内容;- 状态变化时间:
reserved_at/sent_at/settled_at; - 错误分类与原始 HTTP 状态;
- 退款或结算的证据来源;
- 锁定的
price_version与最终 usage。
真正需要告警的不是所有失败,而是:unknown 数量突然升高、停留时间超过阈值、同一用户高频制造超时,以及预留总额与上游未结账金额持续偏离。
最终测试矩阵
假设初始余额 100,每次预留 40:
| 场景 | 终态 | 可用余额 | 是否调用上游 | 后续动作 |
|---|---|---|---|---|
| 本地构造请求失败 | released |
100 | 否 | 无 |
| 上游明确拒绝且确认未计费 | released |
100 | 是 | 保存证据 |
| 发送后超时、账单未知 | unknown |
60 | 是 | 定时对账 |
| 对账查到实际费用 17 | settled |
83 | --- | 记录 usage |
| 对账确认未计费 | released |
100 | --- | 记录证据 |
因此,"失败不扣费"不应该被实现成一个粗暴的 catch → refund。更可靠的承诺是:确定未产生费用时立即释放;结果不明时暂时冻结并持续对账;最终只按可信的实际用量结算。
下一篇可以继续拆解对账队列:如何避免 Cron 重入、怎样给 unknown 请求加租约,以及如何处理上游账单晚到一天的情况。
参考:Cloudflare Workers Fetch API、Workers Request 与 AbortSignal、Scheduled Handler、D1 Prepared Statements、D1 Return Objects。