PAYATHON 2026

如何安全重试支付服务商的 REST API POST 请求?

支付老李

结论

可以重试。如果支付服务商已经确认重复请求具有幂等性,有限重试通常比只调用一次更可靠。

安全重试需要满足两个条件:

  1. 每次重试都代表同一笔支付,并使用相同的幂等标识。
  2. 只重试网络异常、超时和服务端临时故障,不要对所有失败响应一律重试。

重试次数没有适用于所有项目的固定值。常见做法是设置较短的总超时时间,重试 2~3 次,同时使用指数退避和随机抖动。具体参数要结合支付服务商的限流规则、接口超时要求,以及业务可以接受的等待时间来确定。

为什么 POST 请求也可以重试

HTTP 规范并不保证 POST 请求天然幂等。第一次请求可能已经在服务端完成扣款,只是响应在返回途中丢失。如果此时发送一笔全新的支付请求,就可能造成重复扣款。

能否重试主要取决于接口的业务语义,而不只是 HTTP 方法。只要支付服务商明确保证相同请求会被幂等处理,就可以重试同一笔支付。

还需要确认服务商如何判断请求是否相同,例如:

  • 是否要求传递 Idempotency-Key、订单号或支付请求 ID;
  • 幂等标识的有效期有多长;
  • 使用相同标识但提交不同参数时会如何处理;
  • 幂等结果是否涵盖处理中、成功和失败等状态;
  • 超过幂等有效期后再次请求,是否可能产生新交易。

仅仅重复发送相同的 JSON,不一定能实现幂等。更稳妥的做法是为每笔业务交易生成一个稳定且唯一的幂等键,并在所有重试中复用。

建议的重试策略

可以按以下顺序处理:

  1. 创建支付任务时生成幂等键,并持久化保存。
  2. 首次调用和后续重试使用完全相同的幂等键和业务参数。
  3. 为每次 HTTP 请求设置超时。
  4. 只对可恢复的错误进行有限重试。
  5. 在重试之间使用指数退避,并加入随机抖动。
  6. 用尽重试次数后,将交易标记为“状态未知”,再调用查询接口核实,不要创建一笔新支付。

以下情况通常可以重试:

  • DNS、连接中断、连接重置等网络错误;
  • 客户端等待响应超时;
  • 408 Request Timeout;
  • 429 Too Many Requests;
  • 支付服务商明确允许重试的 5xx 响应。

以下情况通常不应自动重试:

  • 请求参数错误;
  • 身份认证或权限错误;
  • 余额不足、银行卡拒付等明确的业务失败;
  • 其他表明请求不会因重试而成功的 4xx 响应。

500 等响应是否可以重试,仍要以支付服务商的接口约定为准。有些服务端错误可能发生在交易已经执行之后。

Node.js 示例

下面用 fetch 演示有限重试。接口地址、超时时间和重试次数需要根据实际服务商的要求调整。

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function isRetryableStatus(status) {
  return status === 408 ||
         status === 429 ||
         status === 502 ||
         status === 503 ||
         status === 504;
}

function getRetryAfterMs(response) {
  const value = response.headers.get('retry-after');

  if (!value) {
    return null;
  }

  const seconds = Number(value);

  if (Number.isFinite(seconds)) {
    return Math.max(0, seconds * 1000);
  }

  const retryAt = Date.parse(value);

  if (Number.isNaN(retryAt)) {
    return null;
  }

  return Math.max(0, retryAt - Date.now());
}

async function submitPayment({
  endpoint,
  payment,
  idempotencyKey,
  maxAttempts = 3,
  timeoutMs = 5000
}) {
  const body = JSON.stringify(payment);

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey
        },
        body,
        signal: controller.signal
      });

      if (response.ok) {
        return await response.json();
      }

      if (!isRetryableStatus(response.status)) {
        const errorBody = await response.text();
        throw new Error(
          `Payment rejected with HTTP ${response.status}: ${errorBody}`
        );
      }

      if (attempt === maxAttempts) {
        throw new Error(
          `Payment status is uncertain after HTTP ${response.status}`
        );
      }

      const retryAfterMs = getRetryAfterMs(response);
      const exponentialDelay = 250 * (2 ** (attempt - 1));
      const jitter = Math.floor(Math.random() * 200);

      await sleep(retryAfterMs ?? exponentialDelay + jitter);
    } catch (error) {
      const isNetworkOrTimeoutError =
        error.name === 'AbortError' ||
        error instanceof TypeError;

      if (!isNetworkOrTimeoutError || attempt === maxAttempts) {
        throw error;
      }

      const exponentialDelay = 250 * (2 ** (attempt - 1));
      const jitter = Math.floor(Math.random() * 200);

      await sleep(exponentialDelay + jitter);
    } finally {
      clearTimeout(timer);
    }
  }

  throw new Error('Payment status is uncertain');
}

调用时,同一笔支付的所有尝试都必须复用同一个 idempotencyKey:

const result = await submitPayment({
  endpoint: 'https://payment-provider.example/payments',
  payment: {
    orderId: 'ORDER-20260901-001',
    amount: 1000,
    currency: 'CNY'
  },
  idempotencyKey: 'payment-ORDER-20260901-001'
});

生产环境中,不要在每次重试前重新生成幂等键,否则服务端可能把这些请求识别为多笔不同的支付。

需要特别注意的情况

超时只说明客户端没有收到结果,不代表服务端没有处理请求。用尽重试次数后,不能直接认定交易失败,更不能立即更换幂等键重新扣款。此时应保留订单号、幂等键和服务商交易 ID,通过支付查询接口或后续对账确认最终状态。

每次尝试的时间、响应状态、幂等键和关联 ID 都应记录,但不要将银行卡号、安全码、访问令牌等敏感信息写入日志。

如果多个进程或队列消费者可能同时处理同一订单,还需要在本地设置唯一约束、状态机或分布式并发控制。服务商的幂等机制只能作为最后一道保护,不能代替项目自身的订单一致性设计。

备注:内容仅供参考。