两层幂等机制,网络超时后安全重发请求,避免重复下单、退款或履约。
分布式系统里"没有收到响应"不等于"请求没有成功"。你发出下单请求后连接超时,可能是请求根本没到,也可能是订单已经创建、只是响应在回程中丢了——客户端无法区分这两种情况。
KukoPay 提供两层幂等,它们解决的问题不同,建议同时使用。
| 机制 | 位置 | 作用范围 | 有效期 |
|---|---|---|---|
out_trade_no / out_refund_no | 请求体 | 只覆盖下单和退款 | 永久 |
Idempotency-Key | 请求头 | 所有写接口 | 24 小时 |
out_trade_no 是商户订单号,同时也是当前商户、当前环境中的幂等键。同一个值重复提交时,接口返回首次创建的订单,不会重复创建或扣款。
{
"out_trade_no": "ORDER_20260921_0001",
"amount": 2990,
"currency": "USD"
}测试和正式环境相互隔离,因此两个环境可以分别使用相同的 out_trade_no。
正式环境的退款必须提供 out_refund_no 并以它作为幂等键。一次退款的所有重试都使用同一个值;另一笔退款必须换新值。
out_trade_no 有一个盲区:同一个值配上不同的金额时,接口依然返回原订单。
代码里把金额算错这类 bug 会因此一直藏到对账时才暴露。下面的 Idempotency-Key 正是为了堵住它。
在任何写接口上加一个 Idempotency-Key 请求头即可,值由你生成,推荐 UUID v4。平台会存下首次的响应并在 24 小时内原样重放,并且校验请求体是否一致。
curl -X POST "https://web.kukopay.com/api/v1/orders" \
-H "X-Api-Key: kuko_test_你的密钥" \
-H "Idempotency-Key: 8f14e45f-ea2a-4c6f-9d3b-1b7c2a55e901" \
-H "Content-Type: application/json" \
-d '{
"out_trade_no": "ORDER_20260921_0001",
"amount": 2990,
"currency": "USD",
"subject": "Pro 年度订阅"
}'| 规则 | 说明 |
|---|---|
| 取值 | 不超过 255 个可打印 ASCII 字符,推荐 UUID v4 |
| 作用范围 | 按 商户 + 环境 + 接口 隔离,互不干扰 |
| 保留时长 | 24 小时,之后该值可以被重新使用 |
平台原样返回首次的状态码和响应体,并额外带上 Idempotent-Replayed: true。注意 request_id 也是首次那一次的——这正是它有用的地方:你日志里的两条记录会指向同一次真实执行。
HTTP/1.1 201 Created
X-Request-Id: req_9f2c1a7b4e8d0356c1ab77de90f4b215
Idempotent-Replayed: true这是平台会明确拒绝的唯一情况,因为它一定意味着调用方出了 bug。
{
"code": 400,
"error": "idempotency_error",
"type": "idempotency_error",
"message": "该 Idempotency-Key 已被用于内容不同的请求。",
"param": "Idempotency-Key",
"request_id": "req_31b6c0d7f2a94e15b8c3d0a7e6f45219"
}重试时不要重新生成时间戳、随机数或签名字段。如果请求体必然会变,那就不要复用 Key——那本来就是一次新的请求。
首次请求还在处理中时,第二次请求会立刻得到 409 idempotency_in_progress,而不是排队等待。稍后用同一个 Key 重试即可拿到最终结果。
| 响应 | 行为 |
|---|---|
2xx | 存下并在 24 小时内重放 |
4xx | 同样存下并重放——请求已有确定结论,重试不会有不同结果 |
5xx | 不缓存,Key 被释放。用同一个 Key 重试会真正执行 |
async function createOrder(payload) {
// 每一笔业务订单生成一次,重试时复用同一个值
const idempotencyKey = crypto.randomUUID()
const delays = [1000, 2000, 4000, 8000]
for (let attempt = 0; ; attempt++) {
const res = await fetch("https://web.kukopay.com/api/v1/orders", {
method: "POST",
headers: {
"X-Api-Key": process.env.KUKOPAY_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
const body = await res.json()
// 把 request_id 记进日志,反馈问题时平台靠它定位
console.info("kukopay", res.status, body.request_id)
if (res.ok) return body.data
// 409 表示同一个 Key 正在处理中;5xx 表示结果未知——都可以原样重试
const retriable = res.status === 409 || res.status >= 500
if (!retriable || attempt >= delays.length) throw new Error(body.message)
await new Promise((r) => setTimeout(r, delays[attempt]))
}
}能否重试请看 错误码 页的 type 字段:api_error 与 api_connection_error 可以安全重试,invalid_request_error 修正参数前重试没有意义。
Webhook 可能因为超时或非 2xx 响应被重投。请在业务数据库为事件 id 建立唯一约束,并在同一事务内完成"写入事件 ID"和"业务履约"。
不要只在内存里去重,也不要先发货再记录事件。进程重启或并发通知会绕过这两种做法。