Development

Structured Error Objects an Agent Can Parse and Act On

Antonio Evans9 min read

A split-view illustration: on the left, a server log with dense lines of text and stack traces; on the right, a clean JSON error object with five labeled fields (code, message, retryable, next_action, context).
Key points

Key points

  1. 1A tool error needs five fields: a stable code, a plain message, a retryable boolean, a next_action with one of three values, and an optional context object.
  2. 2The model decides to retry from code plus retryable, and decides what to do when retrying will not help from next_action.
  3. 3Swallowing the response body behind a bare status code is the single most common cause of agents looping on the same mistake.
  4. 4Schema validation and snapshot tests across every error path keep the shape from drifting when someone edits a handler.

The gap between what your logs show and what the model sees

Server logs are written for a human with a terminal open. They carry stack traces, request IDs, timestamps, thread names and a pager link. None of that helps a language model choose its next move. What helps is a small, boring, predictable payload that answers three questions: what went wrong, can I try again, and if I cannot, who should.

The failure mode is well documented. When an HTTP client calls something equivalent to raise_for_status, it raises an exception keyed on the status code and discards the response body that explains the problem. One team building a domain management SDK watched an agent repeatedly send a single name field where the API required first_name and last_name. The API was returning a detailed body naming both missing fields, but the agent only ever saw "HTTP Error 422". The agent kept trying variations without ever touching the real problem, a pattern the authors called a doom loop in their write-up of handling HTTP errors in AI agents. Once the SDK re-raised an error that carried the response text, the agent fixed the call on the next attempt.

That case study generalizes into a rule: the error payload is a user interface for the model. Design it like one.

The five fields, and why each one earns its place

A workable schema is small enough to read in one glance and specific enough that a model cannot misread it.

{
  "code": "validation_error",
  "message": "The field 'due_date' must be an ISO 8601 date, for example 2025-03-14.",
  "retryable": false,
  "next_action": "ask_user",
  "context": {
    "field": "due_date",
    "received": "next friday"
  }
}
FieldTypeRequiredWhat the model does with it
codestring, from a closed enumyesDistinguishes transient from permanent, and picks a repair strategy
messagestring, plain languageyesQuotes it to the user or rewrites the call
retryablebooleanyesGates any second attempt at all
next_actionone of retry, ask_user, stopyesTells the model what to do when retrying is pointless
contextobject or nullnoCarries the offending field, the received value, or a retry_after_ms hint

The code has to come from a closed set that the prompt documents. If handlers invent codes at runtime, the model has no prior to reason from and will treat every error as novel. Twelve to twenty codes covers most tool surfaces: validation_error, not_found, permission_denied, rate_limited, upstream_timeout, conflict, payload_too_large, unsupported_input.

The message carries the remedy, not just the diagnosis. "The field 'due_date' must be an ISO 8601 date" tells the model how to fix it; "Bad request" does not. Keep it to two sentences and put the expected format after the word "for example" so it is easy to copy.

retryable answers the cheapest question first. A validation error is almost never retryable, because a deterministic function with the same input returns the same output. A rate limit or a connection reset usually is. Be strict here. Marking a permanent error retryable is worse than not having the field, because it invites the loop.

next_action covers the case where retryable is false but the task is not dead. There are exactly three sensible outcomes. Retry means the same call may succeed, possibly after a delay. Ask_user means a person holds information the tool cannot derive, such as an address or an approval. Stop means the task cannot continue and the agent should report failure rather than burn tokens.

The optional context object is where diagnosis becomes actionable. A field name, the value that was rejected, a list of allowed values, a retry_after_ms number, or an upstream request ID for support. Keep it small and never put credentials or full request bodies in it.

A decision table for assigning code, retryable and next_action

Most arguments about error design collapse once you write the mapping down. This is the table your handlers should implement, and the one a reviewer should be able to check.

Situationcoderetryablenext_actioncontext
Schema or type mismatch in argumentsvalidation_errorfalseask_userfield, received, expected
Resource does not existnot_foundfalsestopresource, id
Caller lacks scopepermission_deniedfalsestoprequired_scope
Rate limit hitrate_limitedtrueretryretry_after_ms
Upstream 5xx or timeoutupstream_timeouttrueretryretry_after_ms
Concurrent edit lostconflictfalseask_userexpected_version, current_version
Body over the size cappayload_too_largefalseask_userlimit_bytes, received_bytes
Argument is valid but ambiguousambiguous_inputfalseask_usercandidates

The rates are worth stating plainly. A model that has a retryable flag will usually respect it if the flag is a real boolean rather than a string. A model that has next_action will almost always follow it when there is exactly one value. Both properties disappear the moment the value set is open ended.

