Make write endpoints retry-safe and abuse-resistant — in a few lines.
Two primitives every write API eventually needs, done right and dependency-free:
withIdempotency— run an operation at most once per key. The StripeIdempotency-Keypattern: networks retry, clients double-click, webhooks redeliver — and you still charge/send/create exactly once.RateLimiter— a sliding-window rate limiter (smoother than fixed windows, no boundary bursts).
Both run on a tiny pluggable async Store. An in-memory store is included; implement the same interface over Redis/Postgres for distributed use — nothing above the store changes.
- Zero runtime dependencies. TypeScript, ESM, fully typed.
- Deterministic + offline-testable. Inject the clock; no real time or network needed in tests.
- Correctness-first. The one operation that must be atomic lives in the store (a SETNX / Lua script in Redis) — not smeared across app code.
npm install github:Shailesh93602/idempotency-kitNot on npm yet — the
idempotency-kitname there belongs to an unrelated package, so install from the repo. It builds itself on install (prepare), and imports asidempotency-kit.
import { withIdempotency, MemoryIdempotencyStore } from "idempotency-kit";
const store = new MemoryIdempotencyStore(); // swap for a Redis-backed store in prod
async function createCharge(req) {
const { value, replayed } = await withIdempotency(
req.headers["idempotency-key"],
() => psp.charge(req.body), // runs at most once per key
{ store },
);
return { charge: value, replayed };
}- First call runs
psp.chargeand stores the result. - Any retry with the same key returns the stored result (
replayed: true) — no second charge. - Two concurrent calls race on the store; exactly one runs. The other fails fast with
IdempotencyInProgressError(return a 409), or setwaitRetriesto have it wait for the winner and replay:
await withIdempotency(key, fn, { store, waitRetries: 10, waitIntervalMs: 100 });- If
fnthrows, the claim is released so a later retry can run — a transient failure doesn't permanently poison the key. - Non-JSON results? Pass
serialize/deserialize.
A subtle, dangerous bug: a client reuses an idempotency key for a different payload (a coding mistake, or a key generated too coarsely). Replaying the first result would silently return the wrong answer — e.g. confirm a $10 charge when the caller asked for $99. Stripe rejects this with a 400; so does this kit when you pass a request fingerprint:
import {
withIdempotency,
fingerprint,
IdempotencyFingerprintMismatchError,
} from "idempotency-kit";
try {
await withIdempotency(key, () => psp.charge(req.body), {
store,
fingerprint: fingerprint(req.body), // canonical hash of the payload
});
} catch (e) {
if (e instanceof IdempotencyFingerprintMismatchError) {
return res.status(400).json({
error: "Idempotency-Key already used with different parameters",
});
}
throw e;
}fingerprint() is order-independent ({a,b} and {b,a} match), recurses into nested objects/arrays, and is runtime-agnostic (no node:crypto, so it works on edge/Deno/browsers). It honours toJSON (so a Date hashes by its ISO string, matching the serialized payload), and brands Map/Set/class instances so they can't collapse into each other. It guards against accidental reuse, not adversarial collisions. The fingerprint is captured on first claim and persists for the key's whole lifetime — a same-payload retry still replays cleanly.
import { RateLimiter, MemoryRateLimitStore } from "idempotency-kit";
const limiter = new RateLimiter({
store: new MemoryRateLimitStore(),
limit: 100,
windowMs: 15 * 60 * 1000, // 100 requests / 15 min
});
const { allowed, remaining, retryAfterMs } = await limiter.check(req.ip);
if (!allowed) {
res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000));
return res.status(429).end();
}- Sliding window, so 100/15min can't be bypassed by bunching requests at a window edge.
- Rejected requests don't consume slots, so a flood can't keep its own window full forever.
checkreturns{ allowed, limit, remaining, resetAt, retryAfterMs }— everything you need forX-RateLimit-*/Retry-Afterheaders.
Implement the small interface and stay correct across instances:
interface IdempotencyStore {
claim(key, now, fingerprint?): Promise<IdempotencyRecord | null>; // atomic insert-if-absent (SETNX), storing the fingerprint
complete(key, result, now): Promise<void>; // keep the claim's fingerprint
release(key): Promise<void>;
get(key): Promise<IdempotencyRecord | null>;
}
interface RateLimitStore {
// atomic: prune window, allow+record if under limit, else reject (a Redis Lua script)
record(key, now, windowMs, limit): Promise<RateLimitDecision>;
}The atomic step is deliberately the only thing the store does — that's the part a distributed system must get right, and it's where you'd drop a Lua script.
Both in-memory stores bound their own memory: idempotency keys are unique per request and rate-limit keys are unbounded (per-IP), so expired records and drained windows are actively evicted by an amortized sweep rather than left to accumulate. Tune with new MemoryIdempotencyStore(ttlMs, sweepEvery) / new MemoryRateLimitStore(sweepEvery); store.size exposes the live count.
A reliable write endpoint needs both: idempotency so correct clients can safely retry, and rate limiting so abusive ones can't take you down. They're the patterns behind real payment integrations (Stripe/Razorpay idempotency keys), an inventory reservation engine's exactly-once guarantee, and a production per-IP API limiter.
MIT