Warning
This library is a work in progress. The API and behavior may change in future releases.
Idempotent request middleware for Hono, compliant with IETF draft-ietf-httpapi-idempotency-key-header-07.
Note
Not published yet since it has some known gaps against the draft.
# pnpm
pnpm add hono-idempotent-request
# npm
npm install hono-idempotent-request
# yarn
yarn add hono-idempotent-request- Hono >= 4.0.0
import { Hono } from "hono";
import { idempotentRequest } from "hono-idempotent-request";
const app = new Hono()
.use(
"/api",
idempotentRequest({
activationStrategy: (request) => ["POST", "PATCH"].includes(request.method),
// https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07#name-resource
resource: {
satisfiesKeySpec: (key: string) => {
// Validate Idempotency-Key format.
// Other than this example, you can implement your own logic like UUID validation.
return key.length <= 255;
},
getStorageKey: (idempotencyKey: string, request: Request) => {
// Generate a storage key for the request.
// For better isolation / performance, you might want to include user identifiers or other context.
// Returns a plain string (internal conversion to branded type is handled by middleware)
return `${idempotencyKey}:${request.url}`;
},
getFingerprint: async (request: Request) => {
// Generate a fingerprint for the request.
// Returns a string or null. If you want to use structured objects, stringify them.
const payload = await request.json();
return JSON.stringify({ user: payload.userId });
},
},
storage: {
adapter: {
get: async (storageKey) => {
// Retrieve a stored request
return yourStorage.get(storageKey);
},
save: async (request) => {
// Save a new request
// The storageKey is available as request.storageKey
await yourStorage.set(request.storageKey, request);
},
update: async (request) => {
// Update an existing request
// The storageKey is available as request.storageKey
await yourStorage.set(request.storageKey, request);
},
},
},
}),
)
.post("/api/payment", async (c) => {
// Your handler logic
return c.json({ status: "success" });
});Controls when idempotency processing is applied.
"always": Apply idempotency processing to all requests"opt-in-with-key"(default): Only apply when theIdempotency-Keyheader is present(request: Request) => boolean | Promise<boolean>: Custom function to determine activation
activationStrategy: "opt-in-with-key"
// or
activationStrategy: (request) => request.method === "POST"Hooks to modify behavior.
modifyResponse?: (response: Response, type: ResponseType) => Promise<Response> | Response
Modify the response before it's returned to the client. The type parameter indicates why the hook was called:
"success": Successful request processing"retrieved_stored_response": Cached response returned"key_conflict": Concurrent request detected (409)"key_missing": Missing or invalid Idempotency-Key (400)"key_payload_mismatch": Fingerprint mismatch detected (422)"error": General error
hooks: {
modifyResponse: (response, type) => {
// Add custom headers based on response type
if (type === "retrieved_stored_response") {
response.headers.set("X-Idempotent-Replayed", "true");
}
return response;
}
}Resource specification implementation.
-
satisfiesKeySpec(key: string): boolean- Validate the
Idempotency-Keyformat - Return
trueif the key is valid
- Validate the
-
getStorageKey(idempotencyKey: string, request: Request): string | Promise<string>- Generate a storage key from the
Idempotency-Keyand request - Return a plain string (internal conversion to branded type is handled by middleware)
- MUST include the
Idempotency-Keyvalue in the returned string
- Generate a storage key from the
-
getFingerprint(request: Request): string | null | Promise<string | null>- Generate a fingerprint for the request payload
- Return a plain string or
nullif fingerprinting is not needed - If returning structured data, stringify it (e.g.,
JSON.stringify()) - Return
nullif this resource does not use fingerprinting
Storage adapter implementation.
-
get(storageKey: StorageKey): Promise<IdempotentRequest | null>- Retrieve a stored idempotent request by storage key
- Return
nullif not found
-
save(request: UnProcessedIdempotentRequest): Promise<void>- Save a new unprocessed idempotent request
- The request object includes:
storageKey,idempotencyKey,fingerprint,requestMethod,requestPath,createdAt,lockedAt(null),response(null) - Access the storage key via
request.storageKey - Throw
IdempotencyKeyStorageErrorif the key already exists
-
update(request: ProcessingIdempotentRequest | FulfilledIdempotentRequest): Promise<void>- Update an existing idempotent request
- The request object includes all fields including
request.storageKey ProcessingIdempotentRequesthaslockedAt: Dateandresponse: nullFulfilledIdempotentRequesthaslockedAt: nullandresponse: SerializedResponse- Throw
IdempotencyKeyStorageErrorif the key doesn't exist
The middleware returns the following HTTP errors in accordance with the IETF draft:
The Idempotency-Key header is missing or doesn't satisfy the specification.
{
"type": "https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-06#section-4.2",
"title": "Bad Request",
"status": 400,
"detail": "Idempotency-Key header is missing or does not satisfy the specification"
}A request with the same Idempotency-Key is currently being processed.
{
"type": "https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-06#section-4.3",
"title": "Conflict",
"status": 409,
"detail": "A request with the same Idempotency-Key is currently being processed"
}The Idempotency-Key is being reused with a different request payload.
{
"type": "https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-06#section-4.4",
"title": "Unprocessable Content",
"status": 422,
"detail": "Idempotency-Key is reused for a different request payload"
}idempotentRequest(implementation: IdempotentRequestImplementation): MiddlewareHandler
IdempotentRequestImplementation- Configuration interfaceIdempotentRequest- Union type of all request statesUnProcessedIdempotentRequest- Request not yet processed (lockedAt: null,response: null)ProcessingIdempotentRequest- Request currently being processed (lockedAt: Date,response: null)FulfilledIdempotentRequest- Request completed with cached response (lockedAt: null,response: SerializedResponse)
SerializedResponse- Serialized response typeResourceSpecification- Resource specification interfaceStorageAdapter- Storage adapter interfaceStorageKey- Branded type for storage keysIdempotencyFingerprint- Branded type for request fingerprints
All IdempotentRequest variants include these common fields:
storageKey: StorageKey- Key used to retrieve the request from storageidempotencyKey: string- OriginalIdempotency-Keyheader valuefingerprint: IdempotencyFingerprint | null- Request payload fingerprint (null if not used)requestMethod: string- HTTP method (e.g., "POST")requestPath: string- Request URL pathnamecreatedAt: Date- Timestamp when the request record was created
State-specific fields:
lockedAt: Date | null- Processing lock timestamp (Date when processing, null otherwise)response: SerializedResponse | null- Cached response (only present inFulfilledIdempotentRequest)
IdempotencyKeyStorageError- Thrown when storage operations failUnsafeImplementationError- Thrown when implementation violates safety constraints
MIT