Idempotency: Why At-Least-Once Delivery Needs It
In any system built from more than one computer, you must assume the network is unreliable. Requests will be lost, responses will vanish, and services will become temporarily unavailable. To build reliable services on top of this unreliable foundation, clients must retry failed operations. This leads to a fundamental choice in message delivery guarantees: at-most-once, at-least-once, or exactly-once. At-least-once delivery, where a sender retries until it gets a confirmation, is a popular and practical strategy that guarantees an operation will be processed. But it introduces a serious side effect: the operation might be processed multiple times.
This is where idempotency becomes critical. An idempotent operation is one that can be performed many times with the same effect as performing it once. By designing server-side operations to be idempotent, we can safely use at-least-once delivery, building a reliable system that tolerates network failures without corrupting its state. This isn't a theoretical exercise; it's a proven pattern used by major payment processors, cloud providers, and API designers to prevent everything from duplicate charges to misconfigured infrastructure. Understanding the mechanism of idempotency is essential for any engineer building or leading the development of distributed services.
What is Idempotency?
The term idempotence[1] comes from mathematics and describes an operation that produces the same result whether applied once or multiple times. For a function f, idempotence means that f(f(x)) = f(x) for all possible values of x. In software, this concept applies to operations or service requests. An idempotent request can be sent repeatedly, but the server-side state will only change as a result of the first successful request. All subsequent, identical requests will have no further effect on the system's state and should ideally return the same result as the original request.
HTTP methods defined in RFC 9110[2] provide a useful starting point. Methods like GET, HEAD, OPTIONS, and TRACE[3] are defined as idempotent because they are meant for information retrieval and should not change the state of the resource. The DELETE[4] method is also idempotent; calling DELETE /api/users/123 multiple times has the same outcome as calling it once. The user is deleted, and subsequent calls might return a 404 Not Found, but the system state (the absence of user 123) remains consistent. The PUT[5] method is also defined as idempotent when used to completely replace a resource. Sending PUT /api/users/123 with the same complete user profile twice is identical to sending it once.
In contrast, the POST[6] method is not idempotent. A POST request is typically used to create a new subordinate resource. Sending POST /api/payments twice will, by default, create two distinct payment records. Similarly, a PATCH request that performs a calculation, like incrementing a value, is not idempotent. Idempotency is not a property of the entire system but of a specific operation.
The Unreliable Network and At-Least-Once Delivery
In a distributed system, when a client sends a request to a server, there are three possible outcomes from the client's perspective:
- Success: The client receives a successful response.
- Failure: The client receives an explicit error response.
- Timeout: The client receives no response at all within a specified time.
The timeout is the most difficult case. The client cannot know if its request never reached the server, if the server crashed while processing it, or if the server processed it successfully but the response was lost on its way back. This ambiguity forces a choice between two primary reliability strategies for operations with side effects:
-
At-Most-Once Delivery: The client sends the request once and hopes for the best. If it times out, it assumes failure and moves on. This is simple but risks data loss if the request was lost before processing. It's suitable for non-critical telemetry or when data loss is acceptable. UDP[7] is a classic example of a protocol offering at-most-once semantics.
-
At-Least-Once Delivery: The client sends the request and, if it times out or receives a retryable error, it sends the exact same request again. It continues retrying until it receives a definitive success or failure response. This prevents data loss but creates the risk of duplicate processing. Many robust systems, like Amazon SQS standard queues[8], provide at-least-once guarantees. For Apache Kafka, the default guarantee is more nuanced: since version 3.0, the producer's
enable.idempotencesetting istrueby default, which provides exactly-once semantics within a single producer session, but this is only enabled if other settings are not in conflict (e.g.,acksmust be 'all').
A third guarantee, exactly-once, is the ideal but is notoriously difficult and expensive to implement correctly in a general-purpose distributed system. It often requires complex coordination protocols like the Two-Phase Commit Protocol[9], which can introduce significant performance overhead and operational complexity. In many practical scenarios, what is called "exactly-once" is actually an "exactly-once effect" achieved by combining at-least-once delivery with idempotent processing on the receiver.
At-least-once delivery is therefore a common engineering compromise. It provides the necessary reliability to avoid losing operations, and idempotency is the pattern that manages its primary risk: duplicate execution.
The Mechanism: How to Implement Idempotency
Making a non-idempotent operation like POST /payments safe to retry requires a specific server-side implementation pattern. The core idea is to give the client the ability to assign a unique identifier to each distinct operation, allowing the server to recognize and de-duplicate retries. This identifier is called an idempotency key.
The pattern is championed and well-documented by services like the Stripe API[10] and was proposed in an expired IETF draft for an Idempotency-Key HTTP header[11].
Here is the step-by-step mechanism:
-
Client Generates a Key: Before sending a request for a new operation, the client generates a unique idempotency key. A common choice is a Universally Unique Identifier (UUID)[12], now defined in RFC 9562. This key must be saved by the client for as long as it might need to retry the operation.
-
Client Sends the Key: The client includes this key in the request, typically in an HTTP header like
Idempotency-Key: <your-unique-key>. -
Server Receives and Checks the Key: When the server receives the request, the first thing it does is extract the idempotency key. It then consults a persistent, high-availability storage layer (like a Redis cluster or a database table) to check the status of this key.
-
Server Logic Branches: The server's behavior depends on the state of the key in its store:
-
Case A: Key Not Found. This is the first time the server has seen this operation. The server must process it atomically:
a. Start a database transaction[13] or use a similar locking mechanism.
b. Save the idempotency key to the store with a status of "in-progress". This acts as a lock to prevent concurrent processing of the same request.
c. Execute the core business logic (e.g., charge a credit card, create a database record).
d. Once the logic is complete, store its result (the HTTP status code and response body) in the idempotency store, associated with the key.
e. Update the key's status to "completed".
f. Commit the transaction.
g. Send the generated response back to the client. -
Case B: Key Found, Status is "in-progress". This means a request with the same key arrived while the original is still being processed. This could be a concurrent request from a confused client or a very fast retry. The server should reject this request with a conflict error, such as
409 Conflict[14], signaling to the client that the operation is already underway. -
Case C: Key Found, Status is "completed". This is a retry of a previously completed operation. The server's job is simple: do not re-execute the business logic. Instead, fetch the saved response from the idempotency store and send it back to the client immediately. This ensures the client gets the original result without causing a duplicate side effect.
The atomicity of step 4A is the most critical part of the implementation. If the business logic succeeds but the commit of the idempotency record fails, the system is vulnerable to duplication on the next retry.
A Concrete Example: A Payment API
Let's illustrate with a simplified Python web application using FastAPI. Imagine a client needs to create a payment, an action that must never be duplicated.
First, the client-side logic would look something like this. It generates a UUID, sends the request, and includes retry logic.
# client.py
import requests
import uuid
import time
API_BASE = "http://127.0.0.1:8000"
def create_payment(amount: int, currency: str):
idempotency_key = str(uuid.uuid4())
headers = {
"Idempotency-Key": idempotency_key
}
payload = {"amount": amount, "currency": currency}
max_retries = 3
for attempt in range(max_retries):
try:
print(f"Attempt {attempt + 1} with key {idempotency_key}...")
response = requests.post(
f"{API_BASE}/payments",
json=payload,
headers=headers,
timeout=2 # Use a short timeout to simulate network issues
)
response.raise_for_status() # Raise an exception for 4xx/5xx responses
print("Payment created successfully:", response.json())
return response.json()
except requests.exceptions.Timeout:
print("Request timed out. Retrying...")
time.sleep(1 * attempt) # Exponential backoff
except requests.exceptions.RequestException as e:
print(f"An error occurred: {e}")
# For a 409 Conflict, we might stop retrying
if e.response and e.response.status_code == 409:
print("Conflict detected, operation in progress.")
break
# For other server errors, we retry
print("Failed to create payment after several retries.")
return None
if __name__ == "__main__":
create_payment(amount=1000, currency="usd")
On the server, the FastAPI application implements the idempotency logic. We'll use a simple dictionary to simulate the persistent idempotency store.
# server.py
from fastapi import FastAPI, Request, HTTPException, Response
from pydantic import BaseModel
import time
import random
app = FastAPI()
# In a real application, this would be Redis, DynamoDB, or a SQL database.
IDEMPOTENCY_STORE = {}
class Payment(BaseModel):
amount: int
currency: str
# A simple database simulation
PAYMENTS_DB = []
@app.post("/payments")
async def process_payment(payment: Payment, request: Request):
idempotency_key = request.headers.get("Idempotency-Key")
if not idempotency_key:
raise HTTPException(status_code=400, detail="Idempotency-Key header required.")
# 1. Check the idempotency store
if idempotency_key in IDEMPOTENCY_STORE:
stored_entry = IDEMPOTENCY_STORE[idempotency_key]
if stored_entry["status"] == "completed":
print(f"Idempotency key '{idempotency_key}' found. Replaying response.")
# Replay the original response
return Response(
content=stored_entry["response_body"],
status_code=stored_entry["status_code"],
media_type="application/json"
)
elif stored_entry["status"] == "in-progress":
raise HTTPException(status_code=409, detail="Request with this key is already in progress.")
# 2. Key not found. Start processing.
print(f"New idempotency key '{idempotency_key}'. Processing request.")
IDEMPOTENCY_STORE[idempotency_key] = {"status": "in-progress"}
try:
# --- Begin Business Logic ---
print(f"Charging card for {payment.amount} {payment.currency}...")
# Simulate a process that can fail or be slow
time.sleep(3)
if random.random() < 0.1: # 10% chance of failure
raise ValueError("Payment provider failed")
payment_id = len(PAYMENTS_DB) + 1
new_payment_record = {"id": payment_id, **payment.dict()}
PAYMENTS_DB.append(new_payment_record)
# --- End Business Logic ---
# 3. Store the successful result before responding
response_body = str(new_payment_record).replace("'", '"')
status_code = 201 # Created
IDEMPOTENCY_STORE[idempotency_key] = {
"status": "completed",
"status_code": status_code,
"response_body": response_body
}
print(f"Processing complete for key '{idempotency_key}'.")
return Response(content=response_body, status_code=status_code, media_type="application/json")
except Exception as e:
# If processing fails, remove the in-progress key to allow a clean retry
del IDEMPOTENCY_STORE[idempotency_key]
raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
If you run this example, the client's first attempt will time out because its timeout (2 seconds) is shorter than the server's processing time (3 seconds). The client will then retry with the same key. The server, having finished the first request in the background and stored the result, will see the key in its store and immediately return the saved 201 Created[15] response. The payment is created only once.
Tradeoffs, Costs, and Failure Modes
Implementing idempotency is not free. Technical leaders must weigh its benefits against its costs and complexities.
Costs and Performance Impact:
- Added Latency: Every request that uses an idempotency key incurs at least one extra read and one extra write to a persistent store. This adds latency to the critical path of the operation.
- Storage Overhead: The idempotency keys and their corresponding responses must be stored for a period of time, known as the idempotency window. This window must be long enough to cover any reasonable client retry period (e.g., 24 hours). This consumes storage and requires a garbage collection mechanism to purge old keys.
- Increased System Complexity: The logic for handling idempotency adds another moving part to your system. The idempotency store itself becomes a critical component that must be highly available and performant. A failure in this store can have system-wide consequences.
Failure Modes and Architectural Decisions:
- Idempotency Store Unavailability: What happens if your Redis cluster or database holding the keys goes down? You face a difficult choice: fail open by processing the request anyway (risking duplicates) or fail closed by rejecting the request (sacrificing availability for correctness). The right choice depends on the business risk of a duplicate operation.
- Race Conditions: If the logic to check for a key and create an "in-progress" lock is not atomic, two concurrent requests with the same key could both believe they are the first. This completely defeats the purpose of the pattern. Using a database transaction or a distributed lock primitive is essential. While the Redlock algorithm for Redis[16] is one such primitive, its safety guarantees are not universally accepted[17] and depend on strong timing assumptions that may not hold in practice.
- Client-Side Errors: The system relies on the client to behave correctly. If a client bug causes it to generate a new key for every retry instead of reusing the original one, the server will treat each attempt as a new operation, leading to duplicates.
When to Use It (and When Not To):
A technical leader should apply this pattern judiciously.
- Use it for: Any state-changing operation (
POST,PATCH, or evenPUTwith complex side effects) where duplication would cause significant harm. This includes creating payments, placing orders, triggering workflows, or making critical configuration changes. - Don't use it for:
- Read-only operations (
GET) that are naturally idempotent. - Operations that are already logically idempotent (e.g., setting a user's status to
active). - Low-risk or high-volume operations where the performance overhead is unacceptable and occasional duplicates are tolerable (e.g., analytics event tracking).
Common Misconceptions
Misconception: "Idempotency is the same as exactly-once delivery."
This is incorrect. Idempotency is a property of the operation handler (the receiver), not the delivery system. At-least-once delivery combined with an idempotent receiver gives you an exactly-once processing effect. The message may still be delivered multiple times, but it is processed to completion only once. This distinction is important for understanding where the responsibility lies.
Misconception: "A unique constraint in my database is good enough."
A unique constraint can prevent creating two resources with the same name, but it doesn't solve the core problem of a lost reply. If a client retries after a timeout, the database constraint will cause the second INSERT to fail. The server will return an error (e.g., 500 Internal Server Error or 409 Conflict). The client now knows the operation failed, but it doesn't receive the details of the resource that was successfully created on the first attempt. A proper idempotency implementation returns the original successful response, allowing the client to proceed correctly.
Idempotency in Practice Today
The idempotency key pattern is a mature and widely adopted solution for building reliable APIs.
-
Payment and Financial APIs: Stripe's
Idempotency-Keyheader is the canonical public example. Nearly all serious financial technology APIs implement a similar mechanism to prevent duplicate transactions. -
Cloud Provider APIs: Cloud providers use this pattern extensively to make infrastructure management safe. The Amazon EC2
RunInstancesAPI call includes aClientTokenparameter that functions as an idempotency key to prevent launching duplicate virtual machines. -
Cloud Storage: Google Cloud Storage's resumable uploads[18] use a session URI that acts as an idempotency identifier. If an upload is interrupted, the client can continue sending data to the same URI without starting over or corrupting the file.
-
Standardization Efforts: The recognition of this pattern's importance led to an IETF draft to standardize the
Idempotency-Keyheader for HTTP, though this draft has since expired. The widespread use of the header signals a move toward making idempotency a first-class, interoperable feature of web APIs, rather than a proprietary implementation detail.
For engineers and leaders building distributed systems, idempotency is not an optional extra. It is a fundamental requirement for creating reliable services that can weather the inevitable failures of the network. By understanding its mechanism, costs, and benefits, you can make informed decisions about where and how to apply it, turning the challenge of at-least-once delivery into a foundation for correctness and resilience.
Sources (18)
- Wikipedia: Idempotence
- IETF RFC 9110: HTTP Semantics
- IETF RFC 9110: GET Method
- IETF RFC 9110: DELETE Method
- IETF RFC 9110: PUT Method
- IETF RFC 9110: POST Method
- Wikipedia: User Datagram Protocol (UDP)
- AWS SQS Docs: At-Least-Once Delivery
- Wikipedia: Two-phase commit protocol
- Stripe API Docs: Idempotent Requests
- IETF Draft: The Idempotency-Key HTTP Header Field
- IETF RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace
- PostgreSQL Docs: Transactions
- IETF RFC 9110: 409 Conflict
- IETF RFC 9110: 201 Created
- Redis Docs: Distributed locks
- martin.kleppmann.com
- Google Cloud Storage Docs: Resumable uploads