Idempotency Middleware for Express

raw JSON →
2.0.0 verified Thu Apr 23 auth: no javascript

express-idempotency is an Express.js middleware designed to add idempotency to API routes, inspired by Stripe's implementation. It ensures that repeated requests with the same idempotency key result in the same outcome, preventing duplicate processing of non-idempotent operations. The current stable version is 2.0.0. The package has seen consistent updates, with recent releases focusing on dependency upgrades and security fixes, indicating an active maintenance cadence rather than rapid feature development. Key differentiators include its high level of customization for data adapters, intent validators, and response validators, allowing developers to integrate it deeply with their existing infrastructure and persistence layers. It also provides helpers to detect idempotency hits and report errors, facilitating robust error handling within route handlers. It requires Node.js >=18.0.0 and npm >=9.0.0, aligning with modern JavaScript ecosystem standards. The library ships with TypeScript types, promoting strong typing in projects.

error TypeError: Cannot read properties of undefined (reading 'idempotency')
cause This typically occurs when attempting to use CommonJS `require()` without correctly destructuring the named export `idempotency`, or trying to call the `express-idempotency` module root directly as a function.
fix
For CommonJS, use const { idempotency } = require('express-idempotency'); or const idempotency = require('express-idempotency').idempotency;. For ESM, use import { idempotency } from 'express-idempotency';.
error Idempotency key misuse detected (HTTP 409 Conflict)
cause A request was received with an `Idempotency-Key` that matches a previously processed request, but the current request's method, URL, query parameters, or body differs from the original. This indicates a potential misuse of the idempotency key.
fix
Clients must ensure that when retrying a request, they use the *exact same* Idempotency-Key and *identical* request parameters (method, URL, query, body). If the intent or payload of the request changes, a new, unique Idempotency-Key must be generated and used.
error ReferenceError: getSharedIdempotencyService is not defined
cause The `getSharedIdempotencyService()` helper function, which provides access to the `IdempotencyService` instance, is not recognized in the current scope. This often happens if the `express-idempotency` middleware hasn't been properly initialized in the Express application, or if the function's availability is misunderstood.
fix
Ensure that app.use(idempotency(...)) has been called to initialize the middleware before attempting to call getSharedIdempotencyService(). If using TypeScript, you might need a declare function getSharedIdempotencyService(): ...; statement in a type definition file or at the top of your file to resolve type checking issues if it's implicitly global.
breaking Version 2.0.0 updated the minimum required Node.js version to `>=18.0.0` and npm to `>=9.0.0`. Projects running on older environments must upgrade their Node.js and npm installations.
fix Upgrade Node.js to version 18.0.0 or higher and npm to version 9.0.0 or higher. For example, `nvm install 18 && nvm use 18`.
gotcha The default data adapter provided by `express-idempotency` is an in-memory store, which is explicitly stated as not suitable for production environments due to lack of persistence across restarts and inability to scale. Using it in production can lead to lost idempotency state.
fix Implement a custom `IIdempotencyDataAdapter` using a persistent storage solution like Redis, MongoDB, PostgreSQL, or a distributed cache. The library provides examples for custom adapters, and dedicated adapters might be available (e.g., `express-idempotency-mongo-adapter`).
gotcha The idempotency middleware is designed to always call `next()` in the Express chain, even if an idempotency hit is detected and a cached response is sent. Developers must explicitly check `idempotencyService.isHit(req)` within their route handler and `return` early to prevent accidental execution of business logic for replayed requests.
fix Always include `if (idempotencyService.isHit(req)) { return; }` at the beginning of your route handler's business logic to ensure it's skipped on idempotency hits.
gotcha The `getSharedIdempotencyService()` helper function's availability and exact mechanism can be a source of confusion. It implies a global singleton, which might conflict with certain testing patterns or specific dependency injection setups.
fix Understand that `getSharedIdempotencyService()` provides access to the middleware's internal service instance. For testing, consider mocking this global function or structuring your code to inject the service instance if custom patterns allow. Ensure the middleware is initialized before this function is called.
security The security and reliability of idempotency heavily depend on clients generating truly unique and unpredictable `Idempotency-Key` headers. Reusing the same key for different operations or using weak/predictable keys can lead to security vulnerabilities or bypass the idempotency mechanism.
fix Instruct API clients to generate robustly unique keys, preferably UUID v4 or v5, for each distinct idempotent operation attempt. The same key must be reused only for retries of the *exact same* original request.
npm install express-idempotency
yarn add express-idempotency
pnpm add express-idempotency

