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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Google ErrorInfo | 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 | 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 | 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.
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.
Related how-tos
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
-
Section 3.1.4 of RFC 9457 says consumers should not parse the
detailmember 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 ofRetry-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 -
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 isTry again soon.word for word. One hour in the header andsoonin the body, on the same response, in the document that defined the status code. ↩︎ Back to text -
AIP-193 requires an
ErrorInfo.reasonto match[A-Z][A-Z0-9_]+[A-Z0-9]and to run to at most 63 characters. It offersCPU_AVAILABILITYandNO_STOCKas good examples, and as bad onesTHE_BOOK_YOU_WANT_IS_NOT_AVAILABLE, marked overly verbose, andERROR, 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 -
Stripe’s rate limits page says a rate-limited response carries a
Stripe-Rate-Limited-Reasonheader with one of five values, fromglobal-ratetoresource-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