The Request Succeeded. The Client Timed Out. Then It Sent the Request Again.
A practical look at API idempotency using Spring Boot and Amazon DynamoDB, including duplicate requests, race conditions, conditional writes, retries, and the trade-offs behind a reliable implementation.
The Request Succeeded. The Client Timed Out. Then It Sent the Request Again.
A REST API can return a timeout even when the operation on the server has already succeeded.
That sounds like a small networking problem until the endpoint is something like:
1
2
3
4
POST /orders
POST /payments
POST /bookings
POST /withdrawalsNow the retry can become a business problem.
The client doesn't know whether the first request succeeded, so it sends the request again.
The server receives two requests.
Without some form of idempotency, the database may end up with two orders instead of one.
This article walks through the problem and a practical implementation using Spring Boot and Amazon DynamoDB.
The Problem Starts With a Timeout
Consider this request:
1
2
3
4
5
6
7
8
POST /orders
Idempotency-Key: 3d4a8b7e-9d6e-4c8b-a4ab-2f9c7b7a1f21
{
"customerId": "C1001",
"productId": "P500",
"quantity": 2
}The API receives the request and creates the order.
Everything works.
But before the HTTP response reaches the client, something goes wrong.
Maybe the connection is interrupted.
Maybe a proxy times out.
Maybe the client has a shorter timeout than the server-side operation.
From the client's perspective, the result is unknown.
So the client retries.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Request #1
|
v
Server
|
v
Order created
|
X
Response lost
|
v
Client timeout
|
v
Request #2If the API treats both requests as independent operations:
1
2
Order #101 <- Request #1
Order #102 <- Request #2We now have a duplicate.
For an order this is annoying.
For a payment, booking, wallet transfer, or inventory update, it can become a serious business problem.
"I'll Just Check Before Inserting"
A first implementation often looks like this:
1
2
3
if (!orderRepository.existsById(request.getOrderId())) {
orderRepository.save(order);
}It looks reasonable, but the check and the insert are two separate operations.
Two requests can arrive almost simultaneously:
1
2
3
4
5
6
7
8
9
10
11
Request A Request B
exists? exists?
| |
|---- NO --------------------|
| |
save() save()
| |
v v
DuplicateBoth requests can observe that the record does not exist.
Both continue.
Both write.
This is a classic race condition.
The important part is that idempotency cannot depend on application-level timing.
The storage layer needs to participate in the guarantee.
What Should an Idempotent API Do?
The client sends an idempotency key:
1
Idempotency-Key: 3d4a8b7e-9d6e-4c8b-a4ab-2f9c7b7a1f21The server treats that key as the identity of the operation.
The flow becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST /orders
|
v
Idempotency-Key
|
v
Already processed?
/ \
YES NO
| |
v v
Return previous Process request
result |
v
Store result + order
|
v
Return responseThe goal is simple:
Retrying the same operation should produce the same business result, not execute the business operation again.
Designing the Idempotency Record
For this example, let's use two DynamoDB tables:
1
2
3
4
5
6
7
Orders
-------------------------
PK: orderId
Idempotency
-------------------------
PK: idempotencyKeyAn idempotency record could contain:
1
2
3
4
5
6
7
8
9
{
"idempotencyKey": "3d4a8b7e-9d6e-4c8b-a4ab-2f9c7b7a1f21",
"requestHash": "8c7f...",
"status": "COMPLETED",
"orderId": "ORD-8f31",
"responseStatus": 201,
"responseBody": "{...}",
"expiresAt": 1790000000
}The
requestHash is important.An idempotency key should not silently be reused for a different request.
For example:
1
2
3
4
5
Request 1
Idempotency-Key: abc
quantity = 2Later:
1
2
3
4
5
Request 2
Idempotency-Key: abc
quantity = 10Those are not the same operation.
The API should reject the second request instead of returning an unrelated result.
The Spring Boot Endpoint
The controller can keep the HTTP concerns simple:
1
2
3
4
5
6
7
8
9
10
11
public ResponseEntity<OrderResponse> createOrder(
String idempotencyKey,
CreateOrderRequest request) {
OrderResponse response =
orderService.createOrder(idempotencyKey, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(response);
}The service handles the idempotency workflow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public OrderResponse createOrder(
String idempotencyKey,
CreateOrderRequest request) {
validate(idempotencyKey);
String requestHash = requestHasher.hash(request);
IdempotencyRecord existing =
idempotencyRepository.find(idempotencyKey);
if (existing != null) {
validateSameRequest(existing, requestHash);
return existing.toResponse();
}
return createNewOrder(idempotencyKey, request, requestHash);
}This is cleaner, but it still has a race condition.
Two requests can both execute:
1
find(idempotencyKey);and both receive
null.So the real protection needs to happen at the database level.
Conditional Writes Are the Important Part
DynamoDB supports conditional writes.
For example:
1
attribute_not_exists(idempotencyKey)means:
Create this item only if the idempotency key does not already exist.
That is different from:
1
2
3
read
if not exists
writeThe condition is evaluated as part of the write operation.
Conceptually:
1
2
3
4
5
6
PutItemRequest request = PutItemRequest.builder()
.tableName("Idempotency")
.item(item)
.conditionExpression(
"attribute_not_exists(idempotencyKey)")
.build();Now, if two requests race:
1
2
3
4
5
6
7
Request A
|
+---- conditional write ----> SUCCESS
Request B
|
+---- conditional write ----> FAILEDOnly one request wins.
The losing request can retrieve the existing idempotency record and return the stored result.
There Is Still Another Failure Window
Suppose the implementation does this:
1
2
3
1. Create idempotency record
2. Create order
3. Return responseWhat happens if step 1 succeeds and step 2 fails?
Now the system may believe the request has already been handled even though the order was never created.
The reverse ordering has a different problem:
1
2
3
1. Create order
2. Save idempotency record
3. Idempotency write failsA retry could create another order.
So the problem is not just duplicate detection.
We also need to think about atomicity.
Using a DynamoDB Transaction
DynamoDB provides
TransactWriteItems, which can commit multiple writes atomically.For this example:
1
2
3
4
5
TransactWriteItems
|
+---- Create order
|
+---- Create idempotency recordBoth operations succeed or neither does.
We can still put a condition on the idempotency record:
1
attribute_not_exists(idempotencyKey)Conceptually:
1
2
3
4
5
6
7
8
9
10
TransactWriteItemsRequest transaction =
TransactWriteItemsRequest.builder()
.clientRequestToken(transactionToken)
.transactItems(
createOrderItem(order),
createIdempotencyItem(idempotencyRecord)
)
.build();
dynamoDbClient.transactWriteItems(transaction);The transaction token is useful when the client cannot determine whether the transaction succeeded because of a network failure and retries the request.
The Complete Request Flow
The final request flow looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Client
|
| POST /orders
| Idempotency-Key
v
Spring Boot API
|
v
Calculate request hash
|
v
Check idempotency record
|
+------+------+
| |
Exists Doesn't exist
| |
v v
Validate hash DynamoDB transaction
| |
v +---+---+
Return stored | |
response Order Idempotency
write write
| |
+---+---+
|
SUCCESS
|
v
Return 201When the same request arrives again:
1
2
3
4
5
6
7
8
9
10
11
Client
|
| Same Idempotency-Key
v
API
|
v
Idempotency record exists
|
v
Return previous resultThe business operation does not run again.
What About Lambda Retries?
This becomes even more important when Lambda is part of the architecture.
A common mistake is to assume that a Lambda invocation or event will always be processed exactly once.
Distributed systems do not work that way.
A failure can happen after the side effect but before the successful acknowledgement:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Event
|
v
Lambda
|
v
Database write
|
X
Failure / timeout
|
v
Retry
|
v
Same event againThe consumer should therefore be safe to run again.
The same idea applies whether the duplicate comes from:
- an HTTP client retry
- a Lambda retry
- an SQS redelivery
- a network timeout
- an application-level retry mechanism
Idempotency is useful across all of them.
Idempotency Is Not "Exactly Once"
This distinction matters.
Idempotency does not mean:
The code only executes once.
The operation may execute multiple times.
What we want is:
Multiple executions should produce the same effective business result as one execution.
For example:
1
2
3
Request received -> process
Request retried -> detect duplicate
Request retried -> detect duplicateThe system can still have multiple attempts.
The important part is that the business state does not change incorrectly.
What If the Database Is PostgreSQL or MySQL?
DynamoDB is not automatically the right answer for every application.
Suppose the actual order is stored in PostgreSQL:
1
2
3
4
5
Spring Boot
|
+---- PostgreSQL
|
+---- DynamoDBNow the order and idempotency record live in different systems.
A DynamoDB transaction cannot make a PostgreSQL write atomic.
In that situation, a database-backed design may be simpler.
For example:
1
2
CREATE UNIQUE INDEX idx_orders_idempotency
ON orders (idempotency_key);The database itself can then enforce the uniqueness guarantee.
Depending on the workflow, an outbox pattern can also be useful when the operation needs to produce events reliably.
The important lesson is not:
Always use DynamoDB for idempotency.
It is:
Put the duplicate-prevention guarantee as close as possible to the state change that needs protection.
What About an In-Progress Request?
There is another state that is easy to overlook.
An idempotency record isn't always just:
1
COMPLETEDA request may still be processing:
1
IN_PROGRESSConsider:
1
2
3
4
5
6
7
8
9
10
11
Request A
|
v
IN_PROGRESS
|
| processing...
|
Request B arrives
|
v
Same Idempotency-KeyThe API needs a defined behavior for Request B.
Depending on the use case, you might:
- return
409 Conflict - return a retryable response
- wait briefly
- return the current operation state
There isn't one universal choice.
It is part of the API contract.
How Long Should an Idempotency Key Live?
You usually don't need to keep every idempotency key forever.
The retention period should match the business operation.
For example:
1
2
3
4
Payments -> potentially longer
Orders -> several days may be enough
Short-lived jobs -> minutes
Search requests -> usually unnecessaryDynamoDB TTL can help clean up expired records.
But TTL should be treated as a cleanup mechanism, not as the correctness mechanism.
Your application should still define when an idempotency key is considered valid.
A Simple Concurrency Test
A useful test is to send the same request many times concurrently.
For example:
1
2
3
4
5
6
7
8
9
10
20 concurrent requests
|
v
Same Idempotency-Key
|
v
API
|
v
DynamoDBThe expected result should be:
1
2
3
4
5
6
7
8
20 HTTP requests
1 actual order
1 stored business result
19 requests
|
+---- return the existing resultNot:
1
2
3
20 HTTP requests
20 ordersand not:
1
2
3
4
20 HTTP requests
1 order
19 random errorsA good implementation should make the retry behavior predictable.
What I Would Pay Attention to in Production
1. Validate the Idempotency Key
Don't accept an unlimited-size arbitrary header.
Define a reasonable format and maximum length.
2. Bind the Key to the Request
Store a hash of the normalized request.
That prevents:
1
2
Same key
Different requestfrom silently becoming a valid operation.
3. Define the In-Progress State
Decide what happens when the same idempotency key arrives while the original request is still processing.
4. Keep the Guarantee Close to the Data
Use database constraints or transactional writes where they actually protect the business state.
5. Test Failure Paths
Normal success tests are not enough.
Also test:
- timeout after commit
- duplicate requests
- concurrent requests
- retry after partial failure
- same key with different payload
- expired idempotency keys
Final Architecture
For a DynamoDB-backed workflow, the design looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+----------------+
| Client |
+-------+--------+
|
Idempotency-Key
|
v
+----------------+
| Spring Boot |
| API |
+-------+--------+
|
Validate + Hash
|
v
+-------------------------+
| DynamoDB |
| |
| Conditional / |
| Transactional Write |
+------------+------------+
|
+------------+------------+
| |
v v
+-------------+ +--------------+
| Orders | | Idempotency |
| Table | | Table |
+-------------+ +--------------+The core idea is simple:
1
2
3
4
5
Same operation
+
Same idempotency key
=
Same business resultThe hard part is not generating the key.
The hard part is making sure the key, business operation, retries, concurrent requests, and failure scenarios all agree about what "the same operation" means.
That is where idempotency stops being just an HTTP concern and becomes a distributed-systems problem.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article