skills/idempotency-handling/references/express-idempotency-middleware.md

4.3 KiB

Express Idempotency Middleware

Express Idempotency Middleware

import express from "express";
import Redis from "ioredis";
import crypto from "crypto";

interface IdempotentRequest {
  key: string;
  status: "processing" | "completed" | "failed";
  response?: any;
  error?: string;
  createdAt: number;
  completedAt?: number;
}

class IdempotencyService {
  private redis: Redis;
  private ttl = 86400; // 24 hours

  constructor(redisUrl: string) {
    this.redis = new Redis(redisUrl);
  }

  async getRequest(key: string): Promise<IdempotentRequest | null> {
    const data = await this.redis.get(`idempotency:${key}`);
    return data ? JSON.parse(data) : null;
  }

  async setRequest(key: string, request: IdempotentRequest): Promise<void> {
    await this.redis.setex(
      `idempotency:${key}`,
      this.ttl,
      JSON.stringify(request),
    );
  }

  async startProcessing(key: string): Promise<boolean> {
    const request: IdempotentRequest = {
      key,
      status: "processing",
      createdAt: Date.now(),
    };

    // Use SET NX to ensure only one request processes
    const result = await this.redis.set(
      `idempotency:${key}`,
      JSON.stringify(request),
      "EX",
      this.ttl,
      "NX",
    );

    return result === "OK";
  }

  async completeRequest(key: string, response: any): Promise<void> {
    const request: IdempotentRequest = {
      key,
      status: "completed",
      response,
      createdAt: Date.now(),
      completedAt: Date.now(),
    };

    await this.setRequest(key, request);
  }

  async failRequest(key: string, error: string): Promise<void> {
    const request: IdempotentRequest = {
      key,
      status: "failed",
      error,
      createdAt: Date.now(),
      completedAt: Date.now(),
    };

    await this.setRequest(key, request);
  }
}

function idempotencyMiddleware(idempotency: IdempotencyService) {
  return async (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
  ) => {
    // Only apply to POST, PUT, PATCH, DELETE
    if (!["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
      return next();
    }

    const idempotencyKey = req.headers["idempotency-key"] as string;

    if (!idempotencyKey) {
      return res.status(400).json({
        error: "Idempotency-Key header required",
      });
    }

    // Check for existing request
    const existing = await idempotency.getRequest(idempotencyKey);

    if (existing) {
      if (existing.status === "processing") {
        return res.status(409).json({
          error: "Request already processing",
          message: "Please wait and retry",
        });
      }

      if (existing.status === "completed") {
        return res.status(200).json(existing.response);
      }

      if (existing.status === "failed") {
        return res.status(500).json({
          error: "Previous request failed",
          message: existing.error,
        });
      }
    }

    // Start processing
    const canProcess = await idempotency.startProcessing(idempotencyKey);

    if (!canProcess) {
      return res.status(409).json({
        error: "Request already processing",
      });
    }

    // Capture response
    const originalSend = res.json.bind(res);
    res.json = (body: any) => {
      // Save response for future requests
      idempotency.completeRequest(idempotencyKey, body).catch(console.error);
      return originalSend(body);
    };

    // Handle errors
    const originalNext = next;
    next = (err?: any) => {
      if (err) {
        idempotency
          .failRequest(idempotencyKey, err.message)
          .catch(console.error);
      }
      return originalNext(err);
    };

    next();
  };
}

// Usage
const app = express();
const redis = new Redis("redis://localhost:6379");
const idempotency = new IdempotencyService("redis://localhost:6379");

app.use(express.json());
app.use(idempotencyMiddleware(idempotency));

app.post("/api/payments", async (req, res) => {
  const { amount, userId } = req.body;

  // Process payment
  const payment = await processPayment(amount, userId);

  res.json(payment);
});

async function processPayment(amount: number, userId: string) {
  // Payment processing logic
  return {
    id: crypto.randomUUID(),
    amount,
    userId,
    status: "completed",
  };
}

app.listen(3000);