Idempotent APIs: make retries safe
A timeout does not tell you whether a write failed. How to give an operation an identity, handle concurrent retries, and avoid creating the same booking twice.
A customer submits a booking. The server saves it, but the connection drops before the response reaches the browser. The customer sees an error and presses the button again. Two valid HTTP requests have now produced two bookings for one intention.
Disabling the button while a request is pending helps the interface. The server still needs a way to recognize a repeated operation, because retries can come from a browser refresh, a mobile connection, or another service.
That is the problem an idempotency key is meant to address. Stripe provides a concrete example: its API stores the status and response of an executed request and returns that result when the same key is retried. It also checks that the parameters match. Those are Stripe's documented semantics; another API needs to define its own contract explicitly. Stripe's idempotent request reference.
Give the intention a stable identity
For a booking API, I would create a random operation identifier when the customer begins the submission. The same identifier travels with retries of that submission. A genuinely new booking gets a new identifier.
The server should scope the key to the authenticated tenant and operation type. A key used for creating a booking should not accidentally collide with one used for cancelling it. Authentication and authorization still run before returning any stored result.
An illustrative record might look like this:
{
"tenantId": "tenant_42",
"operation": "create_booking",
"key": "submission_7f31",
"requestHash": "hash-of-normalized-input",
"state": "completed",
"resourceId": "booking_1042"
}
The identifiers here are examples. In a real implementation, the key should have enough randomness to avoid collisions, and the request hash should cover the meaningful input after normalization. Reusing a key with a different date or service should produce a clear conflict instead of silently replaying an unrelated result.
Make concurrent requests meet at the database
The tempting implementation is “look up the key, then insert if it is missing.” Two workers can both finish that lookup before either inserts anything.
I would enforce uniqueness on the tenant, operation, and key in the database. For an operation whose effects are entirely in that database, claiming the key, creating the booking, and recording the result can happen in one transaction. A competing request must follow the existing operation rather than start another one.
The response for an operation still in progress also needs a contract. The API might wait within a bounded timeout or return a pending status with an operation URL. The client should be able to distinguish pending work from a rejected request.
Treat external effects separately
A database transaction cannot make an email provider or another HTTP service part of the same atomic commit. If the booking is saved and the receipt fails, retrying the entire operation should not create another booking.
One design is to write the booking and an outbox event in the same transaction. A worker then delivers the receipt and records its progress. The delivery path needs its own duplicate handling, including a provider idempotency key where supported.
That is a useful boundary: the booking can be confirmed while receipt delivery remains pending. The interface should report those states accurately. The queue mechanics fit the pattern described in background jobs with FastAPI, Celery, and Redis.
Test the response you never received
The most useful test is to let the server commit, lose the response, and retry with the same key. Verify that the client receives the original booking and that the database contains one record.
Also test concurrent attempts, changed input under the same key, unauthorized access, and retrying after the key's retention period. Retention is part of the API contract: once a key expires, it may no longer prevent a duplicate. Important business invariants may need their own permanent constraints.
A good retry policy gives the client a reliable way to recover from uncertainty. It also gives the team a much clearer explanation when someone asks what happened to a submission.