Errors & Idempotency
A consistent, machine-readable error contract (RFC 9457 Problem Details) and idempotency keys so a retried request can't double-charge - the contracts that make an API safe to consume.
On this page
Two aspects of an API contract are routinely neglected and cause the worst production incidents: how errors are communicated and what happens when a request is retried. A consistent, machine-readable error format lets clients handle failure sanely instead of scraping prose; idempotency lets a client safely retry after a timeout without double-charging a customer. Both are part of the contract, and getting them right is what separates a robust API from a fragile one.
A consistent, machine-readable error contract
The worst error handling returns different shapes from different endpoints - {"error": "..."} here,
{"message": "...", "code": 5} there, a raw stack trace somewhere else. Clients can't handle any of it
uniformly. The standard fix is RFC 9457 Problem Details (formerly RFC 7807): one JSON shape for every error
across your API:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.ledger.dev/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "Account 42 has a balance of 30.00 USD; the transfer requires 50.00 USD.",
"instance": "/accounts/42/transfers/abc",
"balance": "30.00"
}type- a stable URI identifying the kind of error, which clients can branch on programmatically.title- a short human-readable summary;detail- specifics for this occurrence.status- mirrors the HTTP status.- Extension members (
balance) - domain-specific data the client can use.
Spring Boot supports this natively (ProblemDetail and @ExceptionHandler). The win: one error shape
everywhere, machine-readable type codes, and no leaking of stack traces. Consumers write error handling once.
Never leak internals in errors
An error body is an attacker's reconnaissance goldmine and a consumer's confusion. Never return raw exception messages, stack traces, SQL, or internal class names in a production API response - they expose your implementation and can leak secrets. Return a clean Problem Details document with a stable type and a helpful (but internal-free) detail; log the full exception server-side, correlated by a trace id (recall observability) so you can still debug.
Idempotency: making retries safe
Networks fail after a request is processed but before the response arrives. The client times out, doesn't
know if it worked, and retries. For a GET or PUT that's fine (idempotent by nature). But for a POST /transfers
or POST /payments, a naive retry charges the customer twice. This is one of the most common and damaging
real-world API bugs.
The solution is an idempotency key: the client generates a unique key per logical operation and sends it with the request; the server remembers keys it has processed and returns the original result for a repeat:
POST /payments
Idempotency-Key: 7f3a-...-c1 ← client-generated, unique per logical payment
Server logic:
seen this key before?
no → process the payment, store (key → result), return 201
yes → do NOT process again; return the stored original result (201)Now a retried request with the same key is a no-op that returns the first response - the customer is charged exactly once, no matter how many times the client retries. This is exactly how Stripe and other payment APIs work, and it's the correct pattern for any non-idempotent, side-effecting operation. (Note the connection to the batch/messaging modules: idempotency is the same defense against duplicate delivery, here at the API edge.)
Status codes carry retry semantics
The error contract and idempotency meet in status codes, which tell the client whether to retry:
4xx- the client's fault (bad input, not found, conflict). Retrying the same request won't help; fix it first. Don't auto-retry.5xxand timeouts - possibly transient server/network trouble. Safe to retry if the operation is idempotent (naturally, or via an idempotency key). This is why idempotency and error design are one topic: together they tell a client exactly when a retry is safe.
Idempotency is how banks stop you paying a bill twice. You write a check with a reference number on it; if the payment system sees that reference has already cleared, it doesn't debit you again - it just confirms the original payment went through. Without the reference, a jittery 'did that go through?' resend would double-pay the bill. The idempotency key is that reference number for API calls: the client stamps each logical operation with one, and the server honors 'I've already done this one' by returning the first outcome rather than doing it again. And Problem Details is the bank's standardized rejection slip - same form every time, with a code you can act on - instead of a different scribbled note from each teller.
A POST /orders/{id}/pay endpoint charges a customer's card. Under flaky mobile networks, clients frequently time
out and retry, and support is fielding complaints about double charges. Additionally, when the card is declined,
the endpoint returns 200 with {"ok": false, "why": "declined"}. Fix both problems and explain how a client should
now behave on a timeout.
What problem does an idempotency key solve, and how?
Key takeaways
- Use a consistent, machine-readable error contract - RFC 9457 Problem Details - so clients handle every error uniformly via a stable type code.
- Never leak stack traces, SQL, or internal messages in error responses; return a clean Problem Details doc and log the full error server-side, correlated by trace id.
- Non-idempotent operations (POST payments/transfers) need idempotency keys so a client retry after a timeout returns the original result instead of double-processing.
- The server stores processed idempotency keys and returns the first response for repeats - exactly one effect regardless of retries (the Stripe pattern).
- Status codes carry retry semantics: 4xx is the client's fault (don't blindly retry); 5xx/timeouts are retry-safe when the operation is idempotent.