Is it safe to retry? For most APIs the honest answer is no, not without work. Any request that can be retried will eventually be retried against a handler that already succeeded, and unless you have designed for it, every retry applies the operation again.
The retries are not hypothetical. A client times out and sends the request a second time. API Gateway gives up at its integration timeout and returns a 504 while the Lambda behind it keeps running and commits the write anyway, a case I covered in Part 4 of the Rust on Lambda series. An SQS or event-source trigger redelivers a message because delivery is at-least-once by default. The common thread is that at-least-once delivery is what you get for free; exactly-once is something you build. This post is about building it with DynamoDB conditional writes.
What idempotency means
An operation is idempotent when running it twice with the same input has the same effect as running it once. The second call is not a second mutation, it is a no-op that leaves the system where the first call left it. That is the whole property. The work below is making operations that are not naturally idempotent behave that way.
The easy cases
Two cases need almost nothing.
Updates that set a field to a fixed value are already idempotent. SET status = "shipped" applied twice is the same as applied once, so a plain UpdateItem is safe to retry with no extra machinery.
Creates with a client-chosen key are nearly as simple. Write the row with a PutItem guarded by a condition that the key does not already exist:
client
.put_item()
.table_name("orders")
.item("pk", AttributeValue::S(order_id.clone()))
.item("total", AttributeValue::N(total.to_string()))
.condition_expression("attribute_not_exists(pk)")
.send()
.await?;
The first write wins. A retry finds the item already present and fails with ConditionalCheckFailedException, which you treat as success because the row you wanted is already there. This case is trivial precisely because the deduplication key is the primary key, so the condition has something to check against. The interesting case is the one where it does not.
Non-idempotent updates
The hard case is increments and accumulations: balance -= amount, inventory -= 1, attempts += 1. The item already exists, so attribute_not_exists tells you nothing: the primary key is present whether or not this particular operation has run. Apply a wallet debit twice and you have double-charged the customer.
The fix is to give the operation an identity and refuse to apply the same identity twice. The caller supplies a request ID with the operation. The caller creates this ID, not the server, and the whole scheme depends on the caller generating it once per logical operation (a UUID, say) and sending the same value on every retry. A fresh ID per retry defeats the check, since each one looks like a new operation. This is why APIs that expose idempotency ask the client for a key, such as Stripe’s Idempotency-Key header. The handler does a single UpdateItem that both checks whether that request ID was the last one applied and stamps it in the same atomic write:
match client
.update_item()
.table_name("wallets")
.key("pk", AttributeValue::S(wallet_id))
.update_expression("SET balance = balance - :amount, last_request_id = :rid")
.condition_expression("attribute_not_exists(last_request_id) OR last_request_id <> :rid")
.expression_attribute_values(":amount", AttributeValue::N(amount.to_string()))
.expression_attribute_values(":rid", AttributeValue::S(request_id))
.send()
.await
{
Ok(_) => Ok(()), // first time we saw this request: the debit applied
Err(SdkError::ServiceError(e)) if e.err().is_conditional_check_failed_exception() => {
Ok(()) // retry of an already-applied debit: treat as success, do nothing
}
Err(e) => Err(e.into()),
}
On the first call the condition holds, the balance drops, and the request ID is recorded. On a retry carrying the same request ID, DynamoDB evaluates the condition against the now-stamped item, the condition fails, and the debit does not run a second time. The condition and the mutation are part of one UpdateItem, so DynamoDB applies them atomically against that item. There is no window where the balance has changed but the stamp has not, and two concurrent retries cannot both win. One write does the whole job, with no separate idempotency table, status state machine, or extra round trip.
Where this stops
The single last_request_id attribute remembers only the most recent request applied to that item. It deduplicates a retry of the same operation, which is exactly the failure mode above, but it does not keep a history, so it will not catch an old request ID that comes back after a different one has been applied in between. For “did this exact debit already run,” that is enough. Operations that span multiple writes or reach outside DynamoDB, or that must return the original response on retry, need more than a single conditional write; Luca Palmieri’s Zero to Production in Rust works through that fuller, Postgres-shaped version with a saved-response state machine.
Key takeaways
- At-least-once delivery is the default you inherit from clients, API Gateway timeouts, and event sources. Exactly-once is something you build on top of it.
- An operation is idempotent when running it twice has the same effect as running it once. The job is making operations that are not naturally idempotent behave that way.
- Fixed-value updates (
SET status = "shipped") are already idempotent and safe to retry as-is. - Creates with a client-chosen key are guarded by
attribute_not_existson the primary key. A retry fails withConditionalCheckFailedException, which you treat as success. - Increments and accumulations are the hard case: the item already exists, so checking for the key proves nothing. Give the operation a caller-supplied request ID and refuse to apply the same ID twice.
- A single
UpdateItemcan both check the stored request ID and stamp the new one. The check and the mutation are atomic, so there is no race and no second round trip. - The caller must generate the request ID once per logical operation and resend the same value on every retry. A fresh ID per retry defeats the check.
- A single
last_request_idonly catches a retry of the most recent operation. Multi-write workflows, side effects outside DynamoDB, or replaying the original response need a fuller saved-response approach.
Links
- DynamoDB optimistic locking with version numbers: AWS docs on conditional writes for concurrency control, the same primitive used here
- The Secret Life of AWS: the Idempotency Key + Amazon DynamoDB: walkthrough of the idempotency-key pattern on DynamoDB
- Handle conditional write errors in high-concurrency scenarios with Amazon DynamoDB: AWS database blog on
ConditionalCheckFailedExceptionunder contention - Zero to Production in Rust by Luca Palmieri: the book’s idempotency chapter, the fuller treatment with saved responses backed by Postgres
- Rust Meets Serverless, Part 4: When Lambda Is the Wrong Tool: the 504-while-Lambda-keeps-running case that motivates this post