Making POST Requests Safe to Retry with Idempotency Keys
A practical guide to duplicate execution, request fingerprints, concurrency, and safe retries. A request timeout does not mean the operation failed. Sometimes the server has already completed the operation, but the response never reaches the client. Consider: POST /orders/order-123/pay with: { "amount" : 500000 } The timeline may look like this: Client -> POST /pay Server -> payment succeeds…
Duplicate execution of requests can lead to problems even if the HTTP request is retried. The goal is to ensure that one logical operation results in one effective side effect, regardless of how many times the request is delivered. To achieve this, an idempotency key is introduced by the client. This key remains the same for the same logical operation, even if retries occur.
When a request comes in, the system checks the idempotency key. If it's a new key, the operation is processed and the result is stored. If the key already exists, the system replays the stored response without executing the side effect again. This prevents duplicate side effects from occurring.
The traditional "check-then-act" method fails because if the idempotency key doesn't exist, two concurrent requests might both process the payment, leading to a race condition. To solve this, the uniqueness decision needs to be atomic. In a relational database, this can be achieved by enforcing a unique constraint on the idempotency key.
An idempotency key must represent exactly one logical operation. The same key with the same payload can be safely replayed, but the same key with a different payload should not be accepted. The lab uses a request fingerprint based on SHA-256 to ensure that same key + same fingerprint results in safe replay, while same key + different fingerprint results in a 409 conflict.
The safe flow has two key states: PROCESSING and COMPLETED. When a second request arrives, the system returns a 409 error, indicating that the operation is already in progress. If the operation is already finished, the system loads the stored response and replays it.
Idempotency is not a database transaction, as it protects a logical operation across multiple execution attempts, rather than ensuring atomicity within a single execution. A database transaction provides atomicity inside one execution, while idempotency protects the operation across multiple attempts. For example, if a payment provider successfully processes a payment and the local commit fails, the rollback does not undo the external charge. In such cases, the backend should reuse a stable key for that external request too.
In the lab implementation, the unsafe version directly charges the payment without any additional checks. The safe version, on the other hand, validates the key, calculates the fingerprint, reserves the operation, marks it as PROCESSING, executes the payment, stores the result, and marks it as COMPLETED. The storage implementation uses a map with a sync.RWMutex to simulate atomic uniqueness without introducing a real database. Both versions can be run and compared using the provided testing commands.
Written by urgent.news from Dev.to's reporting — not their text. Machine-written — may contain errors; check the original before relying on it.