AWS Builder Center

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.

Software Devloper

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 /withdrawals
Now 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 #2
If the API treats both requests as independent operations:
1
2
Order #101 <- Request #1
Order #102 <- Request #2
We 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

Duplicate
Both 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-2f9c7b7a1f21
The 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 response
The 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: idempotencyKey
An 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 = 2
Later:
1
2
3
4
5
Request 2

Idempotency-Key: abc

quantity = 10
Those 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
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody 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
write
The 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 ----> FAILED
Only 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 response
What 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 fails
A 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 record
Both 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 201
When 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 result
The 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 again
The 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 duplicate
The 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
|
+---- DynamoDB
Now 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
COMPLETED
A request may still be processing:
1
IN_PROGRESS
Consider:
1
2
3
4
5
6
7
8
9
10
11
Request A
|
v
IN_PROGRESS
|
| processing...
|
Request B arrives
|
v
Same Idempotency-Key
The 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 unnecessary
DynamoDB 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
DynamoDB
The 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 result
Not:
1
2
3
20 HTTP requests

20 orders
and not:
1
2
3
4
20 HTTP requests

1 order
19 random errors
A 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 request
from 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 result
The 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.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article