Skip to content

About

Make write endpoints retry-safe and abuse-resistant — zero-dependency idempotency wrapper (Stripe Idempotency-Key pattern) + sliding-window rate limiter, with a pluggable async store.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

idempotency-kit

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 Stripe Idempotency-Key pattern: 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.

Install

npm install github:Shailesh93602/idempotency-kit

Not on npm yet — the idempotency-kit name there belongs to an unrelated package, so install from the repo. It builds itself on install (prepare), and imports as idempotency-kit.

Idempotency

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.charge and 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 set waitRetries to have it wait for the winner and replay:
await withIdempotency(key, fn, { store, waitRetries: 10, waitIntervalMs: 100 });
  • If fn throws, 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.

Reject a key reused for a different request

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.

Rate limiting

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.
  • check returns { allowed, limit, remaining, resetAt, retryAfterMs } — everything you need for X-RateLimit-* / Retry-After headers.

Bring your own store (Redis, Postgres, …)

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.

Why these two together

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.

License

MIT

About

Make write endpoints retry-safe and abuse-resistant — zero-dependency idempotency wrapper (Stripe Idempotency-Key pattern) + sliding-window rate limiter, with a pluggable async store.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages