Tool Errors, Timeouts and Retries

How to return failures to the model as toolResult errors it can act on, and where retries, timeouts and idempotency belong in an agent loop.

Tools fail. APIs time out, records are missing, arguments are wrong, downstream services throttle. In a normal service you would raise an exception and let the caller deal with it. In an agent loop you have a second option that is often better: tell the model, and let it adapt.

Return errors, do not raise them

The Converse API's toolResult block has a status field with two values, "success" and "error". Setting status: "error" and putting a readable message in content feeds the failure back into the conversation. A good error message lets the model recover on the next turn: it can fix a malformed argument, ask the user for the missing detail, or try a different tool. Crashing the loop gives the user nothing.

Write errors for a reader, not a log

"KeyError: 'order_id'" is useless to the model. "No order found with ID ORD-99999. Order IDs look like ORD-12345 — ask the customer to confirm the number." is actionable. Say what went wrong, and say what would work. Do not leak stack traces, internal hostnames, SQL, or credentials into the message; that text goes into the model's context and, usually, into whatever the model says next to the user.

Decide which failures the model should even see

Not every failure belongs in the conversation. Split them:

  • Model-fixable (bad arguments, not found, ambiguous request) — return as a toolResult error with guidance.
  • Transient infrastructure (throttling, a 503, a connection reset) — retry inside your executor with exponential backoff and jitter, and only surface it if the retries are exhausted. Sending a throttling error to the model just wastes a round trip.
  • Terminal or dangerous (authorisation denied, a validation failure that looks like probing) — return a flat refusal without detail, and alert on it. Do not describe the permission model to something that may be under attacker influence.

Timeouts, and the two clocks

Every tool needs a timeout shorter than the budget of whatever is hosting it. There are usually at least two clocks running: your tool's own HTTP or database timeout, and the enclosing limit — a Lambda function's configured timeout, an API Gateway integration timeout, or the orchestration limit an Amazon Bedrock Agent applies to an action group. If your tool's timeout is longer than the container's, the container dies first and you never get to return a useful error. Configure retries deliberately too: the AWS SDKs let you set a retry mode and maximum attempts on the client, and adaptive mode handles Bedrock throttling more gracefully than blind retries.

Retries need idempotency

The moment you retry a tool that changes state, you risk doing it twice — and an agent that re-reads a timeout may cheerfully call the tool again itself. Give mutating tools an idempotency key, enforce it with a conditional write in DynamoDB or the idempotency utility in Powertools for AWS Lambda, and make the second call return the first call's result rather than repeating the action. Cap the loop as well: a maximum iteration count plus a per-conversation tool-call budget stops a confused model from burning tokens and money in circles. Emit a metric for tool errors by tool name to CloudWatch — a tool whose error rate spikes is usually a description problem, not an outage.

Example

Example · python
import time
import random
import botocore.exceptions

MAX_ATTEMPTS = 3

# Only these AWS errors are transient. Everything else is terminal.
RETRYABLE_CODES = {
    "ThrottlingException",
    "TooManyRequestsException",
    "ServiceUnavailableException",
    "InternalServerException",
    "ModelTimeoutException",
}


class PermissionDenied(Exception):
    """Raised by execute_tool when the caller is not entitled to the action."""


def _error(tool_use_id: str, text: str) -> dict:
    return {"toolResult": {
        "toolUseId": tool_use_id,
        "content": [{"text": text}],
        "status": "error",
    }}


def run_tool_with_recovery(tool_use: dict, principal: dict) -> dict:
    """Return a Converse toolResult block - success or error - but never raise."""
    tool_use_id = tool_use["toolUseId"]

    for attempt in range(MAX_ATTEMPTS):
        try:
            output = execute_tool(tool_use, principal)  # validates, then runs
            return {"toolResult": {
                "toolUseId": tool_use_id,
                "content": [{"json": output}],
                "status": "success",
            }}

        except ToolRejected as exc:
            # Model-fixable: hand it back with guidance, do not retry.
            return _error(tool_use_id, f"{exc} Correct the arguments and try again.")

        except PermissionDenied:
            # Terminal: refuse flatly, reveal nothing, alert elsewhere.
            return _error(tool_use_id, "Not permitted. Do not retry this tool.")

        except botocore.exceptions.ClientError as exc:
            code = exc.response.get("Error", {}).get("Code", "")
            if code not in RETRYABLE_CODES:
                # AccessDenied, ValidationException, ResourceNotFound and
                # friends are not transient - never spin on them.
                return _error(tool_use_id, "The order service rejected the request.")
            if attempt == MAX_ATTEMPTS - 1:
                return _error(tool_use_id, (
                    "The order service is temporarily unavailable. "
                    "Tell the customer to try again shortly; do not retry now."
                ))
            time.sleep((2 ** attempt) + random.random())

        except TimeoutError:
            if attempt == MAX_ATTEMPTS - 1:
                return _error(tool_use_id, (
                    "The order service timed out. "
                    "Tell the customer to try again shortly; do not retry now."
                ))
            time.sleep((2 ** attempt) + random.random())

When to use it

  • A booking agent receives "no availability on 2026-09-02; the nearest free slots are the 3rd and 4th" and offers those alternatives instead of failing the conversation.
  • An action-group Lambda function retries a throttled DynamoDB read internally with backoff, so the agent never sees the transient failure or wastes an inference turn on it.
  • A payments tool enforces an idempotency key in DynamoDB, so a retried timeout returns the original transaction rather than charging the customer twice.

Discussion

  • Be the first to comment on this lesson.