An agent was asked to define "idempotent" for a glossary. The dictionary tool returned a 404. The model, seeing an error it did not understand, tried again with "Idempotent", then "idempotency", then "idempotent (computing)", then went back to the original spelling. Each call was a few credits; the loop had no ceiling; the user's tab had long since closed.
Nothing in that story is a bug in the API or the model. The tool returned an accurate answer ("no entry for that word") in a form the model could not interpret, and the loop around it had no way to stop. Both of those are your code, and both are fixable.
01Errors are now part of the conversation
In the Fundamentals courses, an error from an API went to your code and your code decided: retry, give up, show a message. In an agent there is a second decider. Whatever your tool returns goes back to the model as a tool result, and the model chooses the next step: try other arguments, try another tool, ask the user, or answer without the data.
That gives you a design choice for every failure: does the model need to know about this, and can it do anything useful with it?
- Some failures are about the input, and the model is the one who chose the input. It should hear about them, in words that point at a fix.
- Some failures are transient. The model cannot do anything about a busy minute, so your tool layer should absorb them with a bounded retry and only report if that fails.
- Some failures are fatal for the whole run: a bad key, an empty credit balance. The model cannot fix those either, and telling it invites it to try. Your loop should stop and tell a human.
Model APIs give you a channel for the first group. In the Anthropic Messages API a tool_result can carry is_error: true; in OpenAI's API you return the error text as the tool message content. MCP servers do the same thing: the APIVerve MCP server returns a failed call as tool content with isError: true and the API's own message, rather than a protocol error, precisely so the agent can read it and correct itself.
02Map every status to a decision
Here is how APIVerve's statuses translate into agent behaviour. The first column is what the API says; the last is what the model should see.
| Status | What it means | Who handles it | What the model sees |
|---|---|---|---|
400 | Invalid or missing parameter | Model | The API's message plus "fix the argument" |
404 | Unknown API, or no result for this input | Model | "No result for X. Do not retry the same input." |
401 | Missing or unrecognised key | Your loop: halt | Nothing; the run stops |
403 | Valid key, but not allowed for this call (scoping, IP allow-list) | Your loop: halt | Nothing; the run stops |
429 + credits left | Per-minute rate limit | Tool layer: wait retry-after, retry once or twice | Only if retries fail: "temporarily unavailable" |
429 + x-api-remaining-credits: 0 | Monthly credits exhausted | Your loop: halt | Nothing; the run stops |
5xx, timeout, network | Upstream trouble | Tool layer: one retry | If still failing: "temporarily unavailable" |
Three rows deserve a closer look.
404 is often an answer, not a failure. The dictionary returns 404 with "word not found" when it has no entry, and World Time returns 404 with "city not found". That is information: the input was understood and nothing matched. The right model behaviour is to stop asking about that input, tell the user, or try a meaningfully different input (a larger nearby city). Retrying the same string is never right.
curl "https://api.apiverve.com/v1/dictionary?word=retry" \
-H "x-api-key: YOUR_API_KEY"A hit returns definitions and definitionCount. Try a technical term such as idempotent: it returns 404, word not found.
When we tried idempotent, the dictionary returned 404. The word is real; this dictionary has no entry for it. Your error text should say exactly that, "no entry in the dictionary", not "invalid word", or the model will tell the user the word does not exist.
Both 429s look the same until you read a header. A rate-limit 429 clears in seconds. A credits 429 does not clear until the billing cycle renews. Check x-api-remaining-credits: if it is 0, no amount of waiting or retrying helps, and every retry is a wasted request. Halt the run and surface it.
403 is about the key, not the request. The agent's sub-key should be scoped to exactly the APIs in its tool list, so a 403 means the two have drifted apart: the agent was given a tool its key can't use. No argument the model picks will fix that. Halt the run so a person can fix the key's scope or remove the tool. Sending the user to sign in again or retrying are both wrong.
Inside an agent run, a tool call returns 429 and the response header x-api-remaining-credits is 0. What should happen?
03Write errors the model can act on
A good tool error message answers three questions in plain words: what happened, whether retrying the same call could help, and what to do instead. Compare:
- Bad:
Error: 404 - Bad:
{"status":"error","error":"city not found","data":null} - Good:
No city named "Xyzzyville" was found. Retrying the same name will not help. Ask the user to check the spelling or name a larger nearby city.
The good version costs a few more tokens and saves whole turns. It also keeps internals out of the conversation: no stack traces, no URLs, never a key.
Here is a tool layer that classifies failures into those three groups. Transient errors are retried inside the tool and never reach the model unless retries fail. Model-fixable errors become is_error results with instructions. Fatal errors throw a separate exception type that the loop catches and stops on.
// Node 18+, ESM.
export class ToolError extends Error {} // the model should hear about this
export class AgentHalt extends Error {} // stop the whole run
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function apiverve(id, params, { retries = 2, timeoutMs = 15000 } = {}) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(`https://api.apiverve.com/v1/${id}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(timeoutMs),
});
} catch {
if (attempt < retries) { await sleep(1000 * 2 ** attempt); continue; }
throw new ToolError(`${id} did not respond. This is temporary; answer without it or try later.`);
}
const body = await res.json().catch(() => ({}));
if (res.ok && body.status === 'ok') return body.data;
const msg = body.error ?? `HTTP ${res.status}`;
if (res.status === 401) throw new AgentHalt(`APIVerve key rejected (401): ${msg}`);
if (res.status === 429) {
if (res.headers.get('x-api-remaining-credits') === '0') throw new AgentHalt('APIVerve credits exhausted for this cycle.');
if (attempt < retries) { await sleep(Number(res.headers.get('retry-after') || 2) * 1000); continue; }
throw new ToolError(`${id} is rate limited right now. Do not call it again in this answer.`);
}
if (res.status === 403) throw new AgentHalt(`This key is not allowed to call ${id} (403): ${msg}`);
if (res.status === 404) throw new ToolError(`No result: ${msg}. Retrying the same input will not help.`);
if (res.status >= 500 && attempt < retries) { await sleep(1000 * 2 ** attempt); continue; }
if (res.status >= 500) throw new ToolError(`${id} failed upstream. This is temporary; answer without it.`);
throw new ToolError(`${id} rejected the input: ${msg}. Fix the argument before calling again.`);
}
}# Python 3.9+.
import os
import time
import requests
class ToolError(Exception):
"""The model should hear about this."""
class AgentHalt(Exception):
"""Stop the whole run."""
def apiverve(api_id, params, retries=2, timeout=15):
attempt = 0
while True:
try:
r = requests.get(
f"https://api.apiverve.com/v1/{api_id}",
params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=timeout,
)
except requests.RequestException:
if attempt < retries:
time.sleep(2 ** attempt); attempt += 1; continue
raise ToolError(f"{api_id} did not respond. This is temporary; answer without it or try later.")
try:
body = r.json()
except ValueError:
body = {}
if r.ok and body.get("status") == "ok":
return body["data"]
msg = body.get("error") or f"HTTP {r.status_code}"
if r.status_code == 401:
raise AgentHalt(f"APIVerve key rejected (401): {msg}")
if r.status_code == 429:
if r.headers.get("x-api-remaining-credits") == "0":
raise AgentHalt("APIVerve credits exhausted for this cycle.")
if attempt < retries:
time.sleep(float(r.headers.get("retry-after") or 2)); attempt += 1; continue
raise ToolError(f"{api_id} is rate limited right now. Do not call it again in this answer.")
if r.status_code == 403:
raise AgentHalt(f"This key is not allowed to call {api_id} (403): {msg}")
if r.status_code == 404:
raise ToolError(f"No result: {msg}. Retrying the same input will not help.")
if r.status_code >= 500:
if attempt < retries:
time.sleep(2 ** attempt); attempt += 1; continue
raise ToolError(f"{api_id} failed upstream. This is temporary; answer without it.")
raise ToolError(f"{api_id} rejected the input: {msg}. Fix the argument before calling again.")The retry budget is small on purpose. Inside an agent, a user is usually waiting for an answer, and two quick retries cover the brief blips; anything longer is better reported than hidden. Backoff, jitter and longer budgets belong to batch jobs, which the Fundamentals course covered.
Your wrappers can add their own errors in the same style. The get_local_time wrapper from the previous lesson might raise ToolError('No city named "Xyzzyville". Ask the user for a nearby larger city.') when World Time returns 404:
curl "https://api.apiverve.com/v1/worldtime?city=Xyzzyville" \
-H "x-api-key: YOUR_API_KEY"A city the API does not know returns 404 with the message city not found.
Which tool error message gives the model the most useful next step?
04Never loop forever
An agent loop ends when the model stops asking for tools. Usually that happens in a few turns. When it does not, it is because the model is stuck: retrying a failure, chasing a goal it cannot reach, or bouncing between two tools. None of the model APIs will stop it for you. Four limits will.
- A turn ceiling. Count model requests per user message and stop at a number that comfortably fits your longest real task, often 6 to 10.
- A repeated-failure guard. Key failures by tool name plus arguments. The second identical failure means the model is not learning from the error; remove that tool for the rest of the run or stop.
- A deadline. A wall-clock limit per user message, after which you return whatever answer you have.
- A halt path. Fatal errors end the run immediately and produce a message for a human, not for the model.
Here is the loop from lesson one with all four, using the error classes above:
import Anthropic from '@anthropic-ai/sdk';
import { ToolError, AgentHalt } from './tool-errors.mjs';
import { TOOLS, definitions } from './tools.mjs'; // tools.mjs now imports apiverve() from tool-errors.mjs
const client = new Anthropic();
const MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-opus-5-5';
const MAX_TURNS = 8;
const DEADLINE_MS = 60_000;
export async function ask(question) {
const messages = [{ role: 'user', content: question }];
const failures = new Map();
const disabled = new Set();
const started = Date.now();
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (Date.now() - started > DEADLINE_MS) return 'That took too long. Please try a narrower question.';
const tools = definitions.filter((d) => !disabled.has(d.name));
const res = await client.messages.create({ model: MODEL, max_tokens: 4096, tools, messages });
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') {
return res.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
}
const results = [];
for (const block of res.content) {
if (block.type !== 'tool_use') continue;
const key = `${block.name}:${JSON.stringify(block.input)}`;
try {
if (disabled.has(block.name)) throw new ToolError(`${block.name} is disabled for this conversation.`);
const out = await TOOLS[block.name].run(block.input);
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(out) });
} catch (err) {
if (err instanceof AgentHalt) {
console.error('[agent halted]', err.message); // alert a human here
return 'This assistant cannot look that up right now. The team has been notified.';
}
const text = err instanceof ToolError ? err.message : `${block.name} failed unexpectedly.`;
failures.set(key, (failures.get(key) ?? 0) + 1);
if (failures.get(key) >= 2) disabled.add(block.name);
results.push({ type: 'tool_result', tool_use_id: block.id, content: text, is_error: true });
}
}
messages.push({ role: 'user', content: results });
}
return 'I could not finish that within the step limit. Here is what I can say without more lookups.';
}Two details are easy to miss. Every tool_use block must get a matching tool_result, even when it fails or the tool is disabled; dropping one makes the next request invalid. And when you disable a tool you also remove its definition from the next request, so the model stops considering it rather than calling it and being refused.
Next: Control cost and latency, where you count what every turn costs in tokens, credits and seconds, and put a budget on each conversation.