One key identifies one logical operation
- 01RequestKey + body
- 02CommitKey with business effect
- 03RetryStored result
While investigating a bug in a Tarantool CRUD module, we had to ask the same question about every operation: what happens if the client retries after a timeout?
That is where idempotency stops being textbook vocabulary and becomes part of the system contract.
The precise meaning
An operation is idempotent when several identical requests have the same intended effect on the server as one such request.
The definition is about state, not identical responses. The first DELETE /documents/42 might return 204, while a retry returns 404. The document is absent after either request, so the intended effect has not changed.
Incidental effects may still repeat. A server can write another log entry, increment a metric, or perform an authorisation check without changing the method’s business semantics.
An HTTP method is only the starting point
In HTTP semantics, PUT, DELETE, and the safe methods are idempotent. POST and PATCH are not required to be.
The verb cannot rescue a broken implementation. A PUT /limits/42 endpoint that adds 100 on every call violates the contract. Conversely, POST /payments can be made retry-safe with an idempotency key.
Why a UUID is not enough
A unique identifier helps only if the server persists and checks it. Generating a fresh UUID for every retry creates several distinct operations.
A robust flow usually works like this:
- The client creates one key for one logical operation and reuses it for retries.
- The server scopes the key, for example by account and endpoint.
- It stores the key, a request hash, and the operation outcome.
- The business change and the key record are committed atomically, or protected by a unique constraint and a consistent transaction design.
- A retry with the same key and payload receives the stored outcome.
- Reusing the key with a different payload is rejected as a conflict.
Retention matters as well. If keys expire before the realistic retry window closes, an old request can become “new” again.
Idempotent message consumers
With at-least-once delivery, duplicates are expected. A consumer can handle them in two common ways:
- use a naturally idempotent operation, such as
status = 'approved'rather thanapproved_count += 1; - store the
event_idin an inbox table in the same database transaction as the business update.
A status assignment is safe only with respect to repeating that action. A late status = 'approved' after cancellation can restore an invalid state. Check the allowed transition and object version; idempotency does not provide event ordering. Authorise retries even when returning a stored result: knowing a key must not grant access to another account’s operation.
Calling an external system widens the consistency boundary. The receiver needs its own idempotency support, or the workflow needs an explicit retry and reconciliation design such as inbox/outbox.
An outbox durably records the intent to send, but delivery can still repeat. It does not make an arbitrary external API idempotent: the receiving side must recognise duplicate operations, or the workflow must reconcile their effects.
What the database can and cannot do
Unique constraints, transactions, and conditional updates are building blocks. None of them makes an arbitrary operation idempotent by itself.
An ACID transaction can successfully execute balance = balance - 100 twice. A UUID can appear in two separate writes if nothing enforces uniqueness. The guarantee appears only when the duplicate check and the business state change share a consistency boundary.
The same idea applies to infrastructure automation. Many Ansible modules describe a desired state and are designed for repeated runs. An arbitrary shell command does not become idempotent merely because Ansible executes it.
Four questions before release
- What will the client retry after a timeout?
- How will the server identify the same logical operation?
- Where are the business effect and processing marker committed atomically?
- What happens if the process fails between those steps?
Those answers are more useful than an “idempotent” label in an API specification.
The normative HTTP definition is in RFC 9110, section 9.2.2.
Failure walkthrough: the operation succeeded, the response vanished
A client creates a request with key req-42. The server saves it, but the response is lost. A timeout does not establish that the operation failed.
| Follow-up request | Required behaviour |
|---|---|
| Same key and payload | Return the stored outcome without creating another record |
| Same key, different payload | Report a conflict |
| Same key, original still running | Return the agreed pending state or retry guidance |
| New key | Treat as a separate operation under business rules |
The dangerous gap is between the business write and the retry record. Within one database, design a shared transaction and key uniqueness. An external effect such as email needs its own delivery and deduplication strategy. Our key table cannot undo a message already sent elsewhere.
Test simultaneous requests too. Two handlers can both observe an absent key. An atomic constraint closes that race; an optimistic application check does not.
Originally published on my Telegram channel @it_underside.
Yours, DPUPP
Related Articles
EventStorming: Benefits, Limits, and Practice
A practical guide to EventStorming: what the method helps a team discover, where it falls short, and how to prepare a session that produces useful outcomes.
Data Exchange Formats: JSON, XML, Protobuf, Avro, and MessagePack
Choose a format through contract evolution, backward and forward compatibility, tooling, and measured processing cost rather than fashion.
UML in Practice: When a Diagram Helps and When It Does Not
Choosing a UML diagram for a specific question, defining how long it should live, and keeping modelling from becoming a project of its own.