Error Handling for AI Agent Web Tools: Retryable Errors, Timeouts and Partial Pages
An agent loop that treats every failure the same way does two expensive things. It retries pages that will never load, and it pastes raw error text into the model as if the model were the retry layer. Web tools fail in two different shapes, and only one of them should ever reach the model.
This guide is the handling layer in front of the AgentSearch web tools on the Pocket Agentic Portal: Web Search, Web Extract and Web Render. Each call is $0.005 in USDC, over x402 on Base or MPP on Tempo, with no signup and no API key. The payment client itself is covered in Pay-per-call APIs for AI agents. What follows is what you do with the response.
Two failures, two places to look
The call did not go through. The portal answers with a non-2xx status and a JSON body that is not the usual envelope:
{ "error": { "code": "UPSTREAM_ERROR", "message": "...", "retryable": true } }
The tool never returned a page. Retry in your own code when retryable is true, and honor a Retry-After header. A 503 with Retry-After means the service is temporarily not offered. A 400 with UPSTREAM_REJECTED (retryable: false) means the service refused your request body; sending the same body again gets the same answer, so fix the input instead of retrying. If the supplier does not deliver, the portal settles nothing on x402, so that attempt is not a paid call. (With an MPP push payment the transfer has already moved; it stays redeemable for 15 minutes on a retry with the same credential.)
The call went through, and the website (or the search backend) did not. Search, Extract and Render still answer HTTP 200. Through the portal that body is wrapped:
{
"portal": { "provenance": "third-party-supplier", "serviceId": "agentsearch-web-extract-v1", "schemaCheck": "passed" },
"data": { "url": "https://example.com/slow", "requested_url": "https://example.com/slow", "markdown": "", "error": { "code": "TARGET_TIMEOUT", "retryable": true } }
}
data is the supplier’s own response. You paid for it, because it was delivered. Retrying it is a new call at $0.005, not a free replay of a failed payment.
Branch on code and retryable. Never branch on message. Messages are for your logs. The model should see a short note you wrote.
Codes worth recognizing
These are the supplier codes. All three tools put them in data.error, next to empty or partial content. Portal codes stay on the non-2xx body above.
| Tool | Where it appears | What it means |
|---|---|---|
| Search | HTTP 200, results: [], plus error |
The search did not finish. UPSTREAM_TIMEOUT means nothing came back inside the 4.5 s deadline. Search sets retryable: true on these errors. |
| Extract | HTTP 200, empty markdown, error |
The page fetch failed. The deadline is 4 s (TARGET_TIMEOUT). retryable is true for timeouts, connection failures, other fetch failures (TARGET_FETCH_FAILED), and a target 429 or 5xx, and false otherwise. A 404 is TARGET_HTTP_ERROR and is not worth a blind retry. UNSUPPORTED_CONTENT means the URL is not HTML or text, such as a PDF or an image. |
| Render | HTTP 200, content empty or partial, error |
Headless Chromium had a problem. The hard deadline is 4.2 s, counted from when the request arrives. TARGET_DEADLINE is always retryable: true and can still include the text that rendered in time (in markdown, sometimes as plain text if conversion did not finish). retryable is also true for TARGET_UNREACHABLE, TARGET_DNS_ERROR, RENDER_FAILED and a target 429 or 5xx. UNSUPPORTED_CONTENT is not a page. |
Extract and Render name their deadlines differently. Extract’s clock runs out as TARGET_TIMEOUT and the body is empty. Render’s clock runs out as TARGET_DEADLINE, and a slow page can still carry the text that rendered in time.
A few input details that look like errors and are not:
- Search
max_resultsonly returns 1 to 5. A value outside that range is clamped, not rejected. - Extract
timeout_msaccepts 1000 to 60000, and anything above 4000 is capped at 4000. Setting 30000 does not make the service wait 30 seconds. A value outside 1000 to 60000 is rejected as a bad request body. - Render
formatsacceptsmarkdown,text,htmlandlinks.wait_untilisdomcontentloaded(the default),loadornetworkidle.
url on an Extract or Render response is the final URL after redirects whenever the page was reached (after an Extract timeout or connection failure it is just the URL you sent). requested_url is what you sent: Render always includes it, Extract only on an error. Cite url.
Step 1: classify the HTTP response
Assume payFetch is the x402 client from the pay-per-call tutorial, with a spend cap already installed. This wrapper does not throw for an expected failure. Throwing dumps a stack into the model. Returning a small object lets the next function decide.
// web-tools.ts
const PORTAL = "https://agent.pocket.network/v1";
// The paid fetch from the x402 tutorial (wrapFetchWithPaymentFromConfig).
declare const payFetch: typeof fetch;
type PortalFailure = {
kind: "portal";
status: number;
code: string;
retryable: boolean;
retryAfter: string | null;
};
type Delivered = { kind: "data"; data: Record<string, unknown> };
export async function callService(
serviceId: string,
path: string,
body: unknown,
): Promise<PortalFailure | Delivered> {
const res = await payFetch(`${PORTAL}/${serviceId}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
const err = json.error ?? {};
return {
kind: "portal",
status: res.status,
code: String(err.code ?? `HTTP_${res.status}`),
retryable: err.retryable === true,
retryAfter: res.headers.get("Retry-After"),
};
}
if (!json.data || typeof json.data !== "object") {
throw new Error("Unexpected response shape; refusing to use it.");
}
return { kind: "data", data: json.data };
}
payFetch is in scope from your payment setup. A 402 never reaches this function if the client pays and retries on its own. If you are only inspecting prices, an unpaid POST returns 402 and never a page. That path is in the other tutorial.
Count delivered calls, and do not spin on portal errors:
const CALL_BUDGET = 8; // 8 delivered calls = $0.04 for this task
let deliveredCalls = 0;
function budgetLeft(): boolean {
return deliveredCalls < CALL_BUDGET;
}
export async function delivered(
serviceId: string,
path: string,
body: unknown,
): Promise<PortalFailure | Delivered | { kind: "budget" }> {
if (!budgetLeft()) return { kind: "budget" };
let last: PortalFailure | null = null;
for (let attempt = 1; attempt <= 2; attempt++) {
const result = await callService(serviceId, path, body);
if (result.kind === "data") {
deliveredCalls += 1; // a data.error still counts: the call was served
return result;
}
last = result;
if (!result.retryable || attempt === 2) break;
await sleep(retryDelayMs(result.retryAfter, attempt));
}
return last!;
}
function retryDelayMs(retryAfter: string | null, attempt: number): number {
const seconds = retryAfter != null ? Number(retryAfter) : NaN;
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
return 400 * attempt;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Only portal failures are retried here, and only once. On x402 a portal failure is not settled, so that second attempt is not another $0.005. A website error is inside data, so it increments deliveredCalls and is not looped.
Step 2: a page result the model can act on
type PageData = {
url?: string;
requested_url?: string;
title?: string | null;
markdown?: string;
error?: { code: string; retryable?: boolean; upstream_status?: number | null; request_id?: string } | null;
};
type ModelPage =
| { status: "ok"; url: string; title: string | null; markdown: string; note?: string }
| { status: "retry_later"; url: string; code: string }
| { status: "skip"; url: string; code: string }
| { status: "stop"; code: "BUDGET" };
const THIN = 400; // local heuristic, not a service field
export function decidePage(page: PageData, requested: string): ModelPage {
const url = page.url || requested;
const err = page.error ?? null;
const markdown = (page.markdown ?? "").trim();
if (err) {
// Log err.request_id and err.upstream_status. Do not put err.message in the tool result.
console.warn("page error", err.code, err.upstream_status ?? "", err.request_id ?? "");
if (err.code === "TARGET_DEADLINE" && markdown) {
return {
status: "ok",
url,
title: page.title ?? null,
markdown,
note: "partial: the render hit its 4.2s deadline",
};
}
return err.retryable
? { status: "retry_later", url, code: err.code }
: { status: "skip", url, code: err.code };
}
if (markdown.length < THIN) return { status: "skip", url, code: "THIN" };
return { status: "ok", url, title: page.title ?? null, markdown };
}
THIN is yours. A short markdown string with no error often means Extract received a JavaScript shell and pulled the empty template. It is not a guarantee, and it is not a signal to retry Extract. It is a signal to spend one Render call, which actually runs the page.
TARGET_DEADLINE is the exception to “an error means skip.” If markdown is non-empty, hand it over and say it may be incomplete. If it is empty, retryable decides, the same as any other code, and for TARGET_DEADLINE it is always true, so the URL comes back as retry_later.
Step 3: Extract first, Render only when the page needs it
Render is the same price as Extract, so it is a fallback, not the default. One Render after a thin Extract is two delivered calls, $0.01.
export async function readPage(url: string): Promise<ModelPage> {
const first = await delivered("agentsearch-web-extract-v1", "/v1/extract", {
url,
formats: ["markdown"],
max_chars: 8000,
});
if (first.kind === "budget") return { status: "stop", code: "BUDGET" };
if (first.kind === "portal") return { status: "retry_later", url, code: first.code };
let decision = decidePage(first.data as PageData, url);
if (decision.status !== "skip" || decision.code !== "THIN") return decision;
const second = await delivered("agentsearch-web-render-v1", "/v1/render", {
url,
formats: ["markdown"],
max_chars: 8000,
});
if (second.kind === "budget") return { status: "stop", code: "BUDGET" };
if (second.kind === "portal") return { status: "retry_later", url, code: second.code };
decision = decidePage(second.data as PageData, url);
if (decision.status === "skip" && decision.code === "THIN") {
return { status: "skip", url: decision.url, code: "EMPTY_PAGE" };
}
return decision;
}
Endpoints, for reference:
| Service | POST | Portal page |
|---|---|---|
| Web Extract | https://agent.pocket.network/v1/agentsearch-web-extract-v1/v1/extract |
agentsearch-web-extract-v1 |
| Web Render | https://agent.pocket.network/v1/agentsearch-web-render-v1/v1/render |
agentsearch-web-render-v1 |
| Web Search | https://agent.pocket.network/v1/agentsearch-web-search-v1/v1/search |
agentsearch-web-search-v1 |
What the model should see is the ModelPage, not the raw payload. retry_later means “this URL might work on another turn.” skip means “pick a different result.” stop means the task budget is gone and the loop should answer with what it has. None of those require the model to understand TARGET_HTTP_ERROR.
Pass markdown through as a tool result, the way you would any other untrusted page text. Do not concatenate it into the system prompt. When you asked for a page and got a page, the words on it are still someone else’s words.
Step 4: search failures look almost the same
Search does not use TARGET_* codes. A failure is still HTTP 200, with an empty results array and an error object whose retryable flag you already know how to read (the search adapter sets it to true on these errors). UPSTREAM_TIMEOUT is the 4.5 s deadline. Other codes include UPSTREAM_UNAVAILABLE and UPSTREAM_HTTP_ERROR. A code can also be passed through from the search backend, so keep using the flag rather than a private allowlist.
type SearchHit = { title: string; url: string; content: string; published_date?: string | null };
type SearchData = {
query: string;
results: SearchHit[];
retrieved_at?: string;
error?: { code: string; retryable?: boolean; request_id?: string };
};
export async function searchWeb(query: string, maxResults = 5) {
const result = await delivered("agentsearch-web-search-v1", "/v1/search", {
query,
max_results: Math.min(5, Math.max(1, maxResults)),
});
if (result.kind === "budget") return { status: "stop" as const, code: "BUDGET" };
if (result.kind === "portal") return { status: "retry_later" as const, code: result.code };
const data = result.data as unknown as SearchData;
if (data.error) {
console.warn("search error", data.error.code, data.error.request_id ?? "");
return data.error.retryable
? { status: "retry_later" as const, code: data.error.code }
: { status: "skip" as const, code: data.error.code };
}
return {
status: "ok" as const,
retrieved_at: data.retrieved_at ?? null,
results: data.results.map((r) => ({ title: r.title, url: r.url, snippet: r.content })),
};
}
Clamping max_results in the client matches the service, which clamps rather than returning INVALID_REQUEST. Asking for 10 and getting 5 is success.
A practical loop is: searchWeb, then readPage on the hits you still need, and stop when a call returns stop or you have enough ok pages. If readPage says skip, take the next hit. If it says retry_later, leave that URL for another turn instead of calling it again inside this one. You already spent the retry the portal failure was entitled to.
What to log, and what to withhold
Keep a line per call with the service id, the code, retryable, upstream_status, request_id, and whether you counted it against the budget. That is enough to see, later, that a task spent $0.03 because three pages 404’d.
Do not put error.message in the tool result. It is not a stable API, and it is not something you want the model to improvise on. The stable contract is code plus retryable.
Also check the final URL. If requested_url and url differ, the citation should be url. Two search hits that redirect to the same final URL are one page. Skipping the second saves a call.
If you are on MCP instead of your own client
Pocket’s MCP server pays over x402 on Base and exposes search_services, describe_service (both free) and call_service (paid). The config belongs in the client’s env block:
{
"mcpServers": {
"pocket-network": {
"command": "npx",
"args": ["-y", "@pocket-network/agentic-portal-mcp"],
"env": {
"POCKET_PRIVATE_KEY": "0x…",
"POCKET_MAX_TOTAL_ATOMIC": "1000000",
"POCKET_MAX_PER_CALL_ATOMIC": "5000"
}
}
}
}
1000000 is a $1.00 session cap and 5000 is $0.005 per call, in USDC atomic units. With no key, only the free tools work. POCKET_QUOTE_ONLY=true returns the seller’s terms and does not pay.
The classifier above still matters. call_service returns the receipt and the portal envelope unchanged, with the supplier payload, error object included, under data, so the model will try to interpret it unless your instructions say otherwise. Tell it the same three outcomes: use the page, try another URL, or stop. The spend caps are what hold if it tries anyway. More on the MCP setup: MCP web search for AI agents.
Why the split is worth the code
A retry policy written against HTTP status alone is wrong for these tools. The interesting failures are HTTP 200. A policy written against the message string breaks the first time a message is reworded. retryable is the contract, the call budget is the safety limit, and partial markdown from a render deadline is content, not a crash.
For when to pick Search, Extract or Render in the first place, see Search, Extract, Render: three web tools for AI agents. Pocket’s integration guide covers the envelope and both payment methods.
Get started
Put decidePage in front of any loop that already calls Extract or Render, and point callService at your paid fetch. Start with Web Extract for static pages, add Web Render only after a thin result, and use Web Search when the agent does not yet have a URL. The three services are $0.005 per call on the Pocket Agentic Portal, with no signup and no API key. More on the AgentSearch blog.
More from the blog
- x402 Tutorial: Pay-Per-Call APIs for AI Agents, No API Key Required
- Search → Extract → Render: Three Web Tools for AI Agents, Not One Browse
- Add Web Search to Your AI Agent with MCP (2026 Guide)
Service pages: Web Search API · Web Extract API · Web Render API