PAYATHON 2026

如何追踪 payment gateway 请求的 503 timeout error?

支付阿杰

结论

仅凭 503,无法判断支付是否失败,也无法确认客户端是否发生了超时。

要区分具体情况,需要同时记录 HTTP 状态码、请求耗时、网络异常、支付业务流水号和响应内容:

  • 收到 HTTP 503 Service Unavailable:支付网关或其上游服务暂时不可用。
  • 客户端等待时间超过设定值:发生本地请求超时,客户端可能没有收到任何 HTTP 响应。
  • 连接被重置、DNS 失败或 TLS 失败:这些是网络错误,不属于 HTTP 503。
  • 请求虽然超时,但支付已经被网关受理:支付结果暂时无法确定,不能直接判定为失败。

处理支付接口时需要特别注意:超时只表示没有及时拿到结果,不代表交易没有发生。

建议记录哪些信息

每次发起支付前,应先在系统中生成唯一的业务流水号,例如 merchantReference。日志至少需要包含:

  • 业务流水号
  • 请求开始和结束时间
  • 请求耗时
  • 请求地址和 HTTP 方法
  • HTTP 状态码
  • 响应头中的请求追踪 ID(如果有)
  • 响应正文或错误码
  • 网络异常类型
  • 本地订单号
  • 重试次数

日志中不要保存完整银行卡号、CVV、密码、访问令牌等敏感信息。支付日志应当脱敏,并设置严格的访问权限。

如何判断是哪一种超时

收到了 503 响应

如果程序拿到了 HTTP 响应,状态码也是 503,说明请求已经到达某个 HTTP 服务,但该服务暂时无法处理。

可以进一步检查:

  • 是否有 Retry-After 响应头;
  • 响应正文是否包含网关错误码;
  • 响应头中是否有 request ID 或 correlation ID;
  • 同一时间段内是否有大量请求出现相同错误。

503 通常表示服务暂时不可用。按照 HTTP 标准,更直接表示网关等待上游超时的是 504 Gateway Timeout。不过,具体支付服务也可能用 503 表示内部依赖超时,因此要以 Ezidebit 实际返回的响应内容和官方说明为准。

没有收到任何 HTTP 响应

如果客户端抛出 TimeoutException、AbortError、socket timeout 等异常,同时没有 HTTP 状态码,超时可能发生在客户端、网络链路或连接层。

此时,网关可能已经收到并处理了请求,只是响应没有成功返回。交易状态应标记为“待确认”,不能马上重新扣款。

推荐的处理流程

  1. 创建支付请求前,生成唯一的业务流水号。
  2. 将订单状态改为“支付处理中”。
  3. 设置合理的连接超时和响应超时。
  4. 完整记录响应状态、请求耗时和异常信息。
  5. 正常收到成功结果后,更新支付状态。
  6. 遇到 503、504 或网络超时时,将状态改为“待确认”。
  7. 使用业务流水号或支付网关返回的交易标识查询交易状态。
  8. 如果 Ezidebit 支持异步通知,结合 webhook 更新最终状态。
  9. 如果查询后仍无法确认,进入延迟重查或人工对账流程。
  10. 只有确认原请求没有创建交易后,才能重新发起支付。

查询接口、业务流水号字段和 webhook 能力取决于所使用的 Ezidebit API 产品及版本,需要对照相应的接口文档确认。

Node.js 示例

下面的示例用于区分本地超时、HTTP 503 和其他 HTTP 错误。接口地址和请求字段只是结构示例,需要替换成实际使用的 Ezidebit 接口。

import crypto from "node:crypto";

