When a client retries a timeout request, your API should respond without creating duplicate side effects. That is the difference between a robust system and transactional chaos.

Base pattern

Use one idempotency-key per operation and save state of the first processing:

  • request hash,
  • status,
  • final answer.

If the same key arrives, it responds to what has already been processed.

Guided practical case (TypeScript)

Scenario: payments endpoint. Client makes a retry due to timeout and ends up charging twice.

Minimum implementation:

type IdempotencyRecord = {
  key: string;
  requestHash: string;
  status: "processing" | "done" | "failed";
  response?: unknown;
};

const store = new Map<string, IdempotencyRecord>();

export async function chargePayment(input: { key: string; payload: unknown }) {
  const existing = store.get(input.key);

  if (existing?.status === "done") {
    return existing.response;
  }

  if (existing?.status === "processing") {
    throw new Error("Request still processing, retry later");
  }

  store.set(input.key, {
    key: input.key,
    requestHash: JSON.stringify(input.payload),
    status: "processing",
  });

  try {
    const result = await gatewayCharge(input.payload);
    store.set(input.key, {
      key: input.key,
      requestHash: JSON.stringify(input.payload),
      status: "done",
      response: result,
    });
    return result;
  } catch (error) {
    store.set(input.key, {
      key: input.key,
      requestHash: JSON.stringify(input.payload),
      status: "failed",
    });
    throw error;
  }
}

In production, that store must go to Redis/DB with TTL.

Common errors

  • not persisting key long enough,
  • treat idempotence only in frontend,
  • do not contemplate partial failures.
  • do not validate that the same key comes with the same payload.

Checklist

  • mandatory idempotency-key on critical endpoints
  • idempotency storage with clear TTL
  • retries with backoff + jitter
  • observability by key

Happy reading! ☕

Comments