The Developer's Guide to Safe Retries

Idempotent API

Ensures identical requests produce the same result, enabling safe retries.

Overview

Idempotent Flow

Usage

Client Side

Include Idempotency-Key header with unique key per logical operation:
// First request - creates user
const response = await fetch("/api/v1/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(), // e.g. "idx_abc123"
  },
  body: JSON.stringify({ email: "user@example.com", name: "John" }),
});

// Same key - returns cached response (no duplicate creation)
const retry = await fetch("/api/v1/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": "idx_abc123", // Same key!
  },
  body: JSON.stringify({ email: "user@example.com", name: "John" }),
});

Key Format

idx_<ulid>  // Recommended: index + ULID
req_<uuid>  // Alternative: request + UUID
<uuid>      // Simple UUID
Key rules:
  • Max 256 characters
  • Must be URL-safe
  • Client generates and manages

How It Works

1. Middleware Intercepts

// idempotency-middleware.ts
export async function idempotencyMiddleware(c, next) {
  const key = c.req.header("Idempotency-Key");

  if (!key) {
    return c.json({ error: "Idempotency-Key required" }, 400);
  }

  const cached = await cache.get(`idx:${key}`);
  if (cached) {
    return c.json(cached.body, cached.status);
  }

  await next();

  // Store response after handler runs
  const response = c.get("response");
  await cache.set(
    `idx:${key}`,
    {
      body: response.body,
      status: response.status,
    },
    TTL,
  );
}

2. Response Caching

Cached data:
  • HTTP status code
  • Response body
  • Headers (except sensitive ones)

3. TTL Expiration

TTL = 86400 seconds (24 hours)
After TTL, key is purged — safe for new operations.

Supported Operations

MethodSafe to Retry?Notes
POST✅ YesCreate operations
PUT✅ YesUpdate operations
PATCH✅ YesPartial updates
DELETE⚠️ CautionMay cause duplicate deletes
GET❌ NoNot cached (idempotent by nature)

Error Handling

Missing Key (400)

{
  "error": "Idempotency-Key header is required"
}

Invalid Key Format (400)

{
  "error": "Idempotency-Key must be a valid string (max 256 chars)"
}

Conflict (409)

If request is in progress with same key:
{
  "error": "Request with this Idempotency-Key is already being processed"
}

Configuration

IDEMPOTENCY_ENABLED=true
IDEMPOTENCY_TTL=86400

When to Use

Use Cases

  • ✅ User registration
  • ✅ Payment processing
  • ✅ Order creation
  • ✅ Any state-changing operation

Don’t Use For

  • GET requests (already idempotent)
  • ❌ Search queries
  • ❌ Real-time data fetching

Testing

// idempotency.test.ts
describe("Idempotency", () => {
  it("returns cached response for duplicate key", async () => {
    const key = crypto.randomUUID();

    // First request
    const res1 = await fetch("/api/v1/users", {
      method: "POST",
      headers: { "Idempotency-Key": key },
      body: JSON.stringify({ email: "test@example.com" }),
    });

    // Retry
    const res2 = await fetch("/api/v1/users", {
      method: "POST",
      headers: { "Idempotency-Key": key },
      body: JSON.stringify({ email: "test@example.com" }),
    });

    expect(res1.status).toBe(res2.status);
    expect(await res1.json()).toEqual(await res2.json());
  });
});

Best Practices

  1. Generate keys client-side — Never rely on server for key generation
  2. Use ULID for keys — Sortable + unique
  3. Set reasonable TTL — 24h is usually sufficient
  4. Include key in error responses — Helps with debugging
  5. Don’t use sequential keys — Use UUID/ULID to prevent collisions