Development

Error Bodies a Model Can Parse and Act On

Antonio Evans10 min read

A clean API request and response diagram: a client arrow points to a server box, and the server replies with a rectangular error payload made of labelled blocks (code, message, retryable, retry_after, docs, request_id).
Key points

Key points

  1. 1A model can only branch on fields it can see, so an error body needs a stable machine-readable code, a short human message, a boolean retryable flag, a retry_after value in seconds, a docs URL, a request ID, and a details array for field-level problems.
  2. 2Return the same envelope for every non-2xx status, so the agent branches on code and retryable rather than on prose or on the HTTP status alone.
  3. 3Build on RFC 9457 Problem Details for the standard members, and add the agent-specific extensions as top-level properties rather than inventing a second format.
  4. 4Strip the HTML, the stack traces and the marketing copy: agents pay tokens for every character and act on the fields they can find.

Why an error body is a prompt, not a message

When a human developer reads a 400, they bring documentation, logs and prior context to the table. A model does not. It receives the response body as text in its context window and has to decide on the next action from that text alone. If the body says only "Validation failed", the model has three bad options: retry the identical request, invent a plausible fix, or stop and ask a human. All three waste time and money.

This is not a hypothetical. Cloudflare reports that structured error responses for agents cut payload size and token usage by more than 98% compared with HTML error pages, measured against a live rate-limit error response, and it now serves RFC 9457-compliant JSON and Markdown to clients that ask for it (the Cloudflare engineering blog). The same post makes the point that matters for design: the old HTML said "You were blocked", which describes the error but not the recovery. The new payload says "wait 30 seconds and retry", which is an instruction.

Designers have been converging on this for a while. Nordic APIs argues that status codes are not a conversation, because a 404 tells you something was missing and a 500 tells you something broke, but neither tells an agent whether to retry, escalate or change its input (Nordic APIs). Zuplo's guide on agent-callable APIs puts the same problem in numbers: Postman's State of the API Report found that 89% of developers use AI in their daily work, but only 24% design APIs with AI agents in mind (Zuplo). The error body is where that gap shows up first, because it is the part of the API the agent reads most often when it is confused.

There is a second, quieter reason to care. Practitioners who have wired agents to real APIs report that the most common failure is not a bad model output, it is an error the agent never saw: the SDK raised HTTP Error 422 and threw away the response body, so the model had nothing to reason about (Pol Alvarez Vecino). A well-shaped body is worthless if your client library swallows it. Build the body and pass it through.

The seven fields and what each one is for

RFC 9457 defines a JSON object with five members: type, title, status, detail and instance, plus extension members, and it explicitly says its purpose is to carry machine-readable details of errors so APIs do not each define a new format (RFC 9457). That is the right substrate. What it does not define is the set of fields a model needs to choose an action, so treat the standard as the outer shell and add the agent fields as extensions.

Spring Framework's implementation shows how cleanly this works in practice: its ProblemDetail type is "a simple container for both standard fields defined in the spec, and for non-standard ones", and its Jackson integration unwraps a properties map so extension fields render as top-level JSON (Spring Framework docs). You do not need Spring to copy the idea, only the discipline of one container with two layers.

FieldTypeRequiredWhat the model does with it
type or codestringyesBranch target. Match it against a known list and pick a handler.
messagestringyesShort human text. Read by the model and often shown to the user later.
retryablebooleanyesStop or try again. No inference required.
retry_afternumber, secondswhen retryable is trueSleep exactly this long, then retry, instead of guessing a backoff.
docs_urlstring, URLyesFollow for the long explanation when the code is unfamiliar.
request_idstringyesPaste into a support ticket or a log search. Never invent it.
detailsarrayfor validation errorsRead the per-field list and rewrite the request field by field.

