Part of: DynamoDB Conditional Writes for Deduplication
This is a focused runbook for implementing first-writer-wins request deduplication against DynamoDB using ConditionExpression. It assumes you understand the guarantee model and PENDING→COMPLETED state machine from DynamoDB Conditional Writes for Deduplication and the broader storage-layer contract from Backend Implementation & Storage Patterns. Every step below is independently verifiable with the commands provided; all code is copy-pasteable.
Problem statement and prerequisites
What you are implementing: an idempotency claim path where exactly one caller wins a race for a given idempotency key, every losing caller receives the winner’s cached response instead of re-executing business logic, and stale claims expire automatically via TTL.
Prerequisites:
- A DynamoDB table with a single-attribute partition key
pk(string) and no sort key, or a composite key if you need per-tenant range queries. - Familiarity with idempotency key generation — the
pkvalue must be a deterministic fingerprint of the request, not a random value generated per attempt. - IAM permissions for
dynamodb:PutItem,dynamodb:UpdateItem, anddynamodb:GetItemon the target table.
Step-by-step implementation
Step 1 — Create the table with a TTL attribute
aws dynamodb create-table \
--table-name idempotency_keys \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live \
--table-name idempotency_keys \
--time-to-live-specification "Enabled=true, AttributeName=expires_at"
PAY_PER_REQUEST (on-demand) billing avoids under-provisioning WCU during traffic spikes, which is the most common cause of ProvisionedThroughputExceededException being mistaken for a condition failure. Verify TTL is active:
aws dynamodb describe-time-to-live --table-name idempotency_keys
# Expected: "TimeToLiveStatus": "ENABLED", "AttributeName": "expires_at"
Step 2 — Issue a conditional PutItem
The condition attribute_not_exists(pk) guarantees the write only succeeds if no item currently exists for that key. The diagram below shows the two possible outcomes for a single call.
Python (boto3)
import boto3, time, json
from botocore.exceptions import ClientError
table = boto3.resource("dynamodb").Table("idempotency_keys")
def claim(pk: str, ttl_seconds: int = 60) -> bool:
now = int(time.time())
try:
table.put_item(
Item={
"pk": pk,
"status": "PENDING",
"expires_at": now + ttl_seconds,
},
ConditionExpression="attribute_not_exists(pk)",
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise # throttling, validation, and other errors must propagate
Node.js (AWS SDK v3)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
async function claim(pk, ttlSeconds = 60) {
const now = Math.floor(Date.now() / 1000);
try {
await ddb.send(new PutCommand({
TableName: "idempotency_keys",
Item: { pk, status: "PENDING", expires_at: now + ttlSeconds },
ConditionExpression: "attribute_not_exists(pk)",
}));
return true;
} catch (err) {
if (err.name === "ConditionalCheckFailedException") {
return false;
}
throw err; // throttling and validation errors must propagate
}
}
Java (AWS SDK v2)
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.Map;
public boolean claim(DynamoDbClient ddb, String pk, long ttlSeconds) {
long now = System.currentTimeMillis() / 1000;
try {
ddb.putItem(PutItemRequest.builder()
.tableName("idempotency_keys")
.item(Map.of(
"pk", AttributeValue.fromS(pk),
"status", AttributeValue.fromS("PENDING"),
"expires_at", AttributeValue.fromN(String.valueOf(now + ttlSeconds))))
.conditionExpression("attribute_not_exists(pk)")
.build());
return true;
} catch (ConditionalCheckFailedException e) {
return false; // duplicate — caller must read back the existing item
}
// ProvisionedThroughputExceededException and others propagate to the caller
}
Step 3 — Catch the exception as a duplicate, not an error
A ConditionalCheckFailedException is a normal, expected control-flow signal — not a fault. When claim() returns false, read the item back with a strongly consistent GetItem and branch on status:
def handle_request(pk: str, execute_fn):
if claim(pk):
try:
result = execute_fn()
complete(pk, result)
return result
except Exception:
fail(pk)
raise
# Duplicate path
resp = table.get_item(Key={"pk": pk}, ConsistentRead=True)
item = resp.get("Item")
if item is None:
# Extremely rare: TTL deleted it between the failed claim and this read
return handle_request(pk, execute_fn) # safe to retry the claim
if item["status"] == "COMPLETED":
return json.loads(item["response"])
if item["status"] == "FAILED":
return handle_request(pk, execute_fn) # previous attempt failed, safe to retry
raise DuplicateInFlight("request still processing, retry after backoff")
Never re-run execute_fn() on the ConditionalCheckFailedException branch — that reintroduces exactly the duplicate-processing bug this pattern exists to prevent.
Step 4 — Transition PENDING to COMPLETED with a guarded UpdateItem
def complete(pk: str, result: dict, ttl_seconds: int = 86400):
table.update_item(
Key={"pk": pk},
UpdateExpression="SET #s = :completed, response = :r, expires_at = :exp",
ConditionExpression="#s = :pending",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":completed": "COMPLETED",
":pending": "PENDING",
":r": json.dumps(result),
":exp": int(time.time()) + ttl_seconds,
},
)
def fail(pk: str, ttl_seconds: int = 300):
table.update_item(
Key={"pk": pk},
UpdateExpression="SET #s = :failed, expires_at = :exp",
ConditionExpression="#s = :pending",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":failed": "FAILED",
":pending": "PENDING",
":exp": int(time.time()) + ttl_seconds,
},
)
The ConditionExpression: #s = :pending on both transitions prevents a slow, orphaned worker from overwriting a COMPLETED or FAILED state that a different retry attempt has already reached.
Step 5 — Verify first-writer-wins under concurrent writers
Fire two concurrent claims for the same key and confirm exactly one succeeds:
python3 - <<'EOF'
import boto3, concurrent.futures, time
table = boto3.resource("dynamodb").Table("idempotency_keys")
pk = f"test-{int(time.time())}"
def try_claim(_):
try:
table.put_item(
Item={"pk": pk, "status": "PENDING", "expires_at": int(time.time()) + 60},
ConditionExpression="attribute_not_exists(pk)",
)
return "WON"
except Exception as e:
return "LOST" if "ConditionalCheckFailedException" in str(e) else f"ERROR: {e}"
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(try_claim, range(20)))
print(results)
assert results.count("WON") == 1, "exactly one writer must win"
print("PASS: first-writer-wins holds under 20 concurrent PutItem calls")
EOF
Verification and testing
Confirm TTL is attached and correctly typed:
aws dynamodb get-item --table-name idempotency_keys --key '{"pk": {"S": "test-123"}}'
# expires_at must be a Number (epoch seconds), not a String — TTL silently ignores non-Number types
Confirm a duplicate call returns the cached response, not a re-executed result:
# First call — should execute business logic and store COMPLETED
aws dynamodb get-item --table-name idempotency_keys --key '{"pk": {"S": "order-789"}}' \
--consistent-read --query 'Item.status.S'
# Expected after completion: "COMPLETED"
# Simulate the duplicate branch directly
aws dynamodb put-item --table-name idempotency_keys \
--item '{"pk": {"S": "order-789"}, "status": {"S": "PENDING"}, "expires_at": {"N": "9999999999"}}' \
--condition-expression "attribute_not_exists(pk)"
# Expected: ConditionalCheckFailedException — proves the key is already claimed
Confirm FAILED items are re-claimable:
aws dynamodb update-item --table-name idempotency_keys \
--key '{"pk": {"S": "order-789"}}' \
--update-expression "SET #s = :f" \
--expression-attribute-names '{"#s": "status"}' \
--expression-attribute-values '{":f": {"S": "FAILED"}}'
# A subsequent claim() call in application code should now detect FAILED and retry cleanly
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Application code catches all exceptions generically and retries on ConditionalCheckFailedException, silently re-executing business logic |
Catch ConditionalCheckFailedException (or SDK-equivalent) by name specifically; route it to the read-back branch, never to a retry-the-claim branch |
dedup_duplicate_reexecution_total counter — any non-zero value indicates the exception handler is miscategorized; log the exception class name on every catch |
expires_at written as a string instead of a Number, so DynamoDB TTL silently never expires the item |
Validate the attribute type in a unit test against the table schema; add a CI check that calls describe-time-to-live and asserts ENABLED before deploy |
dynamo_ttl_stale_items_total — scan-based canary metric counting items past their intended TTL still present |
ProvisionedThroughputExceededException mistaken for a condition failure, causing the caller to treat a throttled request as “duplicate” and return a stale cached response |
Check the specific exception/error code before branching; only ConditionalCheckFailedException means duplicate — throttling errors must retry with backoff, not read-back |
dynamo_error_code structured log field distinguishing ConditionalCheckFailedException from ProvisionedThroughputExceededException; CloudWatch ThrottledRequests |
Worker crashes after claim() succeeds but before complete() or fail() runs, leaving the item stuck in PENDING past its intended processing time |
Keep the PENDING TTL short (30-60 seconds); run a reconciliation scan for items in PENDING with expires_at in the past and re-dispatch or mark FAILED |
dedup_stuck_pending_total gauge, alert if > 0 for longer than 90 seconds; structured log field pending_since on every stuck item found |
Concurrent UpdateItem calls both attempt the PENDING→COMPLETED transition after a retry storm, and the second loses its ConditionExpression: status = :pending check |
Treat the second ConditionalCheckFailedException on complete() as a benign no-op — the item is already COMPLETED by the other attempt; re-read to confirm rather than raising an error |
dedup_concurrent_completion_race_total counter; trace span attribute dynamo.condition_result set to already_completed |
SRE / observability checklist
dynamo_conditional_write_rejected_total— counter incremented on everyConditionalCheckFailedException, labeled by table and operation (claimvs.complete). A sudden spike signals a retry storm or a client-side bug generating duplicate requests at higher-than-normal rates.dynamo_error_code— structured log field on every DynamoDB call that distinguishesConditionalCheckFailedException,ProvisionedThroughputExceededException, andTransactionCanceledException. Never collapse these into one generic error bucket.dedup_stuck_pending_total— gauge counting items withstatus = PENDINGandexpires_atin the past. Alert if greater than zero for more than 90 seconds; indicates a reconciliation job is not running.dynamo_ttl_stale_items_total— canary metric from a periodic scan comparing item count against expected TTL-based deletion timing; catches the up-to-48-hour TTL lag silently masking correctness assumptions.- Span
dedup.claim— OpenTelemetry span wrapping everyPutItemclaim attempt, with attributesdynamo.condition_result(won|lost) andidempotency.key(hashed, not raw, for PII safety). dedup_concurrent_completion_race_total— counter for benign races on thePENDING→COMPLETEDtransition, to distinguish expected concurrency noise from genuine bugs during incident triage.
Related
- DynamoDB Conditional Writes for Deduplication — parent page covering the guarantee model, the
PENDING→COMPLETEDstate machine,TransactWriteItems, and operational budgeting in full. - Backend Implementation & Storage Patterns — the parent section covering all storage-layer idempotency strategies, including Redis and PostgreSQL alternatives.
- Using Redis SETNX for Distributed Request Deduplication — the equivalent single-node claim runbook for teams using Redis instead of DynamoDB.
- Database Unique Constraints & Upserts — the relational-database analogue using
ON CONFLICT DO NOTHINGand unique indexes. - Implementing Optimistic Concurrency with Version Columns — the version-check pattern this runbook’s
complete()step borrows for guarded state transitions.