A timeout leaves the result unknown
A customer clicks Submit, waits and tries again. The first request may have stopped before changing anything. It may also have committed successfully and lost its response on the way back. Both failures can look identical to the client. Retrying safely requires the server to recognize the intended operation across those attempts.
We built a small, sequential SQLite model to make that distinction visible. It stores a business effect and a request result in one transaction. Across six cases, a repeated key replays a committed result, a changed payload is rejected and the same key can identify different operations for different tenants. Changing the key on a retry creates a second effect even though the handler supports idempotency.
| Case | Attempts | Committed effects | Replays | Payload conflicts |
|---|---|---|---|---|
| Response lost before commit | 2 | 1 | 0 | 0 |
| Response lost after commit | 2 | 1 | 1 | 0 |
| Same request delivered three times | 3 | 1 | 2 | 0 |
| Same key, changed payload | 2 | 1 | 0 | 1 |
| Same key, two tenants | 2 | 2 | 0 | 0 |
| Retry with a new key | 2 | 2 | 0 | 0 |
These counts describe the supplied model. They are not a benchmark or a guarantee about a deployed database, payment service or Dreamtsoft product. In particular, two effects are correct in the two-tenant case and problematic in the new-retry-key case. A duplicate counter needs to know which business operation a request represents.
Define the operation before choosing a key
In HTTP's definition of idempotent methods, repeating a request has the same intended server effect as making it once. Response bodies and incidental effects such as access logging need not be identical. A custom POST endpoint has to establish its own retry contract.
Start with the user's intention. Creating an export and creating another export with the same filters may be two legitimate operations. Retrying the first export because its response disappeared should remain one operation. A hash of the request body alone cannot distinguish those cases.
Generate an opaque key when the operation begins and persist it with the client's pending operation. Reuse that key after an ambiguous timeout. Creating a fresh UUID inside each retry attempt defeats the server's ability to recognize the original intent. The key should carry no customer details that would become sensitive when logged.
Our model scopes a key by tenant and operation name. Tenant identity comes from authenticated application context, not an arbitrary identifier in the request body. The tuple (tenant, operation, key) identifies one request record. The payload is checked separately, because a reused key with a different requested change must not silently replay an unrelated result.
| Contract field | Decision to make | Failure when omitted |
|---|---|---|
| Tenant and operation scope | Which authenticated caller and action own this key? | Another operation can collide or receive the wrong result |
| Payload comparison | Which normalized inputs define the requested effect? | A changed request can be mistaken for a retry |
| Retention | How long can the client repeat an ambiguous operation? | A late retry can become a fresh execution |
| In-progress behavior | What happens when another attempt owns the operation? | Concurrent requests can execute independently |
| Result policy | What can be replayed after success or failure? | Clients cannot distinguish retry from reconciliation |
Put the result beside the business change
The central transaction in the model inserts a row into effects and another into requests. If an injected exception occurs before commit, neither row survives. The next attempt can perform the operation. If the exception occurs after commit, both rows survive and the next attempt returns the recorded result identifier.
This is the intended sequence for a local database effect:
- Authenticate the caller and validate the requested operation.
- Look up or atomically claim the scoped idempotency key.
- Compare the request against the stored payload representation.
- Commit the business change and its replayable result together.
- Return the result. A later matching attempt reads the same record.
The downloadable example uses one connection and sequential calls. Its initial lookup is adequate for demonstrating commit boundaries under those assumptions. It is not a concurrency-safe request handler to copy into a service. A production implementation must use database uniqueness and an appropriate transactional claim or locking strategy, then handle the losing concurrent request deliberately.
A unique key alone does not finish that design. Consider a handler that changes the business table, commits and only then tries to insert the idempotency record. The losing insert can report a collision after the unwanted business effect already happened. The constraint belongs inside the transaction that protects the effect, or the effect needs its own deduplication boundary.
Read the failure cases as acceptance tests
For the before-commit case, the first trace entry reports a lost response and zero effects. Attempt two returns result:1 and leaves one effect. For the after-commit case, the first attempt already leaves one effect. Attempt two replays result:1. The client sees failure in both initial attempts, but the server has different evidence about what happened.
The changed-payload case first commits an amount of 10. A second request with the same scoped key asks for 20. The model records one conflict and retains one effect. Rejecting this mismatch protects the meaning of the operation. Treating the newer request as an update would silently change the contract.
The tenant case uses the same key text for tenants a and b. Each tenant gets one effect. This does not prove tenant security, because the model has no authentication layer. It verifies only that the data structure's key scope distinguishes the two supplied tenant identities.
Finally, the new-key case sends the same amount twice with different keys. Both calls execute. No system can infer from these inputs alone whether the second request is an accidental retry or a legitimate second operation. Business constraints, such as a unique order reference, can provide an additional boundary when the domain permits one.
External side effects need another boundary
A local transaction cannot atomically commit a row in your database and an unrelated provider's mutation over HTTP. If the provider accepts a request and the response disappears, rolling back your local transaction does not undo that provider's effect. Label that operation as uncertain and reconcile it using the provider's supported identity and lookup mechanisms.
An outbox can preserve a durable intention to send work after the local transaction commits. It does not make the recipient execute exactly once. The dispatcher can crash after delivery and before recording completion, so the receiving operation still needs a stable identity or another safe reconciliation method.
For a concrete provider contract, Stripe's API v1 idempotent request documentation says the first saved status and response body are replayed for a key, including a saved 500 response. It compares parameters and documents when execution has not started and therefore no result is stored. Those details belong to that API version. Our model does not imitate all of them.
Stripe also permits removal of keys once they are at least 24 hours old. After pruning, reuse can create a new request. Do not turn that provider-specific retention boundary into a universal application default. The API v2 overview documents different behavior, so verify the namespace your integration actually calls.
Bound retries and preserve evidence
Even a duplicate that avoids a business mutation consumes some processing capacity. An outage can turn a modest operation rate into a much larger attempt rate. Choose a retry deadline and attempt budget appropriate to the operation, respect documented server guidance and introduce backoff where retries are supported.
Keep operational metrics separate: incoming attempts, newly committed operations, replayed results, payload conflicts and unresolved outcomes. A spike in replays may be harmless to business state while still identifying a network or client problem. Counting every replay as a fresh successful business operation would inflate the product metric.
Expose an operation identifier that support staff can use to trace a customer's request. Store enough evidence to reconcile the result, while limiting retention and access to sensitive payloads. If the business action involves credentials or personal information, a request record should not become an unrestricted second copy of that data.
Retries also interact with shared worker capacity. The SaaS noisy-neighbor experiment shows how scheduling changes who waits for a worker. Idempotency answers whether work should execute again. Scheduling answers when accepted work gets capacity. A service often needs both decisions.
Run the model and extend its boundaries
Download the Python retry model and save it as experiment.py. Running it with Python 3 writes results.json beside the script. It uses only the standard library, creates an in-memory database for each case and makes no network requests. The saved results and per-attempt traces let you inspect the outcomes without running code.
The script asserts the expected effects, replay counts and conflicts for every case, then repeats the entire run and compares its output. Exceptions simulate control-flow failures around a transaction. They do not simulate process termination, disk failure or distributed transactions. There are no concurrent requests, key expiry rules or external effects in this model.
For your own endpoint, extend the acceptance suite around the missing boundaries: two simultaneous attempts, a process killed during execution, a retry after the retention window, a provider response that never arrives and a deployment that changes payload normalization. Record the expected business state for each case before choosing the response code. The retry contract is ready to implement when both client and server agree what evidence identifies the original operation.
Sources
Documentation checked .