Two design rules follow from the table. First, code must be stable and documented: a string like invalid_currency survives a change to the human message, while a bare number such as 1 does not. Speakeasy's design guide makes the case directly, showing a response that improves from a bare "Trip is not possible" message to one with "code": "trip_not_possible", which lets a client write if (error.code === 'trip_not_possible') instead of matching on prose (Speakeasy). Second, retryable must be explicit even when it looks obvious. Temporal's cookbook, which parses HTTP responses into retry policies, lists 408, 409, 429, 500, 502, 503 and 504 as retryable and 400, 401, 403, 404 and 422 as not, but it also honors a non-standard X-Should-Retry header that overrides the status code when the server knows better (Temporal). Put that override in the body, where the model will actually read it.

The canonical envelope

Here is the shape to standardize on. Every non-2xx response, from any endpoint, from any service behind your gateway, returns exactly these top-level keys. Extra keys are allowed, missing ones are not.

{
  "type": "https://api.example.com/problems/currency-not-supported",
  "title": "Currency not supported",
  "status": 422,
  "code": "currency_not_supported",
  "message": "The 'currency' field must be one of the supported ISO 4217 codes.",
  "retryable": false,
  "retry_after": null,
  "docs_url": "https://docs.example.com/errors/currency_not_supported",
  "request_id": "req_01J9ZK4M8Q2C7R3V6B1N5TXW4A",
  "details": [
    {
      "field": "currency",
      "pointer": "#/payment/currency",
      "issue": "not_in_enum",
      "allowed": ["USD", "EUR", "GBP", "JPY"],
      "received": "USDT"
    }
  ]
}

Notice three deliberate choices. The type is a URL under your control, which RFC 9457 recommends so that a problem type can be documented and dereferenced (RFC 7807); the separate code is a short string because models copy short strings more reliably than long URLs into a condition. The retry_after key is present and null rather than absent, so a model that reads the field always finds it and does not fall back to "maybe it is in a header". And details carries a pointer, a JSON Pointer, so the model can point at exactly the sub-object to fix instead of re-sending the whole document.

The Swagger guide on doing API errors well makes the general case: custom error formats are one of the biggest sources of friction in integrations, and the challenges multiply with AI consumers that need precision rather than a paragraph (Swagger). A single envelope is the cheapest way to get that precision, because it makes the agent's error handler a single function.

Example payloads for the codes agents misread most

Status codes alone under-determine the action, which is why the same 4xx can mean "fix your request" or "wait and try again". Below are worked payloads for the cases where agents most often pick the wrong branch.

404, but the resource is on its way. A 404 normally means stop: the thing does not exist. That is wrong for a resource created asynchronously a moment ago. Encoding "not yet" as a retryable 404 is the fix, and it is exactly the case Cloudflare handles by telling the agent whether to retry or escalate via an owner_action_required field (Cloudflare).

{
  "type": "https://api.example.com/problems/not-ready",
  "title": "Resource not ready",
  "status": 404,
  "code": "resource_not_ready",
  "message": "The export is still being generated. Poll again shortly.",
  "retryable": true,
  "retry_after": 5,
  "docs_url": "https://docs.example.com/errors/resource_not_ready",
  "request_id": "req_01J9ZK5A1B7D9E2F4G6H8J0K2L",
  "details": []
}

429, where the wait matters more than the status. The rate-limit case has a standard header, Retry-After, and a well-formed 429 should carry it alongside a Problem Details body (Zuplo on 429). Duplicate the seconds into retry_after in the body. Not every client surfaces headers to the model, and a model that reads "30" in the body will wait 30 seconds rather than invent a backoff.

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too many requests",
  "status": 429,
  "code": "rate_limited",
  "message": "Rate limit exceeded for this key. Retry after the indicated delay.",
  "retryable": true,
  "retry_after": 30,
  "docs_url": "https://docs.example.com/errors/rate_limited",
  "request_id": "req_01J9ZK6B2C8E0F3G5H7J9K1L3M",
  "details": [{ "field": "limit", "issue": "exceeded", "scope": "api_key", "window_seconds": 60 }]
}

401 versus 403, the classic wrong branch. Agents routinely retry a 401 with the same key, which cannot work, or give up on a 403 that only needed a different scope. Temporal's list treats both as non-retryable, and that is right for the request as sent, but the recovery differs and the body should say so (Temporal). For a 401, set retryable: false and put the refresh action in message. For a 403 that is scope-related, include the required scope in details so the agent can request the right grant instead of stopping.

