Using ConditionExpression for Atomic Idempotency

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 PENDINGCOMPLETED 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 pk value must be a deterministic fingerprint of the request, not a random value generated per attempt.
  • IAM permissions for dynamodb:PutItem, dynamodb:UpdateItem, and dynamodb:GetItem on 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.

Conditional PutItem decision flow A caller issues PutItem with ConditionExpression attribute_not_exists(pk). If the item is absent, the write succeeds and the item is stored as PENDING. If the item already exists, DynamoDB returns ConditionalCheckFailedException, and the caller performs a strongly consistent GetItem to read back the existing status, returning the cached COMPLETED response or polling if still PENDING. PutItem cond: attribute_not_exists(pk) item existed? NO Write succeeds status = PENDING, run logic YES ConditionalCheckFailedException this caller is a duplicate GetItem (consistent read) COMPLETED → return cached response PENDING → poll or 409

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 PENDINGCOMPLETED 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

  1. dynamo_conditional_write_rejected_total — counter incremented on every ConditionalCheckFailedException, labeled by table and operation (claim vs. complete). A sudden spike signals a retry storm or a client-side bug generating duplicate requests at higher-than-normal rates.
  2. dynamo_error_code — structured log field on every DynamoDB call that distinguishes ConditionalCheckFailedException, ProvisionedThroughputExceededException, and TransactionCanceledException. Never collapse these into one generic error bucket.
  3. dedup_stuck_pending_total — gauge counting items with status = PENDING and expires_at in the past. Alert if greater than zero for more than 90 seconds; indicates a reconciliation job is not running.
  4. 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.
  5. Span dedup.claim — OpenTelemetry span wrapping every PutItem claim attempt, with attributes dynamo.condition_result (won | lost) and idempotency.key (hashed, not raw, for PII safety).
  6. dedup_concurrent_completion_race_total — counter for benign races on the PENDINGCOMPLETED transition, to distinguish expected concurrency noise from genuine bugs during incident triage.