async function submitPayment(paymentData) {
  const merchantReference = crypto.randomUUID();
  const controller = new AbortController();
  const startedAt = Date.now();

  const timeout = setTimeout(() => {
    controller.abort();
  }, 15000);

  try {
    const response = await fetch(process.env.PAYMENT_GATEWAY_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${process.env.PAYMENT_API_TOKEN}`
      },
      body: JSON.stringify({
        merchantReference,
        ...paymentData
      }),
      signal: controller.signal
    });

    const elapsedMs = Date.now() - startedAt;
    const responseBody = await response.text();

    console.info({
      event: "payment_gateway_response",
      merchantReference,
      status: response.status,
      elapsedMs,
      retryAfter: response.headers.get("retry-after"),
      gatewayRequestId:
        response.headers.get("x-request-id") ??
        response.headers.get("x-correlation-id"),
      responseBody: redactSensitiveData(responseBody)
    });

    if (response.ok) {
      return {
        state: "completed",
        merchantReference,
        responseBody
      };
    }

    if (response.status === 503 || response.status === 504) {
      return {
        state: "pending_confirmation",
        merchantReference,
        reason: `gateway_http_${response.status}`
      };
    }

    return {
      state: "failed",
      merchantReference,
      reason: `gateway_http_${response.status}`,
      responseBody
    };
  } catch (error) {
    const elapsedMs = Date.now() - startedAt;

    if (error.name === "AbortError") {
      console.warn({
        event: "payment_gateway_timeout",
        merchantReference,
        elapsedMs
      });

      return {
        state: "pending_confirmation",
        merchantReference,
        reason: "client_timeout"
      };
    }

    console.error({
      event: "payment_gateway_network_error",
      merchantReference,
      elapsedMs,
      errorName: error.name,
      errorMessage: error.message
    });

    return {
      state: "pending_confirmation",
      merchantReference,
      reason: "network_error"
    };
  } finally {
    clearTimeout(timeout);
  }
}

function redactSensitiveData(value) {
  return value
    .replace(/("cardNumber"\s*:\s*")[^"]+(")/gi, "$1***$2")
    .replace(/("cvv"\s*:\s*")[^"]+(")/gi, "$1***$2")
    .replace(/("token"\s*:\s*")[^"]+(")/gi, "$1***$2");
}

调用方应根据返回状态采取不同的处理方式:

const result = await submitPayment(paymentData);

switch (result.state) {
  case "completed":
    await markOrderAsPaid(result.merchantReference);
    break;

  case "pending_confirmation":
    await markOrderAsPending(result.merchantReference);
    await schedulePaymentStatusCheck(result.merchantReference);
    break;

  case "failed":
    await markOrderAsFailed(result.merchantReference, result.reason);
    break;
}

关于自动重试

支付请求不能像普通查询请求那样无条件重试。第一次请求可能已经完成扣款,直接重试可能造成重复交易。

只有满足以下条件时,自动重试才相对安全:

  • 接口明确支持 idempotency key;
  • 每次重试都使用相同的幂等键或业务流水号;
  • 网关保证相同幂等键不会重复创建交易;
  • 或者已经通过状态查询确认原请求没有成功。

对于明确允许重试的 503,可以采用指数退避,例如依次等待 1s、2s、4s,并加入少量随机抖动。如果响应中包含 Retry-After,应优先按照该值等待。支付创建请求能否重试,仍要以对应接口的幂等规则为准。

排查时的注意事项

  • 确认 503 是由 Ezidebit 返回的,还是由网站前面的 Nginx、负载均衡器、CDN 或云平台返回的。
  • 对比应用日志、反向代理日志和支付请求时间,查清错误发生在哪一层。
  • 保存网关提供的 request ID。联系支持人员时,一并提供请求时间、时区和业务流水号。
  • 不要把“待确认”直接显示为“支付失败”。可以提示用户“支付结果确认中,请勿重复支付”。
  • 建立定时对账任务,用于处理 webhook 丢失、响应超时和长时间未确认的订单。
  • 503 的响应正文可能是 HTML,也可能是 JSON。解析前应检查 Content-Type,避免因错误页格式不符而再次触发解析异常。

备注:内容仅供参考。