{
  "type": "https://api.example.com/problems/insufficient-scope",
  "title": "Insufficient scope",
  "status": 403,
  "code": "insufficient_scope",
  "message": "This token lacks the 'exports:write' scope. Request a token with that scope or use a read-only path.",
  "retryable": false,
  "retry_after": null,
  "docs_url": "https://docs.example.com/errors/insufficient_scope",
  "request_id": "req_01J9ZK7C3D9F1G4H6J8K0L2M4N",
  "details": [{ "field": "scope", "issue": "missing", "required": "exports:write", "granted": ["exports:read"] }]
}

500 and 503 are not the same. Both are server-side, but a 500 may hide a bug the client triggered, while a 503 is a temporary capacity problem. Cloudflare's 1xxx class shows the useful shape: classify the failure with error_code and error_category, then let retryable and retry_after drive the backoff (Cloudflare). For a 500 you did not expect, say so in the message and set retryable: true with a short delay, because a single retry costs little and often clears a transient dependency failure.

Retry rules that belong in the body, not in the client

The most useful decision rule is a precedence order: trust the body before the status code, and the status code before the agent's own judgment. A retry loop in an agent should look like this, in order:

  1. If retryable is false, stop and surface the message and docs URL to the user or the planning step.
  2. If retryable is true and retry_after is a number, sleep that long exactly, then retry.
  3. If retryable is true and retry_after is null, fall back to exponential backoff with jitter.
  4. After N failures, stop and include request_id in the escalation.

That precedence is worth writing down because the cheap version, retry every 5xx and give up on every 4xx, gets two important cases wrong. It retries a 503 with fixed spacing and causes a thundering herd, and it gives up on a retryable 404 that would have resolved in five seconds. PostGrid's retry documentation recommends capping at five attempts spread over a 30-minute window using exponential backoff with full jitter, where the actual delay is a random value between zero and the calculated delay, and pairing retries with idempotency keys so a repeated create does not duplicate an order (PostGrid). The body cannot carry the idempotency decision for the client, but it can and should carry the delay.

There is a scaling point hidden here. Every field you add to the error body is paid on every failure, across every step of every workflow. Cloudflare's experience with a 1015 rate-limit page, where structured JSON and Markdown cut token usage by more than 98% against HTML, is the argument for stripping CSS and copy but keeping retryable and retry_after (Cloudflare). A 300-token JSON body with five useful fields beats a 40,000-token HTML page that says "blocked".

Idempotency also changes what "retryable" can honestly mean. An error on a non-idempotent POST is not safe to retry just because it was transient; the request may have landed. The honest body for that case sets retryable: false and says "check the resource before retrying" with a link to the endpoint that lists recent creations. That is a decision rule most first-page error guides miss.

Handing model errors back in the same shape

An agent does not only consume your errors. It also produces errors of its own, from the model call that grounds its plan or the tool that turns text into a call. If the agent's internal failures use a different shape from your API's, the agent's own error handler becomes the second place you have to maintain parsing logic. Return them in the same envelope.

