我故意构造了 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}`,
  };
}

注意:4014295xx 是否计费不能由网关想当然决定。规则要绑定供应商与版本,并保存原始状态码、上游请求 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 APIWorkers Request 与 AbortSignalScheduled HandlerD1 Prepared StatementsD1 Return Objects

相关推荐
探数API小喇叭1 天前
天气 API 接口思路:拆解一套包含 4 个子接口的轻量化气象服务
大数据·api·天气预报
用户298698530141 天前
Python 如何实现 Word 与 RTF 文档互转
后端·python·api
VIP_CQCRE2 天前
用 Ace Data Cloud 一次接入 AI 视频生成:HappyHorse Videos API 实战指南
人工智能·api·ai视频·acedatacloud
Patrick在香港2 天前
Python 抓 0.91 GB 香港法例:Agent 的进度该写进磁盘,不是写进上下文
爬虫·python·api·claude·香港
IT·陈寒2 天前
JavaScript实战技巧总结
人工智能·大模型·api·创业·变现·简历优化
咬代码的兽2 天前
Qwen3.8-Omni-Flash 发布:1M 上下文 + 原生全模态,四步跑通音视频 API
人工智能·大模型·api·qwen
VIP_CQCRE4 天前
用 Ace Data Cloud 快速接入 OpenAI 语音识别:一行 base_url 改造,让音频转文字更简单
ai·openai·api·语音识别·acedatacloud
VIP_CQCRE4 天前
用 Ace Data Cloud 快速接入 Gemini Chat Completion API:让多模型调用像 OpenAI 一样简单
大模型·api·gemini·ai开发·ace data cloud
IT·陈寒5 天前
Redis 连接池泄漏害我加班到凌晨三点
人工智能·大模型·api·创业·变现·简历优化