如何使用 express-idempotency-middleware 防止 Node.js/Express 中的重复收费、订单和表单提交。

49 阅读5分钟

当用户双击“支付”、网络中断、页面重新加载或浏览器重试请求时,您的后端可能会意外创建重复操作:额外收费、重复订单、重复预订。安全的答案是幂等性——确保相同的操作只运行一次。在本文中,我们将使用 在 Express 中启用幂等性express-idempotency-middleware。

TL;DR 客户端发送Idempotency-Key: 。 如果再次收到具有相同密钥的相同请求,则服务器将返回相同的响应并将其标记为。Idempotency-Status: cached 如果相同的密钥通过不同的请求(不同的主体/路线/自定义指纹)到达,则服务器返回409 冲突。 安装 npm i express-idempotency-middleware

or

pnpm add express-idempotency-middleware 适用于 Express 4/5。该软件包已支持 ESM。

快速启动 import express from "express"; import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";

const app = express(); app.use(express.json());

const store = new MemoryStore(); // for production prefer Redis/DB store

app.post( "/orders", idempotencyMiddleware({ store }), (req, res) => { // your business logic (create order / charge card / etc.) res.status(201).json({ id: 123, ...req.body }); } );

app.listen(3000, () => console.log("http://localhost:3000")); 会发生什么:

第一个给定的请求Idempotency-Key执行您的处理程序,并且响应被缓存 TTL(默认为 24​​ 小时)。 稍后具有相同密钥的相同请求将返回带有标头的缓存响应: Idempotency-Status: cached Idempotency-Replayed: true 如果有效载荷/路线/指纹不同,服务器将返回409 冲突。 尝试一下curl KEY=$(uuidgen) # any unique string works

curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":1}'

← 201 Created

Idempotency-Status: created

curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":1}'

← 201 Created

Idempotency-Status: cached

Idempotency-Replayed: true

curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":2}'

← 409 Conflict

Idempotency-Status: conflict

你为什么想要这个 付款、订单、预订、注册。 Webhook 消费者不得两次处理同一事件。 任何重复都会影响金钱、库存或信任的关键路径。 核心思想 幂等性密钥。客户端生成的唯一密钥(通常为 UUID v4)。

指纹。请求的稳定哈希值:方法 + 路由、规范化主体、可选查询以及自定义因素(例如租户)。

缓存 vs 冲突。相同密钥 + 相同指纹 ⇒重放;相同密钥 + 不同指纹 ⇒ 409。5xx

安全。5xx响应不会被缓存;密钥恢复为“free”,因此重试可以成功。

配置 import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";

app.post( "/pay", idempotencyMiddleware({ store: new MemoryStore(),

// Which HTTP methods are guarded (default: ["POST"])
methods: ["POST", "PUT"],

// Require Idempotency-Key header (default: false)
requireKey: true,

// Time-to-live for cached responses (default: 24h)
ttlMs: 24 * 60 * 60 * 1000,

// What to do with concurrent identical requests
inFlight: {
  strategy: "wait",     // or "reject"
  waitTimeoutMs: 5000,  // only for "wait"
  pollMs: 50
},

// What contributes to the fingerprint
fingerprint: {
  includeQuery: false,  // default false
  maxBodyBytes: 64 * 1024,
  custom: (req) => String(req.headers["x-tenant-id"] || "")
},

// Which headers may be replayed from cache
replay: {
  headerWhitelist: ["location"] // content-type is always replayed
}

}), (req, res) => { // critical operation (charge, create booking, etc.) res.setHeader("Location", "/pay/tx-123"); res.status(201).json({ ok: true }); } ); 并发策略 inFlight.strategy: "reject"→ 当第一个请求仍在运行时,第二个相同的请求将获得409错误Idempotency-Status: inflight。 inFlight.strategy: "wait"→ 第二个请求等待第一个请求完成,然后接收缓存的响应。 指纹选项 includeQuery— 将查询字符串包含到指纹中(对某些 API 有用)。 custom(req)— 添加任何特定于域的内容(例如tenantId,userId)。 主体被规范化(键顺序、修剪字符串)并被截断maxBodyBytes。 行为摘要(标题) 第一次成功尝试:

Idempotency-Key: Idempotency-Status: created Idempotency-Replayed: false 重播时:

Idempotency-Key: Idempotency-Status: cached Idempotency-Replayed: true 发生冲突时(相同密钥,不同指纹):

Idempotency-Status: conflict 进行中(并发,“拒绝”):

Idempotency-Status: inflight Retry-After: 1 使用真实存储(Redis) MemoryStore对于本地/开发环境来说很棒,但生产环境通常需要多个实例。使用 Redis 支持存储(草图):

import { createClient } from "redis"; import type { Store, CachedResponse, BeginResult } from "express-idempotency-middleware";

class RedisStore implements Store { constructor(private r = createClient()) {} async begin(key: string, fp: string, ttlMs: number): Promise { await this.r.connect();

// SETNX to claim in-flight; EX sets TTL (seconds)
const claimed = await this.r.set(`idem:${key}:lock`, fp, { NX: true, EX: Math.ceil(ttlMs/1000) });
if (claimed) return { kind: "started" };

const cachedRaw = await this.r.get(`idem:${key}:resp`);
if (!cachedRaw) return { kind: "inflight" };
const cached = JSON.parse(cachedRaw) as CachedResponse;
return cached.fingerprint === fp ? { kind: "replay", cached } : { kind: "conflict" };

} async commit(key: string, data: CachedResponse): Promise { const ttl = await this.r.ttl(idem:${key}:lock); const sec = ttl > 0 ? ttl : 60; await this.r.set(idem:${key}:resp, JSON.stringify(data), { EX: sec }); } async get(key: string) { const raw = await this.r.get(idem:${key}:resp); return raw ? (JSON.parse(raw) as CachedResponse) : null; } async abort(key: string) { await this.r.del(idem:${key}:lock); } } 确切的 Redis 逻辑由您决定;关键点是声明飞行中锁并使用 TTL 存储最终响应。

测试技巧 该软件包专为与 Supertest/Vitest 进行集成测试而设计。并发请求示例模式:

import express from "express"; import request from "supertest"; import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";

function makeBlockingApp() { const app = express(); app.use(express.json()); const store = new MemoryStore();

let release!: () => void; const gate = new Promise((r) => (release = r));

app.post("/slow", idempotencyMiddleware({ store, inFlight: { strategy: "wait", waitTimeoutMs: 3000, pollMs: 10 }}), async (_req, res) => { await gate; res.status(201).json({ ok: true }); } );

return { app, release }; }

it("second concurrent call receives cached", async () => { const { app, release } = makeBlockingApp(); const key = "k1";

const first = request(app).post("/slow").set("Idempotency-Key", key).send({ a: 1 }); const second = request(app).post("/slow").set("Idempotency-Key", key).send({ a: 1 });

setTimeout(() => release(), 100);

const r2 = await second.expect(201); expect(r2.headers["idempotency-status"]).toBe("cached"); await first.expect(201); }); 常见陷阱 客户端必须生成密钥并重复使用该密钥执行相同的操作。如果客户端每次重试时都更改密钥,则无法实现幂等性。 不要缓存 5xx 错误——这由中间件处理。5xx 错误不应该“冻结”密钥;后续重试应该能够成功。 重放头会被过滤:默认情况下,仅content-type重放;用于replay.headerWhitelist安全的附加内容(例如location)。Cookie 和身份验证永远不会被重放。 包起来 有了express-idempotency-middleware, “一键一结果” 将成为默认设置:第一个成功的响应将被安全缓存并重放,而冲突的重试将被拒绝。将其添加到支付流程、订单、预订和 Webhook 消费者中,以消除意外重复,并确保您的 API 在实际网络条件下具有可预测性。

npm:express-idempotency-middleware GitHub:搜索存储库名称或适应您的组织 如果您构建了 Redis 或 Postgres 存储,请考虑发布它——社区将会欣赏可插入的后端。查看更多www.mjsyxx.com