npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hono-idempotency

v0.8.1

Published

Stripe-style Idempotency-Key middleware for Hono. IETF draft-ietf-httpapi-idempotency-key-header compliant.

Downloads

1,469

Readme

hono-idempotency

npm version CI License: MIT CodeRabbit Pull Request Reviews Devin Wiki

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: true header
  • 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-idempotency

Quick 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, use redisStore. For Cloudflare Workers, use durableObjectStore or d1Store for strong consistency, or kvStore for 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 EX provides 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 by purge(). Call purge() 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