如何安全重试支付服务商的 REST API POST 请求?
结论
可以重试。如果支付服务商已经确认重复请求具有幂等性,有限重试通常比只调用一次更可靠。
安全重试需要满足两个条件:
- 每次重试都代表同一笔支付,并使用相同的幂等标识。
- 只重试网络异常、超时和服务端临时故障,不要对所有失败响应一律重试。
重试次数没有适用于所有项目的固定值。常见做法是设置较短的总超时时间,重试 2~3 次,同时使用指数退避和随机抖动。具体参数要结合支付服务商的限流规则、接口超时要求,以及业务可以接受的等待时间来确定。
为什么 POST 请求也可以重试
HTTP 规范并不保证 POST 请求天然幂等。第一次请求可能已经在服务端完成扣款,只是响应在返回途中丢失。如果此时发送一笔全新的支付请求,就可能造成重复扣款。
能否重试主要取决于接口的业务语义,而不只是 HTTP 方法。只要支付服务商明确保证相同请求会被幂等处理,就可以重试同一笔支付。
还需要确认服务商如何判断请求是否相同,例如:
- 是否要求传递
Idempotency-Key、订单号或支付请求 ID; - 幂等标识的有效期有多长;
- 使用相同标识但提交不同参数时会如何处理;
- 幂等结果是否涵盖处理中、成功和失败等状态;
- 超过幂等有效期后再次请求,是否可能产生新交易。
仅仅重复发送相同的 JSON,不一定能实现幂等。更稳妥的做法是为每笔业务交易生成一个稳定且唯一的幂等键,并在所有重试中复用。
建议的重试策略
可以按以下顺序处理:
- 创建支付任务时生成幂等键,并持久化保存。
- 首次调用和后续重试使用完全相同的幂等键和业务参数。
- 为每次 HTTP 请求设置超时。
- 只对可恢复的错误进行有限重试。
- 在重试之间使用指数退避,并加入随机抖动。
- 用尽重试次数后,将交易标记为“状态未知”,再调用查询接口核实,不要创建一笔新支付。
以下情况通常可以重试:
- 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 都应记录,但不要将银行卡号、安全码、访问令牌等敏感信息写入日志。
如果多个进程或队列消费者可能同时处理同一订单,还需要在本地设置唯一约束、状态机或分布式并发控制。服务商的幂等机制只能作为最后一道保护,不能代替项目自身的订单一致性设计。
备注:内容仅供参考。