HTTP status codes are not a substitute. The web standards distinguish these carefully, and the distinctions matter. A 431 Request Header Fields Too Large means the request headers were oversized and may be resubmitted after reducing them, per the MDN reference on 431. A 413 means the request body was too large; a 414 means the URI was too long as this HTTP status guide explains. Those are useful signals for the transport layer, and your code enum can map to them, but a model reading only the number cannot tell whether to shorten a cookie, split a payload, or stop. That is exactly the work next_action does.

Wiring it into the tool layer

The wrapper that calls your tool is the place to enforce the contract. Every exception path, including ones you did not anticipate, has to leave that wrapper in the standard shape.

from dataclasses import dataclass, field
from typing import Literal, Optional

@dataclass
class ToolError:
    code: str
    message: str
    retryable: bool
    next_action: Literal["retry", "ask_user", "stop"]
    context: Optional[dict] = None

def to_payload(err: ToolError) -> dict:
    return {
        "code": err.code,
        "message": err.message,
        "retryable": err.retryable,
        "next_action": err.next_action,
        "context": err.context,
    }

def run_tool(tool, args):
    try:
        return {"ok": True, "data": tool(**args)}
    except ValidationError as e:
        return {"ok": False, "error": to_payload(ToolError(
            code="validation_error",
            message=f"{e.field} is invalid. Expected {e.expected}, for example {e.example}.",
            retryable=False,
            next_action="ask_user",
            context={"field": e.field, "received": e.value},
        ))}
    except RateLimited as e:
        return {"ok": False, "error": to_payload(ToolError(
            code="rate_limited",
            message="The upstream service is rate limiting requests.",
            retryable=True,
            next_action="retry",
            context={"retry_after_ms": e.retry_after_ms},
        ))}
    except Exception:
        return {"ok": False, "error": to_payload(ToolError(
            code="internal_error",
            message="The tool failed unexpectedly and did not complete.",
            retryable=False,
            next_action="stop",
            context=None,
        ))}

Three details make this work in practice. First, the success and error payloads share a top level ok boolean, so the model does not have to guess whether a response is an error. Second, the catch-all keeps the shape stable even for bugs, which is far better than a stack trace leaking into the transcript. Third, the generic message never claims to know what went wrong when it does not.

Document the enum in the tool description the model reads. A short paragraph listing the codes, what each means, and the meaning of the three next_action values outperforms a longer prompt full of hints, because the model can match the error it receives to the vocabulary it was given.

For retries, include a numeric hint rather than prose. A context field of retry_after_ms set to 2000 is unambiguous; "please wait a moment" is not. If your tool surface spans many providers with different error conventions, a single gateway that normalizes them all into this shape removes a whole class of prompt-level work. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models through one OpenAI-compatible key, so the wrapper above sits in one place rather than one per provider.

Tests that keep the shape from drifting

The schema is only as good as the test that fails when someone edits a handler. Use three layers.

Schema validation. Define a JSON Schema for the error payload and validate every error the test suite can produce against it. Required keys, enum membership for code and next_action, boolean type for retryable, and a null-or-object type for context.

import json, jsonschema

ERROR_SCHEMA = {
    "type": "object",
    "required": ["code", "message", "retryable", "next_action", "context"],
    "properties": {
        "code": {"type": "string", "enum": sorted(ERROR_CODES)},
        "message": {"type": "string", "minLength": 1, "maxLength": 400},
        "retryable": {"type": "boolean"},
        "next_action": {"type": "string", "enum": ["retry", "ask_user", "stop"]},
        "context": {"type": ["object", "null"]},
    },
    "additionalProperties": False,
}

def test_every_error_path_matches_schema(tool_fixtures):
    for fx in tool_fixtures:
        payload = fx.trigger_error()
        jsonschema.validate(payload["error"], ERROR_SCHEMA)

Run that against a fixture list that triggers each path, not just the happy ones. The fixtures are the regression net: when a new handler ships without a code, the enum check catches it.

Snapshot tests. Serialize each error payload and compare it to a stored golden file. This catches quiet renames and reorderings that schema validation permits. Review snapshot diffs the way you review migrations, because a changed message string is a changed prompt.

Behavioral tests. These are the ones people skip and the ones that matter most. For each code, assert the mapping in the table above. Then run a recorded model transcript through a small evaluation: given the payload, does the model choose the expected next action? Ten examples per code is enough to catch a code that reads as ambiguous.

One more assertion is worth its weight: check that no error payload contains a raw exception string. A test that greps for "Traceback" and for file paths in the serialized payload prevents the most common leak.

Retry hints, cursors and the size of what you send back

