# How to design error responses an autonomous agent can act on

> Give an LLM caller a stable code, the parameter at fault, a next step, and a structured delay, so it fixes, waits, or stops instead of retrying a 500 forever.

Source: https://voxgig.com/howto/error-responses-an-agent-can-act-on

- Audience: api-producer
- Level: advanced
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Make every error body carry a stable code, the parameter at fault, and a `next_step` that is one of `fix_request`, `wait`, or `give_up`. Give every wait a `retry_after_seconds` value and send the same number as `Retry-After`. RFC 9457 problem details carry all of that as extension members. Then feed each error to a scripted loop and count the calls it makes.

---
## You will need

An error catalog with stable codes, and Node 22 or later for the loop. Verified 2026-09-25
against Node 22.22.2. The loop is scripted, not a model, so it runs offline and gives the same
answer every time. The body format is
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), whose section 3.2 lets a problem type
define extension members and requires consumers to ignore the ones they do not know.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Google ErrorInfo](https://google.aip.dev/193) | You already return `google.rpc.Status`, and want a reason, a domain, and metadata on every error | A `details` array of typed payloads to walk, a canonical status name to map, and a delay only where you add `RetryInfo` | A plain JSON API with no protobuf lineage and no generated clients |
| [RFC 9457 with extensions](https://www.rfc-editor.org/rfc/rfc9457.html#section-3.2) | Any JSON API, and you want one media type a generic client recognizes | The extension names are yours to define, document, and keep, because the RFC names none | The API already has a domain error format that clients parse |
| [Stripe `code` and `doc_url`](https://docs.stripe.com/api/errors) | A large catalog with a page per code and a person behind most calls | A wait is a status plus advice to back off, so the delay is the caller's guess | Callers with no human to read the page the URL points at |

The three shapes carry the same first two fields under different names: a stable code and the
parameter at fault. They part on the wait. Google's `RetryInfo` and this page's
`retry_after_seconds` say how long; Stripe's `doc_url` says where a person can read about it,
which costs an agent a guess and a backoff schedule. The RFC costs you the naming, since it
defines the envelope and not one extension.

## Give every error four fields

The contract is small enough to enforce in a builder. A wait without a delay, a delay that is not
a whole number of seconds, and a fix without a parameter are refused before they reach a client.

```ts title="contract.mjs"
export function problem({ status, code, title, detail, next_step, param, retry_after_seconds, instance }) {
  if (!ACTIONS.includes(next_step)) throw new Error(`next_step must be one of ${ACTIONS.join(', ')}`)
  if (retry_after_seconds != null && !seconds(retry_after_seconds)) {
    throw new Error('retry_after_seconds must be a whole number of seconds, as Retry-After is')
  }
  if (next_step === 'wait' && retry_after_seconds == null) throw new Error('a wait must carry retry_after_seconds')
  if (next_step === 'fix_request' && !param) throw new Error('a fix_request must name the parameter')
  const body = { type: `${BASE}/problems/${code}`, title, status, detail, code, next_step }
  if (instance) body.instance = instance
  if (param) body.param = param
  if (retry_after_seconds) body.retry_after_seconds = retry_after_seconds
  const headers = { 'content-type': 'application/problem+json' }
  if (retry_after_seconds) headers['retry-after'] = String(retry_after_seconds)
  return { status, headers, body }
}
```

`code` is the stable identifier a client switches on, and `type` is the same identifier as a
URI, which is what the RFC asks consumers to compare. `next_step` is the field the whole page
is about: three values, and a caller picks one branch per value. `param` names what to fix.
`retry_after_seconds` names how long to wait, and the same number goes out as `Retry-After`, for
the client that reads headers and never bodies. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3)
allows the header to be a date instead of a number, so the reader in `agent.mjs` accepts
both.[^2]

`detail` stays prose. Section 3.1.4 of the RFC says consumers should not parse it, and this
contract gives them no reason to. Everything a machine needs is in a field of its own.

## Render the same failure in each dialect

Four failures, each rendered four ways: the problem body from `contract.mjs`, a `google.rpc.Status` with
`ErrorInfo` and `RetryInfo`, the Stripe shape, and the sentence most APIs send. The Google
rendering follows [AIP-193](https://google.aip.dev/193), which requires an `ErrorInfo` in every
error and puts anything dynamic in its `metadata` map so that machine actors do not need to parse
error messages.[^3]

```ts title="dialects.mjs"
    rate_limited: () =>
      status(429, 'RESOURCE_EXHAUSTED', 'Quota exceeded for requests per minute', [
        info('RATE_LIMIT_EXCEEDED', { quotaLimit: 'RequestsPerMinute', quotaValue: '60' }),
        { '@type': 'type.googleapis.com/google.rpc.RetryInfo', retryDelay: '30s' },
      ]),
```

`RetryInfo` is defined in
[error_details.proto](https://raw.githubusercontent.com/googleapis/googleapis/master/google/rpc/error_details.proto)
as one field, `retry_delay`, with the comment that clients should wait at least this long between
retrying the same request. It is optional, which is the point of comparison: an `ErrorInfo` on
its own says why and not when.

```bash
node demo.mjs
```

```text output
the same failure in four dialects, rate_limited
  problem  429  retry-after: 30
           {"type":"https://api.example.com/problems/rate_limited","title":"Too many requests","status":429,"detail":"60 requests per minute per key","code":"rate_limited","next_step":"wait","retry_after_seconds":30}
  google   429
           {"error":{"code":429,"message":"Quota exceeded for requests per minute","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"RATE_LIMIT_EXCEEDED","domain":"api.example.com","metadata":{"quotaLimit":"RequestsPerMinute","quotaValue":"60"}},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"30s"}]}}
  stripe   429
           {"error":{"type":"rate_limit_error","code":"rate_limit","message":"Too many requests. Use an exponential backoff.","doc_url":"https://docs.example.com/errors/rate_limit"}}
  vague    429
           {"error":"Too many requests, try again later"}
```

The problem body is the longest of the four, which is the trade-off the brief for this page
names: a longer body against fewer wasted calls. The Stripe shape names the code and links a
page, and its own
[rate limits documentation](https://docs.stripe.com/rate-limits) recommends an exponential
backoff for a 429 without naming a delay.[^4] The last body is a status code and a sentence, and
the sentence is addressed to someone who is not there.

## Feed each error to a loop and count

The loop decodes the response, asks a policy what to do, and runs on a virtual clock, so a
thirty second wait costs nothing and the counts are exact. Three policies get the same response.
One reads the body, one reads only the status and the `Retry-After` header, and one retries at
once.

```ts title="agent.mjs"
export const POLICIES = {
  // Retries anything that looks temporary, at once, until the budget is gone.
  hammer: (decoded) => (decoded.action === 'wait' ? { retry: true, sleep: 0 } : { retry: false }),
  // What most HTTP retry middleware does: retries a 429 or a 5xx, honors a
  // Retry-After header, and reads no body.
  header: (decoded, attempt, res, now) =>
    res.status === 429 || res.status >= 500 ? { retry: true, sleep: retryAfterSeconds(res.headers?.['retry-after'], now) ?? 0 } : { retry: false },
  // Waits exactly as long as it was told, or backs off when it was told nothing.
  contract: (decoded, attempt) =>
    decoded.action === 'wait' ? { retry: true, sleep: decoded.delay ?? DEFAULT_BACKOFF[Math.min(attempt, DEFAULT_BACKOFF.length - 1)] } : { retry: false },
}
```

The scripted service clears its two temporary failures at thirty seconds on that clock, which is
what the structured delays say. Eight calls is the budget.

```bash
node demo.mjs
```

```text output
a loop that reads the body, calls made and seconds slept
  failure           problem               google                stripe                vague
  missing_parameter fix currency 1c 0s    fix currency 1c 0s    fix currency 1c 0s    fix (no param) 1c 0s
  rate_limited      ok 2c 30s             ok 2c 30s             ok 6c 31s             ok 6c 31s
  maintenance       ok 2c 30s             ok 2c 30s             ok 6c 31s             ok 6c 31s
  account_suspended give_up 1c 0s         give_up 1c 0s         give_up 1c 0s         give_up 1c 0s

a loop that reads only the Retry-After header
  failure           problem               google                stripe                vague
  rate_limited      ok 2c 30s             budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s
  maintenance       ok 2c 30s             budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s

a loop that retries at once
  failure           problem               google                stripe                vague
  rate_limited      budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s
  maintenance       budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s    budget spent 8c 0s
```

Read the first table by column. The two dialects that name a delay cost two calls and thirty
seconds. The two that do not cost six calls and thirty-one seconds, because the loop had to
guess with a doubling backoff and guessed its way past the reset. The `missing_parameter` row
shows the other field: three dialects name `currency`, and the vague one leaves the agent to
work out which of its arguments was wrong.

The second table is the case for sending the delay twice. A client that honors `Retry-After`
and ignores bodies succeeds against the one dialect that sends the header, and burns its budget
against the three that put the delay in the body or nowhere. The third table is the agent from
the problem statement. It spends eight calls in zero seconds on every dialect, because a policy
that never sleeps cannot be helped by any body.

## Check it worked

Eight tests pin the contract and the counts. One of them is the review a catalog should get: a
body whose `detail` says `try again later` and whose fields never say when.

```bash
node --test contract.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 105.753013
```

The test that matters is the count: a structured delay costs two calls, prose costs six, and
ignoring both costs the whole budget. If the first two ever tie, the loop has stopped reading the
delay it was sent.

## When it goes wrong

An agent hammers an endpoint that told it to wait. The wait was in `detail`, in words, and the
fields said nothing. Run `violations` from `contract.mjs` over every body in your catalog: it
reports a `wait` with no delay and a detail that promises a retry without a time.

A client obeys `Retry-After` on your 429 and hammers your 503. The maintenance response carries
the delay in the body only. Send it in both places, from one number, as the builder does.

A client parses `Retry-After` as an integer and gets `NaN`. The header was an HTTP-date, which
RFC 9110 allows. Accept both forms, as `retryAfterSeconds` does, and prefer the integer when
you send it, because a date depends on two clocks agreeing.

A code changes meaning and clients keep switching on it. That is a contract change, and the
[evolving an error contract](/howto/evolve-an-error-contract-without-breaking-clients) page
covers the diff that catches it.

## When not to do this

Do not put the next step only in prose. A sentence such as `try again later` is read by an agent
as permission, and the loop in `agent.mjs` shows the price: six calls where two would do, or
eight where none would.

Do not add `next_step` to a success. The field means the request failed and here is what to do
about it, and a 200 that carries advice trains a client to look for it where it does not belong.

Do not invent a format when your API already has one that clients parse. Section 4 of
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html#section-4) says problem details exist to
avoid new fault formats, not to replace a domain-specific one. Add the four fields to the format
you have.

Do not send a `wait` with a delay you cannot honor. An agent that sleeps thirty seconds and
gets the same answer learns that your delays are decoration, and goes back to guessing.

Do not classify every 5xx as `wait`. [AIP-194](https://google.aip.dev/194) marks `INTERNAL` and
`UNKNOWN` as not to be retried, on the grounds that a bug does not clear with time. A failure you
have not classified deserves `give_up` and a code, not a retry loop.

## Related how-tos

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Translate upstream API errors behind your own API](/howto/translate-upstream-errors-behind-your-api)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-09-25 against Node 22.22.2. Every output block is what the command preceding it
printed. The loop is a scripted policy on a virtual clock, not a language model, and every request
stayed inside the process.

[^1]: [Section 3.1.4](https://www.rfc-editor.org/rfc/rfc9457.html#section-3.1.4) of RFC 9457
says consumers should not parse the `detail` member for information, and that extensions are
the more suitable and less error-prone way to obtain it. [Section 4](https://www.rfc-editor.org/rfc/rfc9457.html#section-4)
adds that a problem type definition may specify the use of `Retry-After`. The RFC anticipated
this page in 2023 and stopped at the permission, which is where a specification stops and a
catalog begins.

[^2]: [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html#section-4) introduced the 429 in
2012 with one example response. Its header reads `Retry-After: 3600`, and its body, an HTML
page, explains that the site allows fifty requests per hour per logged-in user. Its last sentence
is `Try again soon.` word for word. One hour in the header and `soon` in the body, on the same
response, in the document that defined the status code.

[^3]: [AIP-193](https://google.aip.dev/193) requires an `ErrorInfo.reason` to match
`[A-Z][A-Z0-9_]+[A-Z0-9]` and to run to at most 63 characters. It offers `CPU_AVAILABILITY` and
`NO_STOCK` as good examples, and as bad ones `THE_BOOK_YOU_WANT_IS_NOT_AVAILABLE`, marked overly
verbose, and `ERROR`, marked too general. The document supplies a regular expression and two
kinds of failure the expression permits, which is a fair summary of naming.

[^4]: Stripe's [rate limits page](https://docs.stripe.com/rate-limits) says a rate-limited
response carries a `Stripe-Rate-Limited-Reason` header with one of five values, from
`global-rate` to `resource-specific`, each explaining why the request was refused. The page
recommends an exponential backoff with randomness added. Five reasons and no number: the caller
learns exactly what it did and is left to decide how long to feel bad about it.