我故意构造了 3 类上游失败:怎样避免 API 失败后误扣费?

"请求失败不扣费"听起来很简单:捕获异常,然后把钱退回去。但真正做过大模型 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 个字段

每次请求至少记录:

  1. 网关 request_id;
  2. 上游 provider_request_id;
  3. payload_hash,防止同一幂等键更换请求内容;
  4. 状态变化时间:reserved_at/sent_at/settled_at;
  5. 错误分类与原始 HTTP 状态;
  6. 退款或结算的证据来源;
  7. 锁定的 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。

相关推荐
VIP_CQCRE8 小时前
ChatBox 接入 Ace Data Cloud 实战:配置 OpenAI 兼容模型,附 404/401 排错
ai·api·chatbox·ace data cloud
高频因子挖掘机10 小时前
历史 K 线突然少一天?用交易日历、停牌信息和数据校验逐步排查
后端·github·api
高频因子挖掘机11 小时前
股票池一大就请求缓慢?量化系统批量获取行情的设计与优化
后端·github·api
高频因子挖掘机13 小时前
量化回测前的数据审计:如何判断股票历史行情是否缺失、重复或存在偏差
后端·github·api
VIP_CQCRE1 天前
Nano Banana 图像 API:角色一致性、修图与商品图,一次接入怎么做?
api·ai绘图·图像生成·nano banana·ace data cloud
VIP_CQCRE1 天前
Visual Studio 接入 AI 助手:LMLocal × Ace Data Cloud 配置指南
大模型·api·ai编程·visual studio·acedatacloud
用户813267933251 天前
行情数据晚到几秒,会让量化策略失去优势吗?从信号时间到回测偏差
后端·github·api
QuantiCore_IO1 天前
从请求风暴到可维护的数据管道:量化系统为什么需要批量接口?
后端·github·api
用户813267933251 天前
量化系统为什么不应该为每只股票单独写一套数据获取逻辑?
后端·github·api
AAASilverwolf1 天前
Gemini Nano Banana 2.1 正式可用:视觉设计、文字渲染和主体一致性到底提升在哪?
人工智能·api