DEV Community

Cover image for Retries Without Regret: Idempotency Keys in Spring Boot and Node
Chaminade Dondah ADJOLOU
Chaminade Dondah ADJOLOU

Posted on

Retries Without Regret: Idempotency Keys in Spring Boot and Node

A popular DEV discussion recently asked to retry or not to retry. The honest answer is: retry, but only once you've made it safe. On mobile networks, where requests time out after the server has already done the work, the dangerous case isn't "the request failed". It's "the client doesn't know whether it succeeded."

This post covers three things:

  1. Which requests are safe to retry by definition.
  2. How to make the unsafe ones (usually POST) safe with an idempotency key stored in Postgres, in both Spring Boot and Node.
  3. How to retry politely: exponential backoff with jitter.

1. What HTTP already guarantees

RFC 9110 defines a method as idempotent if the intended effect of several identical requests is the same as one. GET, HEAD, OPTIONS, PUT and DELETE are idempotent; POST and PATCH are not.

That's a contract about intended effect, so your implementation has to honour it (a PUT that appends a row isn't idempotent, whatever the RFC says). But it gives a clear starting point:

Retry or not? matrix

  • Idempotent methods: retry with backoff.
  • POST with an idempotency key: retry with the same key.
  • POST without a key: don't auto-retry. Query the status first.
  • 4xx like 400/401/403/422: don't retry; the request itself is wrong.
  • 429 / 503: retry, honour Retry-After if present, add jitter.

2. Idempotency keys

The pattern, popularised by payment APIs like Stripe and being standardised by the IETF HTTPAPI working group as the Idempotency-Key header (an Internet-Draft, not yet an RFC), is simple:

  • The client generates a unique key (a UUID) once per logical operation and sends it on every attempt.
  • The server records the key before doing the work. If it sees the same key again, it returns the stored result instead of redoing the work.

Idempotency-Key request lifecycle

The table

CREATE TABLE idempotency_keys (
  key           text        NOT NULL,
  user_id       bigint      NOT NULL,
  request_hash  text        NOT NULL,
  status        text        NOT NULL DEFAULT 'in_progress', -- in_progress | done
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, key)
);
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • Scope keys per user/client. Two users must never collide on the same key.
  • Store a hash of the request body. If the same key arrives with a different payload, that's a client bug: reject it (Stripe and the IETF draft both treat key reuse with a different payload as an error; the draft suggests 422).

Claiming a key atomically

Postgres' INSERT ... ON CONFLICT DO NOTHING gives you an atomic "claim": exactly one concurrent request inserts the row, the others get zero rows back.

INSERT INTO idempotency_keys (key, user_id, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO NOTHING
RETURNING key;
Enter fullscreen mode Exit fullscreen mode
  • One row returned: you own the key, do the work, then update status='done' with the response.
  • No row returned: load the existing row.
    • done and same hash → replay the stored response.
    • in_progress → return 409 Conflict so the client retries later.
    • different hash → 422.

Ideally, do the business write and the "done" update in the same transaction, so you never end up with work done and no stored result.

Spring Boot

@PostMapping("/payments")
public ResponseEntity<?> create(@RequestHeader("Idempotency-Key") String key,
                                @RequestBody PaymentRequest req,
                                @AuthenticationPrincipal User user) {
    String hash = DigestUtils.sha256Hex(json.writeValueAsString(req));
    int claimed = jdbc.update("""
        INSERT INTO idempotency_keys (key, user_id, request_hash)
        VALUES (?, ?, ?) ON CONFLICT (user_id, key) DO NOTHING""",
        key, user.getId(), hash);

    if (claimed == 0) {
        return idempotency.replayOrConflict(user.getId(), key, hash);
    }
    PaymentResult result = payments.execute(req, user, key); // stores result + status='done'
    return ResponseEntity.status(201).body(result);
}
Enter fullscreen mode Exit fullscreen mode

(DigestUtils here is Apache Commons Codec; replayOrConflict implements the three branches above. Exception handling trimmed for readability.)

Node (Express + node-postgres)

app.post("/payments", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
  const hash = sha256(JSON.stringify(req.body));

  const claim = await pool.query(
    `INSERT INTO idempotency_keys (key, user_id, request_hash)
     VALUES ($1, $2, $3) ON CONFLICT (user_id, key) DO NOTHING RETURNING key`,
    [key, req.user.id, hash]
  );

  if (claim.rowCount === 0) return replayOrConflict(req.user.id, key, hash, res);

  const result = await executePayment(req.body, req.user, key); // stores result, status='done'
  res.status(201).json(result);
});
Enter fullscreen mode Exit fullscreen mode

Expiry

Keys don't need to live forever. Stripe documents (for its API v1) that keys can be removed after they're at least 24 hours old. Pick a window longer than your longest client retry, and clean up with a scheduled job:

DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
Enter fullscreen mode Exit fullscreen mode

3. Retrying politely: backoff with jitter

If every client retries at exactly 1s, 2s, 4s, they all hit your recovering server at the same moments. AWS's architecture blog post on exponential backoff and jitter compares strategies and shows that adding randomness spreads retries out. "Full jitter" is the simplest:

async function withRetry<T>(fn: () => Promise<T>, max = 5, base = 300, cap = 10_000) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= max - 1 || !isRetryable(err)) throw err;
      const delay = Math.random() * Math.min(cap, base * 2 ** attempt);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Crucially, the idempotency key is created outside this function, once, so every attempt reuses it:

const key = crypto.randomUUID();
await withRetry(() => api.post("/payments", body, { headers: { "Idempotency-Key": key } }));
Enter fullscreen mode Exit fullscreen mode

When not to retry

  • Validation and auth errors. Retrying a 422 just produces another 422.
  • Non-idempotent calls without a key. Show the user a "checking status" state and query the server instead.
  • When the user is waiting. Cap total retry time. A spinner that lasts a minute is worse than a clear error with a "try again" button.
  • Retries at every layer. If the mobile client, the API gateway and the backend's HTTP client all retry 3 times, one failure can become 27 calls. Retry at one layer.

Checklist

  • [ ] Every non-idempotent endpoint that clients may retry accepts an Idempotency-Key.
  • [ ] Keys are scoped per user and stored with a request hash.
  • [ ] The claim uses INSERT ... ON CONFLICT DO NOTHING.
  • [ ] In-progress duplicates get 409, mismatched payloads get 422.
  • [ ] Clients generate the key once, outside the retry loop.
  • [ ] Backoff uses jitter and a cap; only one layer retries.
  • [ ] Keys expire after a documented window.

What's the worst duplicate-request bug you've shipped (or caught just in time)?

Top comments (0)