This quickstart demonstrates how to install `express-idempotency`, initialize it with a basic in-memory data adapter, and integrate it into an Express route. It highlights how to use `isHit(req)` to prevent re-processing and `reportError(req)` for failed operations, ensuring idempotent behavior for a POST request.

import express from 'express';
import { idempotency, IIdempotencyDataAdapter } from 'express-idempotency';
import { v4 as uuidv4 } from 'uuid';

// IMPORTANT: For production, replace this with a persistent storage solution (Redis, MongoDB, etc.)
// The default in-memory adapter is not suitable for production environments.
class InMemoryDataAdapter implements IIdempotencyDataAdapter {
    private store = new Map<string, { request: any; response: any; status: string }>();

    async get(idempotencyKey: string): Promise<{ request: any; response: any; status: string } | null> {
        return this.store.get(idempotencyKey) || null;
    }

    async set(idempotencyKey: string, request: any, response: any, status: string): Promise<void> {
        this.store.set(idempotencyKey, { request, response, status });
    }

    async remove(idempotencyKey: string): Promise<void> {
        this.store.delete(idempotencyKey);
    }
}

const app = express();
const port = 3000;

// Middleware to parse JSON bodies
app.use(express.json());

// Initialize the idempotency middleware. Always provide a production-ready data adapter.
app.use(
    idempotency({
        dataAdapter: new InMemoryDataAdapter(), // Replace with a real data adapter for production!
        // Other options like idempotencyKeyHeader, intentValidator, responseValidator can be customized here.
    })
);

// Declare `getSharedIdempotencyService` for TypeScript if it's globally available.
// In a real application, consider explicitly importing a service factory if available,
// or accessing a request-scoped service if the middleware attaches it (e.g., `req.idempotencyService`).
declare function getSharedIdempotencyService(): {
    isHit(req: express.Request): boolean;
    reportError(req: express.Request): void;
};

app.post('/process-payment', (req, res) => {
    // Retrieve the IdempotencyService instance.
    const idempotencyService = getSharedIdempotencyService();

    // Crucial: Check if the request is an idempotency hit. If so, prevent further processing.
    if (idempotencyService.isHit(req)) {
        console.log('Idempotency hit detected, a cached response should have been sent by the middleware.');
        // The middleware is designed to send the cached response and then call next().
        // Your route handler should return early here to avoid re-executing business logic.
        return;
    }

    // --- Your business logic starts here (only for non-idempotent requests) ---
    console.log('Processing new payment for:', req.body);
    const transactionId = uuidv4();
    const amount = req.body.amount;

    if (amount <= 0) {
        // If an error occurs during processing, report it to the middleware
        // so that the idempotency state for this key can be cleared or updated.
        idempotencyService.reportError(req);
        return res.status(400).json({ error: 'Amount must be positive.' });
    }

    // Simulate an asynchronous payment processing operation
    setTimeout(() => {
        const responseData = {
            message: `Payment for ${amount} processed successfully.`,
            transactionId: transactionId,
            status: 'completed',
        };
        res.status(200).json(responseData);
        console.log('Payment processed and response sent.');
    }, 1000);
});

app.listen(port, () => {
    console.log(`Server listening at http://localhost:${port}`);
    console.log(`
To test, send a POST request with an 'Idempotency-Key' header:`)
    console.log(`curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: my-unique-key-123" -d '{"amount": 100}' http://localhost:${port}/process-payment`);
    console.log(`
Repeat the curl command with the *same* 'my-unique-key-123' to observe idempotency in action (cached response).`);
    console.log(`Use a *different* key for a new payment.`);
});