hono-idempotency
v0.8.1
Published
Stripe-style Idempotency-Key middleware for Hono. IETF draft-ietf-httpapi-idempotency-key-header compliant.
Downloads
1,469
Maintainers
Readme
hono-idempotency
Stripe-style Idempotency-Key middleware for Hono. IETF draft-ietf-httpapi-idempotency-key-header compliant.
Features
- Idempotency-Key header support for POST/PATCH (configurable)
- Request fingerprinting (SHA-256) prevents key reuse with different payloads
- Concurrent request protection with optimistic locking
- RFC 9457 Problem Details error responses with error codes (
MISSING_KEY,KEY_TOO_LONG,FINGERPRINT_MISMATCH,CONFLICT) - Replayed responses include
Idempotency-Replayed: trueheader - Non-2xx responses are not cached (Stripe pattern — allows client retry)
- Per-request opt-out via
skipRequest - Multi-tenant key isolation via
cacheKeyPrefix - Custom error responses via
onError - Expired record cleanup via
store.purge() - Pluggable store interface (memory, Redis, Cloudflare KV, Cloudflare D1, Durable Objects)
- Works on Cloudflare Workers, Node.js, Deno, Bun, and any Web Standards runtime
Install
# npm
npm install hono-idempotency
# pnpm
pnpm add hono-idempotencyQuick Start
import { Hono } from "hono";
import { idempotency } from "hono-idempotency";
import { memoryStore } from "hono-idempotency/stores/memory";
const app = new Hono();
app.use("/api/*", idempotency({ store: memoryStore() }));
app.post("/api/payments", (c) => {
// This handler only runs once per unique Idempotency-Key.
// Retries with the same key return the cached response.
return c.json({ id: "pay_123", status: "succeeded" }, 201);
});Client usage:
curl -X POST http://localhost:3000/api/payments \
-H "Idempotency-Key: unique-request-id-123" \
-H "Content-Type: application/json" \
-d '{"amount": 1000}'Options
idempotency({
// Required: storage backend
store: memoryStore(),
// Header name (default: "Idempotency-Key")
headerName: "Idempotency-Key",
// Return 400 if header is missing (default: false)
required: false,
// HTTP methods to apply idempotency (default: ["POST", "PATCH"])
methods: ["POST", "PATCH"],
// Maximum key length (default: 256)
maxKeyLength: 256,
// Custom fingerprint function (default: SHA-256 of method + path + body)
fingerprint: (c) => `${c.req.method}:${c.req.path}`,
// Skip idempotency for specific requests
skipRequest: (c) => c.req.path === "/api/health",
// Namespace store keys for multi-tenant isolation
cacheKeyPrefix: (c) => c.req.header("X-Tenant-Id") ?? "default",
// Custom error response handler (default: RFC 9457 Problem Details)
onError: (error, c) => c.json({ error: error.title }, error.status),
});skipRequest
Skip idempotency processing for specific requests. Useful for health checks or internal endpoints.
idempotency({
store: memoryStore(),
skipRequest: (c) => c.req.path === "/api/health",
});cacheKeyPrefix
Namespace store keys to isolate idempotency state between tenants or environments.
idempotency({
store: memoryStore(),
// Static prefix
cacheKeyPrefix: "production",
// Or dynamic per-request prefix
cacheKeyPrefix: (c) => c.req.header("X-Tenant-Id") ?? "default",
});Note: The callback receives Hono's base
Context, so accessing typed variables (e.g.,c.get("userId")) requires a cast:c.get("userId") as string.
onError
Override the default RFC 9457 error responses with a custom handler. Each error includes a code field for programmatic identification:
| Code | Status | Description |
|------|--------|-------------|
| MISSING_KEY | 400 | required: true and no header |
| KEY_TOO_LONG | 400 | Key exceeds maxKeyLength |
| CONFLICT | 409 | Concurrent request with same key |
| FINGERPRINT_MISMATCH | 422 | Same key, different request body |
import type { ProblemDetail } from "hono-idempotency";
idempotency({
store: memoryStore(),
onError: (error: ProblemDetail, c) => {
if (error.code === "FINGERPRINT_MISMATCH") {
return c.json({ error: "Request body changed" }, 422);
}
return c.json({ code: error.code, message: error.title }, error.status);
},
});Use problemResponse as a fallback to keep the default RFC 9457 format for unhandled error codes:
import { problemResponse } from "hono-idempotency";
idempotency({
store: memoryStore(),
onError: (error, c) => {
if (error.code === "CONFLICT") {
return c.json({ retryAfter: 1 }, 409);
}
// Default RFC 9457 response for all other errors
return problemResponse(error);
},
});Stores
Choosing a Store
| | Memory | Redis | Cloudflare KV | Cloudflare D1 | Durable Objects | |---|---|---|---|---|---| | Consistency | Strong (single-instance) | Strong | Eventual | Strong | Strong (single-writer) | | Durability | None (process-local) | Durable | Durable | Durable | Durable | | Lock atomicity | Atomic (in-process Map) | Atomic (SET NX) | Not atomic across edge locations | Atomic (SQL INSERT OR IGNORE) | Atomic (single-writer) | | TTL | In-process sweep | Automatic (EX) | Automatic (expirationTtl) | SQL filter on created_at | Manual (createdAt threshold) | | Setup | None | Redis connection | KV namespace binding | D1 database binding | DO namespace binding | | Best for | Development, single-instance | Node.js / serverless production | Multi-region, low-contention | Multi-region, strong consistency | Cloudflare, strong consistency |
Tip: Start with
memoryStore()for development. For Node.js production, useredisStore. For Cloudflare Workers, usedurableObjectStoreord1Storefor strong consistency, orkvStorefor simpler deployments where occasional duplicate processing is acceptable.
Memory Store
Built-in, suitable for single-instance deployments and development.
import { memoryStore } from "hono-idempotency/stores/memory";
const store = memoryStore({
ttl: 24 * 60 * 60 * 1000, // 24 hours (default)
maxSize: 10000, // max entries, oldest evicted first (optional, default: unlimited)
});Redis Store
For Node.js, serverless, or any environment with Redis. Compatible with ioredis, node-redis, and @upstash/redis.
import { redisStore } from "hono-idempotency/stores/redis";
import Redis from "ioredis";
const store = redisStore({
client: new Redis(),
ttl: 86400, // 24 hours in seconds (default)
});
app.use("/api/*", idempotency({ store }));With Upstash Redis (edge-compatible):
import { redisStore } from "hono-idempotency/stores/redis";
import { Redis } from "@upstash/redis";
const store = redisStore({
client: new Redis({ url: UPSTASH_URL, token: UPSTASH_TOKEN }),
});
app.use("/api/*", idempotency({ store }));Note: Redis
SET NX EXprovides atomic locking — the strongest lock guarantee among all store backends.purge()is a no-op since Redis handles expiration automatically.
Cloudflare KV Store
For Cloudflare Workers with KV. TTL is handled automatically by KV expiration.
import { kvStore } from "hono-idempotency/stores/cloudflare-kv";
type Bindings = { IDEMPOTENCY_KV: KVNamespace };
const app = new Hono<{ Bindings: Bindings }>();
// Store must be created per-request since KV binding comes from c.env
app.use("/api/*", async (c, next) => {
const store = kvStore({
namespace: c.env.IDEMPOTENCY_KV,
ttl: 86400, // 24 hours in seconds (default)
});
return idempotency({ store })(c, next);
});Note: KV is eventually consistent. In rare cases, concurrent requests to different edge locations may both acquire the lock. This is acceptable for most idempotency use cases.
Cloudflare D1 Store
For Cloudflare Workers with D1. Uses SQL for strong consistency. Table is created automatically.
import { d1Store } from "hono-idempotency/stores/cloudflare-d1";
type Bindings = { IDEMPOTENCY_DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
// Store must be created per-request since D1 binding comes from c.env.
// CREATE TABLE IF NOT EXISTS runs each request but is a no-op after the first.
app.use("/api/*", async (c, next) => {
const store = d1Store({
database: c.env.IDEMPOTENCY_DB,
tableName: "idempotency_keys", // default
ttl: 86400, // 24 hours in seconds (default)
});
return idempotency({ store })(c, next);
});Note: D1 provides strong consistency, making
lock()reliable for concurrent request protection.
Durable Objects Store
For Cloudflare Workers with Durable Objects. The single-writer model guarantees atomic locking without additional primitives.
import { durableObjectStore } from "hono-idempotency/stores/durable-objects";
import { DurableObject } from "cloudflare:workers";
export class IdempotencyDO extends DurableObject {
private store;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.store = durableObjectStore({
storage: ctx.storage,
ttl: 24 * 60 * 60 * 1000, // 24 hours in ms (default)
});
}
async fetch(request: Request) {
// Expose store methods via HTTP — or use RPC
}
}Note: DO storage has no native TTL. Expired records are filtered on
get()/lock()and physically removed bypurge(). Callpurge()periodically to reclaim storage.
Purging Expired Records
All stores expose a purge() method that physically removes expired records. This is especially important for D1, where expired rows are logically hidden but remain in storage.
// Cloudflare Workers: use waitUntil for non-blocking cleanup
app.post("/api/payments", async (c) => {
c.executionCtx.waitUntil(store.purge());
return c.json({ ok: true });
});
// Or use a Scheduled Worker for periodic cleanup
export default {
async scheduled(event, env, ctx) {
const store = d1Store({ database: env.IDEMPOTENCY_DB });
ctx.waitUntil(store.purge());
},
};Note:
purge()is a no-op for Redis and KV stores — they handle expiration automatically.
Custom Store
Implement the IdempotencyStore interface:
import type { IdempotencyStore } from "hono-idempotency";
const customStore: IdempotencyStore = {
async get(key) { /* ... */ },
async lock(key, record) { /* return false if already locked */ },
async complete(key, response) { /* ... */ },
async delete(key) { /* ... */ },
async purge() { /* return number of deleted records */ },
};Error Responses
All errors follow RFC 9457 Problem Details with Content-Type: application/problem+json.
| Status | Code | Type | When |
|--------|------|------|------|
| 400 | MISSING_KEY | /errors/missing-key | required: true and no header |
| 400 | KEY_TOO_LONG | /errors/key-too-long | Key exceeds maxKeyLength |
| 409 | CONFLICT | /errors/conflict | Concurrent request with same key |
| 422 | FINGERPRINT_MISMATCH | /errors/fingerprint-mismatch | Same key, different request body |
When hono-problem-details is installed, error responses are generated using its problemDetails().getResponse(). Otherwise, a built-in fallback is used. No configuration needed — detection is automatic.
Accessing the Key in Handlers
The middleware sets idempotencyKey on the Hono context:
import type { IdempotencyEnv } from "hono-idempotency";
app.post("/api/payments", (c: Context<IdempotencyEnv>) => {
const key = c.get("idempotencyKey");
return c.json({ idempotencyKey: key });
});Typed RPC Client
When using Hono's RPC client, merge IdempotencyEnv with your app's Env to get end-to-end type safety:
import { Hono } from "hono";
import { hc } from "hono/client";
import { idempotency } from "hono-idempotency";
import type { IdempotencyEnv } from "hono-idempotency";
import { memoryStore } from "hono-idempotency/stores/memory";
const app = new Hono<IdempotencyEnv>()
.use("/api/*", idempotency({ store: memoryStore() }))
.post("/api/payments", (c) => {
const key = c.get("idempotencyKey"); // typed as string | undefined
return c.json({ id: "pay_123", key });
});
type AppType = typeof app;
// Client knows about all routes and their types
const client = hc<AppType>("http://localhost:3000");Documentation
License
MIT
