Bedrock Agents retry. Models re-call tools. Clients replay. If your action-group Lambda performs a naive DynamoDB PutItem on every invoke, you will double-charge and double-book. Production agents need idempotency keys threaded from session to tool, conditional writes that make duplicates a no-op, and compensation steps when a later hop fails. At-least-once is the delivery contract — idempotent handlers are your exactly-once illusion.
⚡ TL;DR: Require
Idempotency-Key(ortoolUseId+ business key) on every mutating tool. Persist outcomes in DynamoDB with conditional expressions on that key. Return the same success payload on replay. Compensate with explicit undo tools, not silent second charges. Deep dives: Bedrock Agents tool use, DynamoDB advanced patterns, Lambda timeouts/DLQs, Lambda sandboxes.
Thread the idempotency key through the agent
Prefer a client-supplied key stored in sessionAttributes, falling back to Bedrock’s tool use id for a single hop. Business mutations should not key only on tool use id if the model may legitimately call twice for one user intent — use tenantId + intentId.
type ToolEvent = {
parameters?: { name: string; value: string }[];
sessionAttributes?: Record<string, string>;
actionGroup: string;
apiPath: string;
httpMethod: string;
};
export function resolveIdempotencyKey(event: ToolEvent, toolUseId?: string): string {
const fromParam = event.parameters?.find((p) => p.name === "idempotencyKey")?.value;
const fromSession = event.sessionAttributes?.idempotencyKey;
const key = fromParam || fromSession || toolUseId;
// ❌ Never: random UUID generated inside the Lambda on each invoke
if (!key || !/^[\w:.-]{8,128}$/.test(key)) throw new Error("missing_idempotency_key");
return key;
}
Conditional writes that make retries safe
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
} from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.ORDERS_TABLE!;
export async function createBooking(input: {
tenantId: string;
idempotencyKey: string;
amountCents: number;
customerId: string;
}) {
const pk = `TENANT#${input.tenantId}`;
const sk = `IDEMPOTENT#${input.idempotencyKey}`;
try {
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
pk,
sk,
entityType: "BOOKING",
amountCents: input.amountCents,
customerId: input.customerId,
status: "CONFIRMED",
createdAt: new Date().toISOString(),
},
// ✅ Succeed only once
ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)",
})
);
return { ok: true as const, replay: false, status: "CONFIRMED" };
} catch (err: any) {
if (err?.name !== "ConditionalCheckFailedException") throw err;
const existing = await ddb.send(new GetCommand({ TableName: TABLE, Key: { pk, sk } }));
// ✅ Idempotent replay — return original outcome
return {
ok: true as const,
replay: true,
status: existing.Item?.status ?? "CONFIRMED",
amountCents: existing.Item?.amountCents,
};
}
}
// ❌ Naive put — doubles on agent retry
await ddb.send(new PutCommand({ TableName: TABLE, Item: { pk, sk: `BOOKING#${uuid()}` } }));
Action-group handler shape
export const handler = async (event: ToolEvent) => {
const idempotencyKey = resolveIdempotencyKey(event);
const tenantId = event.sessionAttributes?.tenantId;
if (!tenantId) return respond(event, 403, { error: "missing_tenant" });
if (event.apiPath === "/bookings" && event.httpMethod === "POST") {
const amountCents = Number(event.parameters?.find((p) => p.name === "amountCents")?.value);
const result = await createBooking({
tenantId,
idempotencyKey,
amountCents,
customerId: event.sessionAttributes!.customerId!,
});
return respond(event, 200, result);
}
return respond(event, 400, { error: "unsupported" });
};
Keep IAM least-privilege and Guardrails on the agent (Bedrock Agents). For TTL and state patterns see DynamoDB advanced patterns.
Compensations when hop 2 fails
If booking succeeds and payment fails, expose an explicit cancelBooking tool that is also idempotent — don’t “fix” by creating a second booking with a new key.
export async function cancelBooking(tenantId: string, idempotencyKey: string) {
// Conditional transition CONFIRMED -> CANCELLED; replay-safe
await ddb.send(
new PutCommand({
/* UpdateCommand with ConditionExpression status IN (CONFIRMED, CANCELLED) */
})
);
}
| Failure mode | Safe behavior |
|---|---|
| Agent retries same tool | Conditional put → replay response |
| Model calls tool twice, same user intent | Same business idempotency key |
| Downstream payment fails | Compensating cancel tool |
| Lambda timeout after write | Replay returns original (timeouts/DLQs) |
Closing checklist
✅ Dos
– ✅ Require idempotency keys on every mutating tool
– ✅ DynamoDB conditional writes keyed by tenant + key
– ✅ Return identical payloads on replay
– ✅ Design compensating tools as first-class action group ops
– ✅ Test retry storms in CI before prod
❌ Don’ts
– ❌ Don’t generate a new UUID per Lambda invoke for bookings
– ❌ Don’t rely on “the model won’t call twice”
– ❌ Don’t use PutItem without a condition for money paths
– ❌ Don’t compensate by creating duplicate opposite charges without keys
– ❌ Don’t log full card PANs in tool responses
Related reading
- Amazon Bedrock Agents: Tool Use, Memory, and Production Guardrails
- AWS DynamoDB Advanced Patterns for Production
- Lambda Timeouts, Retries, and DLQs: Idempotent Failure Handling
- LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
