API engineering

API idempotency: 6 retry cases you can reproduce

A retry is safe only when the service can identify the original operation and preserve its outcome. Our six-case SQLite model separates a lost response before commit from one lost after commit, then tests duplicate delivery, changed inputs, tenant scope and a new retry key.

Artifact
6 sequential retry cases with per-attempt traces
Boundary
Local business effect and request result in one transaction
Scope
Standard-library SQLite model; no concurrent clients or external effects
Conceptual model of repeated blue request tokens producing one amber result through a verification gate.
Conceptual illustration of repeated requests sharing one committed result.

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.

Committed effects across six cases are 1, 1, 1, 1, 2 and 2. Two tenants legitimately create two effects; a new retry key also creates two.
Figure 1. Results from the sequential SQLite model. Two effects have different meanings depending on whether the calls represent different tenants or one retried operation. View full-size figure.
Observed outcomes from six sequential retry cases
CaseAttemptsCommitted effectsReplaysPayload conflicts
Response lost before commit2100
Response lost after commit2110
Same request delivered three times3120
Same key, changed payload2101
Same key, two tenants2200
Retry with a new key2200

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.

Decisions that define an idempotency contract
Contract fieldDecision to makeFailure when omitted
Tenant and operation scopeWhich authenticated caller and action own this key?Another operation can collide or receive the wrong result
Payload comparisonWhich normalized inputs define the requested effect?A changed request can be mistaken for a retry
RetentionHow long can the client repeat an ambiguous operation?A late retry can become a fresh execution
In-progress behaviorWhat happens when another attempt owns the operation?Concurrent requests can execute independently
Result policyWhat 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.

Authenticate and scope the key, claim or find the operation, apply the business change, commit it with the result and return the recorded result.
Figure 2. A proposed local transaction boundary. The text below explains the sequence and the concurrency limitation of the downloadable model. View full-size figure.

This is the intended sequence for a local database effect:

  1. Authenticate the caller and validate the requested operation.
  2. Look up or atomically claim the scoped idempotency key.
  3. Compare the request against the stored payload representation.
  4. Commit the business change and its replayable result together.
  5. 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 .

  1. RFC 9110: idempotent methods
  2. Stripe API v1: idempotent requests
  3. Stripe: API v2 idempotency differences
  4. AWS: transactional outbox pattern