Guide · Lambda Powertools

AWS Lambda Powertools Idempotency for MCP Servers

AWS Lambda Powertools Idempotency makes your Lambda handlers retry-safe: the first time a request is processed, the result is stored in DynamoDB; any subsequent call with the same idempotency key returns the cached result immediately without re-executing the handler code. For MCP server tool calls that write to external systems — creating files, calling payment APIs, sending notifications, modifying databases — this prevents the most dangerous class of retry bug: duplicate side effects. Three things teams get wrong: not setting in_progress_expiration_seconds — if Lambda is killed mid-execution (timeout, OOM, infrastructure fault), the DynamoDB record stays in INPROGRESS state permanently, blocking all future retries for that key until the TTL expires; using the full event dict as the idempotency key when the event contains a timestamp or random nonce that changes on each retry, making every retry look like a new request; forgetting that the cached response is returned verbatim — if your handler returns a presigned URL that expires in 5 minutes and the TTL is 1 hour, the second caller gets an expired URL from cache.

TL;DR

Use DynamoDBPersistenceLayer with a partition key and TTL attribute. Set expires_after_seconds=3600 and in_progress_expiration_seconds=30. Extract a stable idempotency key from the payload with event_key_jmespath — never hash a timestamp or nonce. Return the same response shape on every invocation (idempotency only stores the return value, not side effects).

DynamoDB table setup and persistence layer

Powertools Idempotency uses DynamoDB as the persistence store. The table needs a string partition key (default: id) and a TTL attribute (default: expiration). No sort key is needed.

# CloudFormation / SAM
Resources:
  IdempotencyTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: mcp-idempotency
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: expiration
        Enabled: true
from aws_lambda_powertools.utilities.idempotency import (
    DynamoDBPersistenceLayer,
    IdempotencyConfig,
    idempotent,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

persistence_store = DynamoDBPersistenceLayer(table_name="mcp-idempotency")

config = IdempotencyConfig(
    event_key_jmespath="body.callId",          # extract stable key from payload
    expires_after_seconds=3600,                 # cache successful responses for 1 hour
    in_progress_expiration_seconds=30,          # release INPROGRESS lock after 30s
    raise_on_no_idempotency_key=True,           # fail if key extraction returns None
    payload_validation_jmespath="body.userId",  # validate same user on retry
)

@idempotent(config=config, persistence_store=persistence_store)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event["body"]["toolName"]
    tool_input = event["body"]["input"]
    result = execute_mcp_tool(tool_name, tool_input)
    return {"statusCode": 200, "body": json.dumps(result)}

Idempotency key selection

The idempotency key is a hash of the value extracted by event_key_jmespath. The key must be stable across retries of the same logical request — it must not change between the original call and any retry. Common patterns:

Event source Stable key path Avoid
API Gateway requestContext.requestId or custom headers.x-idempotency-key Full event (includes timestamp); headers.x-amzn-trace-id (changes per retry)
SQS standard messageId Message body content if it contains a timestamp or auto-generated field
Custom MCP client body.callId — UUID generated by the client before any retries body.timestamp, body.requestedAt
Async callback detail.transactionId from EventBridge event detail.eventTime (changes per event delivery attempt)

With raise_on_no_idempotency_key=True, if the JMESPath expression returns null (e.g., the client didn't include a callId), Powertools raises IdempotencyKeyError and the handler does not execute. This is the safe default: it forces clients to send a stable key rather than accidentally processing every request as unique.

Status transitions and concurrent requests

Powertools Idempotency uses DynamoDB conditional writes to manage concurrent requests with the same key safely. The status field transitions:

Status Meaning What happens on subsequent call
INPROGRESS First invocation is currently executing Raises IdempotencyAlreadyInProgressError — caller should retry after delay
COMPLETED First invocation completed successfully Returns cached response without calling handler
EXPIRED TTL elapsed — DynamoDB auto-deletes the record Handler re-executes as if new request; new INPROGRESS record created

The transition from no-record → INPROGRESS uses a ConditionalCheckFailedException guard: the write only succeeds if no record with that key exists, or the existing record is EXPIRED. This prevents two concurrent Lambda invocations with the same key from both proceeding. The second invocation receives IdempotencyAlreadyInProgressError and should retry after a short delay.

In-progress expiry — handling Lambda kills

in_progress_expiration_seconds is the most important safety parameter. Without it, any Lambda invocation that is killed before completion (timeout, OOM, infrastructure failure, forced stop) leaves a record in INPROGRESS state. Subsequent retries see the INPROGRESS record and raise IdempotencyAlreadyInProgressError — the request is permanently blocked until the TTL expires hours later.

config = IdempotencyConfig(
    event_key_jmespath="body.callId",
    expires_after_seconds=3600,
    # If Lambda is killed after 30s without completing, the INPROGRESS
    # record is considered stale and the next retry re-executes the handler
    in_progress_expiration_seconds=30,
)

Set in_progress_expiration_seconds to your Lambda timeout + a 5-second buffer. For a 25-second timeout function, use 30 seconds. This ensures that if the Lambda was killed after its maximum possible runtime, the next retry can always proceed.

idempotent_function — for internal operations

The @idempotent decorator protects the entire Lambda handler. For finer-grained control — making only specific internal operations idempotent while allowing the handler to run — use @idempotent_function:

from aws_lambda_powertools.utilities.idempotency import idempotent_function

@idempotent_function(
    data_keyword_argument="tool_call",
    config=config,
    persistence_store=persistence_store,
)
def execute_mcp_tool(tool_call: dict) -> dict:
    # This function is idempotent — only the tool execution is deduplicated
    # The handler can run multiple tools; each is independently idempotent
    tool_name = tool_call["name"]
    return tool_implementations[tool_name](tool_call["input"])

def handler(event: dict, context: LambdaContext) -> dict:
    results = []
    for tool_call in event["toolCalls"]:
        result = execute_mcp_tool(tool_call=tool_call)
        results.append(result)
    return {"results": results}

The data_keyword_argument specifies which function argument to hash for the idempotency key. The function must be called with that argument as a keyword argument.

Common failures

Symptom Root cause Fix
Every call treated as unique despite same payload event_key_jmespath path includes a timestamp, nonce, or auto-generated field that changes per call Log the extracted key at DEBUG level; fix the JMESPath to extract a stable client-generated ID
Retries permanently blocked after Lambda kill in_progress_expiration_seconds not set — INPROGRESS record never expires Set in_progress_expiration_seconds to function timeout + 5s buffer
IdempotencyKeyError raised on every call raise_on_no_idempotency_key=True and the JMESPath returns null — key field not present in event Check event shape; ensure clients always include the key field; or set raise_on_no_idempotency_key=False if key is optional
Cached response returned with expired presigned URLs Handler returns short-lived URLs; idempotency TTL longer than URL lifetime Set expires_after_seconds to shorter than the shortest URL lifetime, or remove presigned URLs from cached response
DynamoDB conditional check failures in production logs Normal behavior — concurrent same-key calls; second call is rejected as expected Not an error — client should retry after in_progress_expiration_seconds

Uptime monitoring for MCP servers that rely on idempotent retries

Idempotency makes retries safe. AliveMCP makes sure there's something to retry against — continuous probes against your MCP server endpoint with Slack alerts when it goes down.

Join the waitlist →