The optional context object is a small serialized value, and it should stay small. Two numbers bound it. The format choice costs you about a third in size: Base64 increases the encoded size by 33 percent compared with the source bytes per the Wikipedia entry on Base64. And servers cap what they will accept in headers. Apache's default limit on a request field is 8 KB and IIS allows 16 KB according to this Stack Overflow answer on header size limits, which is why Node's move toward a 16 KB default was argued in this Node issue.

That matters because a natural place to put a continuation or retry hint is a header, and a natural encoding is an opaque token. Prefer the body. Error payloads belong in the response body where length is generous; reserve headers for transport-level facts.

If you do encode an opaque token, copy the cursor discipline that mature pagination uses. A cursor typically encodes the sort key and unique ID of the last item, is Base64 encoded, and must be replayed exactly as issued; GitHub's API documents this pattern in this pagination guide. Signing or at least validating the token prevents a model, or a confused retry, from fabricating one. The RFC that defines the Link header itself, RFC 8288, is explicit that providers define what each relation means, which is the same principle your error contract should follow: you define the vocabulary, the client follows it, and neither side invents values.

The same reasoning explains why a bare status code is not enough. The HTTP specifications recommend that a 431 response say which header was at fault, precisely because the bare code leaves the client guessing per the MDN reference. Widely deployed servers often ignore that advice, which is the origin of the confusing error pages users see as this guide to the 431 error notes. Your tool contract can do better than the servers it runs on.

Migrating an existing tool surface without breaking models

Most teams already ship errors in some ad hoc shape. A migration that keeps agents working has four steps.

  1. Inventory. Enumerate every distinct error response your tools can return. Search for raise statements, except blocks and early returns in the tool layer. Cluster them into the code enum; expect the list to shrink by half.
  2. Shadow. Emit both the old and new payloads behind a flag and log which the model actually consumed. This shows you the paths that matter before you change anything the model sees.
  3. Cut over with the prompt. Add the enum and the three next_action values to the tool description in the same change that flips the payload. Ship the test suite in the same pull request.
  4. Watch the loop rate. Count consecutive repeated calls with identical arguments after an error. A drop in that ratio after cutover is the signal that the new payload is doing its job.

A worked example makes the payoff concrete. Suppose your agent sends 40 tool calls per task and 6 of them hit errors. If a third of those errors are validation failures the model could have repaired immediately with a field name and an expected format, you save 2 round trips per task. At a few hundred milliseconds each plus token costs, that is not a rounding error, and it is the difference between an agent that recovers and one that escalates.

If the tools themselves are the part you would rather not build and maintain, Quanta Skills (https://quantaintelligence.ai/skills) is a library of installable skills you can drop into a coding agent, with the same discipline around typed inputs and outputs. Either way, the contract is yours to define.

FAQ

FAQ

What exactly does a tool error need to contain?

Five fields: a stable string code drawn from a documented enum, a plain-language message that states the problem and the fix, a boolean retryable flag, a next_action string that is one of retry, ask_user or stop, and an optional context object. The context carries details such as the offending field or a retry_after_ms number. Models use code and retryable to decide whether to try again and next_action to decide what to do next.

Why is a boolean retryable flag better than letting the model infer from the status code?

A status code tells the model that something failed, not whether the same request could succeed on a second attempt. Inferring that from a number requires the model to know your stack, which varies by provider and degrades when you swap models. An explicit boolean removes the inference, and a recent SDK case study shows agents looping until the error body itself reached the model per this write-up.

Should next_action have more than three values?

No. Retry, ask_user and stop cover every recoverable outcome: the call can succeed, a human holds the missing information, or the task is over. Adding values like "reformat" or "escalate" overlaps with retry and ask_user and makes the choice harder, not easier. If you need more nuance, put it in code and let next_action stay a three-way switch.

How do I keep the error shape from changing by accident?

Validate every error payload against a JSON Schema in tests, snapshot the serialized payload, and assert the code to retryable and next_action mapping for each code. Run those tests against fixtures that trigger each error path, not just the happy path. The enum check on code is what catches a new handler shipping without a documented code.

Do I return the error in the response body or in headers?

Body. Headers are capped (Apache defaults to 8 KB per field, IIS to 16 KB per this answer) and intermediaries may alter them. A response body has room for a message, a context object and a retry hint, and every model client already parses it. Use headers only for transport-level facts.

Sources

Sources

  1. Handling HTTP Errors in AI Agents: Lessons from the Field
  2. 431 Request Header Fields Too Large - HTTP - MDN Web Docs
  3. 414 Request-URI Too Long - HTTP Status Codes Guides
  4. Base64
  5. Maximum on HTTP header values?
  6. Increase HTTP_MAX_HEADER_SIZE to 16kb · Issue #27645
  7. How Link-Header Pagination Works in REST APIs - Vincent Schmalbach
  8. RFC 8288: Web Linking
  9. 431 Request Header Fields Too Large: Causes & Fixes
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