This is where an inference layer earns its keep. If an agent calls several model providers, each with its own error format, rate-limit behavior and quota semantics, normalizing them is the same job as normalizing your own API errors: one code, one retryable flag, one delay. Quanta Inference exposes text, image, video, audio and embedding models through a single OpenAI-compatible API key, which means one error shape to normalize rather than one per provider (https://quantaintelligence.ai/inference). Whatever you pick, write the normalization once, at the boundary, and hand the model a body it can read.

Two practical checks before you ship. First, verify your client library does not discard the body on error, which is the failure Pol Alvarez Vecino describes with HTTP Error 422 (Pol Alvarez Vecino). Second, test the body with the model that will actually read it: give it the payload and the tool schema and ask which action it would take, then check it against your intent. If it retries a 403, the message was too vague.

A checklist and a worked example

Here is the checklist to run against every endpoint before you call the error model done.

CheckPass condition
Same keys on every non-2xxA schema test compares the keys of 400, 401, 403, 404, 409, 422, 429, 500 and 503 bodies
Code is a stable stringDocumented in a table with the status and the recovery action
retryable is present and booleanTrue or false, never null, never absent
retry_after is secondsA number when retryable is true, null otherwise; matches the Retry-After header when present
docs_url resolves200 for every code in the table, at deploy time
request_id is unique and searchableThe same value appears in the server log line for the failure
details use JSON PointerValidation errors point at the failing sub-field, not the whole document
No implementation detail leaksNo stack traces, SQL, internal hostnames or class names in message
Content-Type is honestapplication/problem+json when you follow RFC 9457, application/json otherwise

The leak check is not cosmetic. Spring's own documentation notes that it is good practice to customize problem details for framework exceptions specifically to avoid revealing implementation details (Spring Framework docs). A model will happily repeat an internal hostname back to a user in its answer.

A worked example makes the value concrete. Suppose an agent submits an order with three problems: a missing email, a currency outside the enum, and a duplicate client reference. A single-error 400 forces three round trips, and each one spends input tokens re-sending the whole request. A single 422 with three details entries lets the model fix all three and one request attempts the call again. That is the option the Stack Overflow discussion on error return structure converges on: return the full list of field problems rather than stopping at the first, because it saves requests and lets the client revalidate only what changed (Stack Overflow). Doing the same math on a 30-step agent workflow with an average of two validation failures per step, you save roughly 30 extra round trips per run, and every saved trip is one fewer set of tokens.

Finally, version the error contract like any other interface. API versioning exists to manage change without breaking consumers, and an error schema is part of what a consumer depends on (Akamai). If you need to add a field, add it. If you need to change the meaning of retryable for an existing code, introduce a new code. An agent that has learned the old code from a schema or a docs page should not silently change behavior because you edited a message string.

FAQ

FAQ

What is the minimum viable error body for a model?

A code, a message, a retryable flag, a retry_after value and a docs URL. Add a request_id and a details array as soon as you have support and validation to handle. Below five fields, the model is guessing, and guessing is where wasted retries and hallucinated fixes come from.

Should I use RFC 9457 or my own format?

Use RFC 9457 as the shell and add your agent fields as extension members. It gives you type, title, status, detail and instance, plus a defined media type and a place to put extras, and it was written specifically so that APIs stop inventing new error formats (RFC 9457). A custom format works too, but it loses interoperability with tooling that already understands problem details.

How do I stop an agent from retrying a 403 forever?

Make the body say what is missing. Set retryable to false, name the required scope or permission in details, and put the corrective action in the message. An agent that reads "requires exports:write, you hold exports:read" can request the right grant; an agent that reads "forbidden" can only retry or give up.

Does the retry_after field make Retry-After redundant?

No, send both. Keep the standard header for generic HTTP clients and proxies, and duplicate the seconds into the body for the model, which may never see headers. Make a test assert the two agree, because a body that says 30 while the header says 5 will send half your clients to the wrong schedule.

How do I test whether the body is good enough?

Feed the payload and the tool schema to the model that will consume it and ask which action it would take next. Grade the answer against what you intended, across one payload per code. If it retries a non-retryable error or stops on a retryable one, the field that would have told it otherwise is missing or ambiguous.

Sources

Sources

  1. Slashing agent token costs by 98% with RFC 9457 ...
  2. Designing API Error Messages for AI Agents
  3. How to Design APIs That AI Agents Can Actually Use
  4. Handling HTTP Errors in AI Agents: Lessons from the Field
  5. RFC 9457: Problem Details for HTTP APIs
  6. Error Responses :: Spring Framework
  7. Errors Best Practices in REST API Design - Speakeasy
  8. Retry policy from HTTP responses | AI Cookbook
  9. RFC 7807: Problem Details for HTTP APIs
  10. Problem Details (RFC 9457): Doing API Errors Well - Swagger
  11. HTTP 429 Too Many Requests: Causes, Headers & Retry ...
  12. Response Codes and Retry Logic - Getting Started - ReadMe
  13. REST API error codes return structure - Stack Overflow
  14. What Is API Versioning? - Akamai
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles