功能说明
该示例解决网络重试导致重复创建的问题。调用方为每次写操作提供幂等键与请求指纹,store 在首次执行时记录 handler 的 Promise,相同键与相同指纹的并发或后续调用直接复用该 Promise,从而只执行一次副作用;同一键携带不同指纹时抛出冲突错误。记录在 TTL 内有效,并可通过 prune 清理过期项。
关键代码
records 以键为索引保存指纹、到期时间与进行中的 Promise。第一次调用在同步阶段就把记录写入 Map,因此后续并发调用一定能看到它,不会产生竞态窗口。handler 被包进 Promise.resolve().then,确保同步抛错也转换为拒绝。失败时仅当 Map 中仍是当前记录才删除,允许调用方用同一键重试;成功后记录保留到 TTL,后续调用直接重放结果。
使用注意
缓存的只是 Promise 结果,不会真正重放 HTTP 响应头或状态码,接入 HTTP 层时应把状态码与响应体一起返回并保存。指纹应覆盖所有影响结果的字段,简单拼接原始请求体可能受键顺序影响,建议使用规范化 JSON 后哈希。状态保存在单进程内存,多实例部署需要共享存储与原子占位。TTL 从开始执行时计算,长任务可能在执行完成前过期。
验证方式
并发调用两次相同键与指纹,断言 handler 只执行一次且两个 Promise 结果一致;任务失败后再次调用,确认会重新执行;成功后推进时钟越过 TTL,再次调用触发新执行;同键不同指纹断言 IdempotencyConflictError;检查 size 会清理过期记录;测试空键、空指纹与非函数 handler 抛 TypeError。
代码示例
interface StoredRecord {
fingerprint: string;
expiresAt: number;
promise: Promise<unknown>;
}
export interface IdempotencyOptions {
ttlMs?: number;
now?: () => number;
}
export class IdempotencyConflictError extends Error {
constructor(key: string) {
super("idempotency key reused with a different fingerprint: " + key);
this.name = "IdempotencyConflictError";
}
}
export class IdempotencyKeyStore {
private readonly records = new Map<string, StoredRecord>();
private readonly ttlMs: number;
private readonly now: () => number;
constructor(options: IdempotencyOptions = {}) {
const ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
this.ttlMs = ttlMs;
this.now = options.now ?? Date.now;
}
get size(): number {
this.prune();
return this.records.size;
}
async execute<T>(
key: string,
fingerprint: string,
handler: () => Promise<T>,
): Promise<T> {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (typeof fingerprint !== "string" || fingerprint.length === 0) {
throw new TypeError("fingerprint must be a non-empty string");
}
if (typeof handler !== "function") throw new TypeError("handler must be a function");
const now = this.now();
if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");
const existing = this.records.get(key);
if (existing) {
if (now < existing.expiresAt) {
if (existing.fingerprint !== fingerprint) {
throw new IdempotencyConflictError(key);
}
return (await existing.promise) as T;
}
this.records.delete(key);
}
const record: StoredRecord = {
fingerprint,
expiresAt: now + this.ttlMs,
promise: Promise.resolve().then(handler),
};
this.records.set(key, record);
try {
return (await record.promise) as T;
} catch (error) {
if (this.records.get(key) === record) this.records.delete(key);
throw error;
}
}
prune(): number {
const now = this.now();
let removed = 0;
for (const [key, record] of this.records) {
if (now >= record.expiresAt) {
this.records.delete(key);
removed += 1;
}
}
return removed;
}
}