How-to › Make calls that survive failure

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.

Audience
API producer
Level
advanced
Topic
Handle and design API errors
Verified

An agent calls your API, gets a 500 with Something went wrong, please try again later, and does what the sentence says. It tries again, at once, forty times, until its call budget is gone. Nothing in the body told it whether to wait, how long, or whether the request was ever going to work.

What you get

You will end up with a four-field error contract that lets a caller with no human pick one of three actions, and a scripted loop that counts what each dialect costs. This is for you if agents call your API and retry what they should not.

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, 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Google ErrorInfoYou already return google.rpc.Status, and want a reason, a domain, and metadata on every errorA details array of typed payloads to walk, a canonical status name to map, and a delay only where you add RetryInfoA plain JSON API with no protobuf lineage and no generated clients
RFC 9457 with extensionsAny JSON API, and you want one media type a generic client recognizesThe extension names are yours to define, document, and keep, because the RFC names noneThe API already has a domain error format that clients parse
Stripe code and doc_urlA large catalog with a page per code and a person behind most callsA wait is a status plus advice to back off, so the delay is the caller’s guessCallers 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.

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 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, 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

    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 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.

node demo.mjs
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 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.

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.

node demo.mjs
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.

node --test contract.test.mjs
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 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 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 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.

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.

Footnotes

  1. 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 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. ↩︎ Back to text

  2. RFC 6585 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. ↩︎ Back to text

  3. AIP-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. ↩︎ Back to text

  4. Stripe’s rate limits page 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.