- Published on
After a timeout, check the outcome before repeating the write
- Authors

- Name
- Mehdi Akiki
Article · Interrupted execution
The provider creates the order. Its response gets lost. Your client times out and submits a new request with a new key.
There are now two orders, although the code was only trying to recover from a failure.
A timeout tells the client that it did not get a result within its limit. It does not always tell the client whether the remote write happened.
Keep an unresolved state
Model what the client actually knows. This Rust enum is a small example:
enum WriteOutcome {
Confirmed { remote_id: String },
Rejected { reason_code: String },
Unresolved { operation_id: String },
}
Unresolved is useful information. It prevents the application from claiming a definite failure while it is still possible that the provider accepted the operation.
Create the stable operation identity before sending the write and persist it when the workflow must survive a restart. Bind it to the relevant payload so the same identity cannot quietly mean a different order later.
The idempotency-key guide explains the server-side contract. A client must use the particular provider's guarantees, including retention windows, payload conflict handling, and which operations support deduplication.
Recover using evidence the provider supports
Some providers offer lookup by request identity. Some document that retrying the same key returns the earlier result. Others expose a separate asynchronous operation status.
Use the supported path. Keep the original identity and payload. A new random key starts another logical operation even if the code calls it retry.
A lookup that finds nothing may still be inconclusive if writes become visible asynchronously. Read the documented consistency and retention contract before treating absence as proof that nothing happened.
If the provider has no reliable lookup or duplicate protection, keep the uncertain state visible and give an operator a way to resolve it. The client cannot create a guarantee the provider does not offer.
HTTP's retry discussion is a useful starting point, but an application-specific write needs its own semantics.
Tell the caller what to do next
An API can return an operation identity and expose a status lookup. The interface can show that confirmation is pending rather than inviting a second purchase immediately.
Do not make the user carry the entire recovery burden with “please try again” if that action can duplicate a write. Decide how repeated submissions relate to the same intent.
Keep local expiry separate from remote outcome. Giving up on automatic recovery does not reverse a remote order. A manual resolution can still be required after the client's retry budget ends.
Simulate a committed write with no response
Build a controlled provider that records the order and then closes the connection before returning its result. The client should enter the unresolved path and recover the same order identity.
Also test a rejection before acceptance and a provider that remains unavailable during lookup. Assert the number of stored orders, not just the client's final message.
This is a small failure to reproduce and a costly one to miss. Once the client distinguishes “no response” from “no write,” the retry policy becomes much easier to reason about.
Lose the HTTP connection after the order commits
The controlled provider writes an order in PostgreSQL, commits, and deliberately panics before producing an HTTP response. This really closes the loopback request without a successful reply. The client records Unresolved with its existing operation identifier. It does not translate a transport error into “order rejected.”
The fixture stores the client's operation state separately from the provider's order. That separation is important: a local pending record describes what the client knows. It is not proof that the remote operation is still pending. An independent database query confirms that the order already exists.
The first status lookup is deliberately unavailable. The operation remains unresolved. When lookup recovers, the same identifier finds remote-order-42, and the client can record confirmation. Repeating the original write with the same identifier and payload still leaves one provider order. Reusing that identifier with a different payload returns 409. Invalid input has a separate 422 rejection path.
These are guarantees of this constructed provider. They are not promises that every external API supports status lookup, durable idempotency keys, or payload-conflict detection. A real integration must verify its provider's retention period, key scope, lookup consistency, and documented behaviour before choosing the same recovery steps.
The complete Rust fixture contains the server, client outcome type, stored operation state, and recovery assertions. It runs through HTTP rather than representing uncertainty with a made-up return value alone.
For the user interface, keep one operation reference visible while confirmation is unavailable. Avoid inviting a second checkout with a new key. If neither lookup nor safe replay is supported, the honest next step may be bounded reconciliation or operator review. A timeout tells us that the reply did not arrive in time. Stored evidence and the provider contract determine what happened to the write.