Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes

Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes

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 (or toolUseId + 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply