The response was lost. Was the message sent?
Idempotency, retries and the meaning of failure in distributed systems.
Abstract
A request times out. The interface offers a retry button. Somewhere beyond the connection, the requested change may already exist. This article develops a practical design for that uncertain interval: name the intended operation, preserve its identity across attempts, and commit its result together with the evidence needed to recognise a duplicate. An original support-message example illustrates concurrency, response replay, retention, and the boundary between database transactions and external effects. The objective is a bounded, testable guarantee rather than a universal claim of exactly-once execution.
The schema, protocol, diagrams, and examples below are proposed teaching designs. They are not reports of Raindance production incidents or a description of a deployed service. Source citations distinguish established protocol and database behaviour from the design choices made here.
1. A timeout describes knowledge, not outcome
Imagine a developer submitting a support message. The browser sends POST /messages, waits four seconds, and displays a connection error. There are several possible histories: the request never reached the service; the service received it but rolled back; or the database committed and the reply disappeared. A fourth possibility is still unfolding: processing continues after the browser stops waiting.
These histories require different recovery actions, yet the same timeout can represent all of them. MIT's RPC teaching notes identify precisely this ambiguity: a missing response does not tell the caller whether the server executed the request. Remote invocation therefore cannot simply inherit the failure behaviour of a local function call. [1]
For our interface, the useful states are confirmed, rejected, and unconfirmed. “Failed to receive confirmation” is more accurate than “message not sent.” Preserve the draft and its operation identifier while unconfirmed. A retry should resume the same submission, not silently create another one.
The timeline separates the database commit from delivery of its acknowledgement. The retry reaches the service after the first transaction has committed. Returning the recorded receipt resolves the client's uncertainty without inserting a second message. The two network attempts represent one intended submission.
This distinction also improves support conversations. An operation identifier lets an engineer investigate a specific attempt sequence without asking the user to submit again. The identifier is evidence of which operation to look up, however, not evidence that it succeeded.
2. Define the guarantee before choosing a mechanism
HTTP distinguishes safe methods from idempotent methods. Safety concerns whether the client requests a state change; idempotence concerns the intended effect of repeating an identical request. PUT, DELETE, and safe methods are idempotent under HTTP semantics. Responses need not be identical, and incidental logging can still occur. POST has no general idempotency guarantee, although an application can define one for a particular operation. [2]
Our proposed contract is narrower and more useful than “exactly once”:
For an authorised account, operation type, and retained request key, retries with the same accepted payload produce at most one committed message and return the same application receipt.
Each qualifier matters. An account is part of the namespace. An operation type prevents accidental collisions between unrelated endpoints. Retention limits the promise in time. Matching payloads prevent a reused key from concealing different intent. “Committed message” identifies the protected effect; email delivery is not included.
Consider two identical messages deliberately submitted a minute apart. Comparing their text cannot tell whether the second is a duplicate. The client must distinguish a new intention from another attempt to complete an existing intention. AWS documents client-provided request identifiers and parameter mismatch checks as part of its idempotent API approach. Our example adopts that explicit distinction. [3]
The resulting guarantee is conditional. If the caller loses its key, the service loses its receipt, or a retry arrives after the supported window, this particular duplicate-prevention mechanism may no longer apply. Writing those conditions down is part of designing the API.
3. Give an intention a durable name
Generate a sufficiently unpredictable key when the user first confirms the submission. Persist it with the exact request body before beginning network activity. The following browser-side fragment illustrates the lifecycle; the application-specific draft store must be appropriate for the sensitivity and retention of its contents.
type Submission = {
key: string;
body: string;
createdAt: string;
};
const submission: Submission = {
key: crypto.randomUUID(),
body: JSON.stringify({ text: draftText }),
createdAt: new Date().toISOString(),
};
await draftStore.save(submission); // Before the first attempt.
// Every retry loads this object; it does not generate a new key.
await fetch("/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": submission.key,
},
body: submission.body,
signal: AbortSignal.timeout(4000),
});This fragment is not a complete retry client: it omits response validation, credentials policy, and recovery controls. Its purpose is to show that request identity exists outside the retry loop. Aborting a local wait must not be interpreted as a confirmed server rollback.
On the server, derive the account scope from verified authentication, not a user-supplied JSON field. Authorise both initial execution and response replay. An idempotency key should not become a bearer credential for retrieving somebody else's receipt.
Store a fingerprint of the accepted operation alongside its key. For this single-field example, define the fingerprint input as a versioned, canonical encoding of the validated text string. Specify whether whitespace and Unicode differences are significant. Do not casually hash arbitrary JSON serialisations: field ordering or default values may differ while the intended operation is unchanged.
Conversely, avoid normalisation that erases meaningful distinctions. Two messages differing only in quoted whitespace might be different submissions. The fingerprint is an equality check under the API's documented rules, not a general judgement of semantic equivalence. Hashing also does not anonymise predictable personal data.
4. Keep the message and its receipt inside one boundary
For this design, a PostgreSQL transaction contains both the domain write and the replay record. PostgreSQL transactions group operations so that they commit together or are rolled back together; intermediate updates are not exposed as committed results. [4]
The boundary encloses the evidence of acceptance as well as the message itself. A process crash before commit leaves neither committed. A lost connection after commit can leave both committed even though the API handler cannot confirm success. An optional notification job belongs inside the boundary as durable intent; the external delivery does not.
Here is a deliberately small receipt schema. payload_hash represents the agreed fingerprint, and the primary key provides database-enforced uniqueness across the three-part namespace. PostgreSQL supports such multi-column constraints. [5]
CREATE TABLE operation_receipts (
account_id uuid NOT NULL,
operation text NOT NULL,
request_key uuid NOT NULL,
payload_hash bytea NOT NULL,
response_status smallint,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, operation, request_key),
CHECK (
(response_status IS NULL AND response_body IS NULL)
OR
(response_status IS NOT NULL AND response_body IS NOT NULL)
)
);The nullable response allows a transaction to reserve the key before creating the message. It does not justify committing an unfinished reservation. The transaction helper must roll back on every exception and commit only after the receipt is complete. This schema alone does not enforce that application invariant; a production design should narrow database permissions and test every transaction exit.
Store a small application receipt, such as a message identifier and acceptance time. Avoid replaying arbitrary HTTP headers, expiring session cookies, or generated secrets. Reconstruct transport headers according to current security policy. If response contents are private, authorisation remains necessary even when the receipt is immutable.
5. Make concurrent duplicates meet at the database
A preliminary SELECT followed by an unprotected insert is insufficient: two handlers can both observe absence. In our design, competing requests first attempt to insert the receipt key with ON CONFLICT DO NOTHING. Only the transaction that owns the reservation may insert the message.
BEGIN transaction at READ COMMITTED
INSERT receipt(scope, key, fingerprint)
ON CONFLICT DO NOTHING
RETURNING key
if this transaction inserted the receipt:
insert message
construct receipt from stored message id and acceptance time
update reserved receipt with response status and body
assert exactly one complete receipt was written
else:
SELECT existing receipt in a NEW statement
if missing: ROLLBACK and enter bounded reconciliation
if fingerprint differs: ROLLBACK and return conflict
if receipt incomplete: ROLLBACK and report invariant failure
use existing receipt
COMMIT
return receipt only after commit acknowledgementThe separate read is intentional. Under PostgreSQL Read Committed isolation, INSERT ... ON CONFLICT DO NOTHING can encounter a conflicting row that was not visible to that statement's snapshot. A later statement obtains a new snapshot and can see the committed row. This detail must not be flattened into an assumed portable database recipe. [6]
Bound lock waits and statement execution. If another request remains active beyond the waiting budget, our API returns a documented retryable, unconfirmed result. It does not start a second domain write. Database deadlocks and transient failures require transaction-level recovery, not continuation halfway through an aborted transaction.
Receipt cleanup introduces another race: a conflicting row might disappear before the follow-up read. Treat that as a reconciliation condition, not permission to invent success. The simplest teaching version excludes cleanup during its test window. A deployable version must coordinate retention with active operations and document the boundary behaviour.
Finally, a failed acknowledgement of COMMIT is itself ambiguous. Reconnect and reconcile with the same key. Never assume that an exception from the database driver proves that no transaction committed.
6. Choose what a repeated response means
Our receipt means “message accepted,” not “message currently exists in its original form.” The message may later be archived or removed. Replaying its acceptance receipt should not recreate it. Keep acceptance history separate from a current-state lookup.
Different APIs make different replay choices. Stripe's API v1 documentation says that, after execution begins, it stores the first status and body, including 500 responses; it also documents parameter comparison and key pruning after at least 24 hours. Those are specific contract decisions, not universal consequences of idempotence. [7]
For our hypothetical message service, validation and authorisation failures occur before reservation. Successful acceptance is durable. A transaction that definitely rolled back leaves no successful receipt. An uncertain commit enters reconciliation. Unexpected internal errors must not be casually cached as success or treated as permission to change the key.
Use machine-readable outcome codes alongside readable explanations. For example, payload_mismatch means the same key now describes different content; outcome_unconfirmed means confirmation is unavailable; accepted includes the recorded identifier. These are proposed application codes, not standard HTTP fields.
Keep the client interface equally precise. “Retry confirmation” preserves the operation. “Submit another message” creates a new one after an explicit decision. An automated caller needs the same distinction: regenerating its entire plan after a timeout must not silently regenerate the request identity.
7. Retry within a budget
Correct duplicate handling does not make unlimited traffic harmless. AWS's backoff guidance describes how retries can amplify overload and how jitter spreads retries that would otherwise arrive together. It also recommends limiting attempts and avoiding multiplication across independently retrying service layers. [8]
For the example client, define one overall deadline, a maximum attempt count, a per-attempt timeout, and a permitted retry set. A rejected payload should not enter the same loop as an interrupted connection. An expired authorisation context requires its own handling. Where the API supplies Retry-After, interpret its documented delay alongside the remaining deadline rather than sending earlier retries. HTTP permits both a date and a delay value. [2]
The following scheduling function illustrates full jitter only; it does not decide whether a request is safe to repeat:
function retryDelayMs(attempt: number): number {
const baseMs = 250;
const capMs = 4000;
const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
return Math.floor(Math.random() * ceiling);
}The numbers are illustrative, not measured recommendations. Select them from the service's latency, load, and user-experience requirements. Before sleeping and again before sending, check cancellation and the remaining budget. Reuse the saved body and key. On exhaustion, retain the unconfirmed operation and offer reconciliation rather than declaring it absent.
Assign retry ownership explicitly. If the browser, gateway, SDK, and worker all retry, the observed attempt count may be much larger than any one component expects. Log enough non-sensitive correlation information to connect those attempts while keeping request content out of routine diagnostics.
8. Retention is part of the correctness contract
A receipt occupies space and may contain personal information. Keeping every key forever is not automatically desirable. Our example therefore needs a published retry window, an actual deletion policy, and a recovery route for older uncertain operations.
Suppose the contract promises recognition for seven days. A laptop reconnecting after ten days must not assume that replaying the old key is still protected. Once the server has deleted the receipt, a previously used key can look new. Adding a timestamp to an untrusted request does not, by itself, solve that problem.
For this example, clients stop automatic submission retries before the supported window ends. Older uncertainty goes to an authorised history lookup or human reconciliation. If the product must reject every expired operation reliably, it needs additional state or verifiable issuance metadata, with its own lifecycle. That requirement changes the design; it is not a free property of UUIDs.
Distinguish receipt retention from message retention. Deleting one may affect recovery without deleting the other. Backups, exports, and observability stores also need compatible policies. When documenting the promise, specify which clock starts the window, whether retries extend it, and what happens at the boundary.
9. External effects need their own agreement
Now add an email notification. Sending it before database commit risks notifying someone about a message that later rolls back. Sending it after commit leaves another gap: a crash can prevent the notification, or an uncertain delivery result can cause it to be sent again.
An original extension to our example is an outbox row written in the same transaction as the message. A separate worker reads outstanding rows and submits notifications. This makes the intention to notify durable alongside acceptance. It does not place the email provider inside the database transaction.
Use a stable notification identifier across delivery attempts. If the provider supports suitable deduplication, align its key scope and retention with the worker's recovery window. If it does not, acknowledge the possibility of duplicate notifications and make their content harmless to receive twice. Never label the whole workflow exactly-once because its first database write is protected.
The same analysis applies to charging a card, creating a cloud resource, or writing to a second database. Draw each acknowledgement boundary. At every crossing, ask what survives a crash, who owns the operation identifier, and how uncertainty is reconciled. A reliable local transaction is a foundation, not a substitute for those separate contracts.
10. Test the uncertainty, not just the happy path
The most useful test interrupts the response after a successful commit. Retry with the same key and assert one message plus the same receipt. This directly exercises the user's original problem.
Extend that test into a small failure matrix:
| Intervention | Required observation in this design |
|---|---|
| Concurrent identical requests | One committed message; matching receipts |
| Same key with changed text | Conflict; no additional message |
| Crash before commit | No committed partial acceptance |
| Lost commit acknowledgement | Reconciliation preserves the original key |
| Different authenticated account | No cross-account receipt disclosure |
| Restarted client | Persisted key and body survive together |
| Receipt cleanup boundary | Documented recovery; no false guarantee |
| Notification acknowledgement lost | Separate delivery policy is exercised |
Run concurrency tests against the actual database engine and isolation level. A mocked repository that always answers sequentially cannot establish race behaviour. Inject failures deliberately and assert durable state, not merely HTTP status codes. Also test cancellation, malformed fingerprints, exhausted retry budgets, and revoked access before replay.
Give the test harness an explicit invariant: within the supported window, the count of committed messages for each accepted operation is at most one, and every successful receipt identifies that message. Record the actual sequence of events so that a failure is reproducible. A test can then vary delays around reservation, insertion, commit, and response delivery without depending on one fortunate ordering. Keep availability separate from correctness: a run in which every request times out might preserve uniqueness while still failing the product's usefulness requirement. Measure both confirmed completion and the number of unresolved operations.
The operational question then becomes answerable: not “Did the network request succeed?” but “What do we know about this identified operation, and which safe action reduces the remaining uncertainty?” That question gives developers, interfaces, and automated clients the same basis for recovery.
References
Primary sources checked on 19 October 2026. The proposed architecture and code are original explanatory examples; they have not been presented as production measurements.
- MIT 6.5840: Threads and RPC, 2026. Explains why missing RPC replies leave execution uncertain.
- IETF RFC 9110: HTTP Semantics. Defines method safety, idempotence, and
Retry-After. - Malcolm Featonby, AWS: Making retries safe with idempotent APIs. First-party discussion of request identity, matching intent, and replay contracts.
- PostgreSQL 18: Transactions. Establishes the local atomicity boundary used in the example.
- PostgreSQL 18: Constraints. Documents composite uniqueness and declarative integrity checks.
- PostgreSQL 18: Transaction Isolation. Specifies Read Committed snapshots and conflict behaviour.
- Stripe API: Idempotent requests. Provides a concrete API v1 response-replay and retention contract.
- Marc Brooker, AWS: Timeouts, retries, and backoff with jitter. Explains retry budgets, load amplification, and jitter.