Skip to content

Errors

Match on the machine code, never on the status alone and never on a message string. Codes are stable; statuses are shared by unrelated conditions and messages are not part of the contract.

Almost every error is one field:

{ "error": "cloud_run_in_progress" }

Seven codes add a second field:

{ "error": "insufficient_scope", "requiredScope": "wamp.cloud.turns:submit" }
{ "error": "cloud_rate_limit_exceeded", "retryAfterSeconds": 3 }
{ "error": "invalid_request", "issues": [{ "path": ["model"], "message": "model must be omitted for a runtime-managed agent" }] }
{ "error": "invalid_path_parameter", "parameter": "sessionId" }
{ "error": "unsupported_media_type", "mediaType": "application/x-www-form-urlencoded" }
{ "error": "method_not_allowed", "allow": ["GET", "HEAD", "OPTIONS", "PUT"] }
{ "error": "cloud_interaction_conflict", "outcome": { "interactionId": "toolu_01K2mWpR7yLd4bQx9sTfNvHe", "choice": "deny", "status": "resolved", "resolution": "denied" } }

outcome is present only when another approval decision already won. A closed or expired approval with no answer returns the same conflict code without it.

Which field a 400 carries, and why there are three codes

Section titled “Which field a 400 carries, and why there are three codes”

The three ways a request can be wrong before it reaches any resource have three different fixes, so they have three codes. Branch on the code, not on which field happens to be present.

Code What is wrong Second field Where you fix it
malformed_request_body The bytes were not JSON — Your serializer
invalid_path_parameter A path segment is not a valid id parameter The URL you built
invalid_request A field of the body or the query was refused issues The payload

issues has one entry per refused field. path is the field path with the outermost segment first — ["initialTurn", "id"] for a nested field, and an integer segment is an array index. message is a short reason meant for a human to read; it is not part of the contract, so branch on the code and the path. Those two keys are the whole entry, and the WAMP Account API sends the same two under the same field name.

A path segment is refused on its own and first: if the id in the URL and the body are both wrong you get invalid_path_parameter, because a body cannot be judged against a resource that is not addressable. Fix the URL and send again to see what the body says.

Treat the body as extensible: new fields may appear, so read the ones you need rather than validating the object shape. Every /v1 failure puts a machine code in error — including the 413 the body parser answers, which is { "error": "request_entity_too_large" }. The only answers without a code are ones no WAMP server wrote: a 502, 503 or 504 from the proxy in front of the API, with an HTML body or none. Releases and answers with no code covers it.

Read the delay from the header, not the body

Section titled “Read the delay from the header, not the body”

Five domain codes — cloud_workspace_starting, cloud_session_finalizing, cloud_run_in_progress, cloud_agent_catalog_unavailable and cloud_extension_catalog_unavailable — carry a delay as a Retry-After header only. It is always 2. There is no retryAfterSeconds in their bodies.

503 service_unavailable, the answer while the API restarts or while its database cannot keep up, also carries Retry-After: 2 as a header only.

429 cloud_rate_limit_exceeded carries the delay both as a Retry-After header and as a retryAfterSeconds body field.

So read the header if you want one code path for all of them. A client that only looks at the body will silently fall back to its own default on every code where the server actually told it what to do.

Status Code Cause Retry?
400 invalid_request A body or query parameter failed validation — including an unknown property or an unknown query parameter. issues names each refused field No. Fix the request
400 invalid_path_parameter A path segment is not a valid id. parameter names it No. Fix the URL
400 malformed_request_body The request body was not parseable JSON, so no field was ever read No. Fix your serializer
400 invalid_input A malformed grantId path parameter, or a value that cleared a route’s character bound and then failed the byte bound behind it — a publication body or a merge commitMessage over 65 536 UTF-8 bytes No. Fix the request
400 environment_invalid An environment document, secret name or value, cache path, extension, archive or operation body failed validation; issues[].path names the field. An archive refusal says which ingest check failed; an extension names a catalog slug that is not listed or an archive the environment does not own No. Fix the request
400 audio_file_required A transcription request omitted its multipart file part No. Attach one audio file
400 invalid_audio_duration durationMs is missing, fractional, below 100 ms, or above 10 minutes No. Fix the request
400 invalid_audio_upload The multipart body is malformed or contains more than one file or field No. Fix the request
400 cloud_repository_branch_not_found source.baseBranch does not resolve in the granted repository. The caller’s own input, which is why it is a 400: no change of authority makes a branch that does not exist exist No. Send a branch that is there, or omit baseBranch for the default
400 invalid_organization A malformed or repeated X-Wamp-Organization header No
400 organization_header_not_allowed X-Wamp-Organization sent together with a bearer No. Remove the header
401 bearer_credential_required No Authorization: Bearer header on a /v1 path No
401 invalid_or_expired_credential Token expired, revoked, wrong audience, or introspection rejected it Mint a new token, retry once
403 cloud_access_denied The credential lacks wamp.cloud.access No. The installation is not consented for Cloud
403 environment_forbidden The actor cannot read or manage organization environments, or an installation cannot select one No. Obtain the required capability
403 insufficient_scope The credential lacks the operation’s capability, named in requiredScope No. Re-consent is required
403 installation_principal_required An installation-only operation was called with a human credential: repository grants, a GitHub-sourced Session create, runtime release probes, or GET /v1/sessions/stream No
403 cloud_repository_grant_required Nobody has granted this repository to this installation, the grant was revoked, or the grantId + repositoryId pair does not match No. An administrator configures access; GET /v1/repositories returns the exact usable pairs
403 cloud_github_connection_required A browser user chose the personal-repository path but their own GitHub connection is missing or expired No. That person reconnects GitHub; organization grants do not depend on personal OAuth
403 cloud_repository_unavailable The grant is intact and the repository is no longer reachable through the GitHub App installation — removed from it, deleted, or renamed No. Somebody restores it on the GitHub side
403 cloud_repository_permission_denied The App reaches the repository and GitHub refuses the operation: a missing App repository permission, or an organization policy such as unsatisfied SAML SSO No. One action on the GitHub side
403 cloud_compute_not_configured The organization has no compute it may run on, or withdrew the compute it owns No. Ask an administrator to add compute
409 cloud_compute_outdated The organization’s own machine is connected and refuses this release’s sandbox image — it is still holding an older one No. Ask an administrator to update that machine’s images
404 unknown_endpoint The path is not part of this API. Nothing was looked up, which is what separates it from the 404s below No. Fix the URL
405 method_not_allowed The path exists and does not answer this method. Allow and the allow field name the ones it does — Sessions are created with PUT, and there is no POST /v1/sessions No. Use a listed method
406 representation_not_acceptable Accept did not match on GET /v1/openapi.json or GET /v1/docs No. Fix Accept
415 unsupported_media_type The body carried a media type this API does not parse, or none at all. Bodies are read under application/json and RFC 6839 application/*+json only; mediaType echoes what you sent. Nothing of the body was read, so no field of it is named No. Fix your Content-Type
415 unsupported_audio_format The transcription file is not FLAC, MP3, MP4/M4A, Ogg, WAV, or WebM audio No. Encode to a supported format
413 request_entity_too_large JSON body over 1 MB, or an environment archive over 32 MiB No. Send less
413 audio_too_large A transcription file is over 20 MiB No. Record a shorter note
422 invalid_audio The declared audio format is supported but the provider cannot decode the bytes No. Re-encode or record again
422 empty_transcript The audio decoded successfully but contained no transcribable speech No. Record speech and retry
429 cloud_rate_limit_exceeded Your authority’s token bucket is empty Yes, after Retry-After
429 cloud_auth_rate_limit_exceeded The per-IP pre-authentication bucket is empty Yes, after Retry-After
500 internal_error Unexpected server fault Cautiously, with backoff
503 identity_service_unavailable Credential introspection is down. Retry-After: 2 Yes
503 event_stream_unavailable The Event wake listener has not proven delivery or the process has reached its 256-stream cap. Retry-After: 2 Yes, after Retry-After
503 cloud_rate_limit_unavailable The rate limiter faulted and fails closed. Retry-After: 1 Yes
503 cloud_extension_catalog_unavailable An environment write adds a catalog extension, and the extension catalog could not be reached to check it. Writes that add no catalog item are unaffected. Retry-After: 2 Yes, after Retry-After
503 cloud_agent_catalog_unavailable Agent/model capability discovery could not answer: it is down, or the bounded number of calls this server may have outstanding against it is spent — a wide concurrent fan-out reaches the second before it spends its own rate budget. Retry-After: 2 Yes, after Retry-After; alert the operator if persistent
503 transcription_unavailable The speech-to-text provider or Account relay is unavailable Yes, with backoff
504 transcription_timeout Transcription did not complete within the bounded request window Yes, once; record a shorter note if persistent

The five repository-authority codes above name five different parties’ next action, and they are newer than the collapsed code they replace. Deployments roll independently, so an older one answers 403 cloud_repository_grant_required for every case in the family — including a source.baseBranch that does not resolve. If you receive that code where the table promises a finer one, confirm the branch exists and that GET /v1/repositories still lists the exact grant/repository pair, which is the distinction the single code cannot draw. Which build is answering you names the deployment you are on.

401 versus 403 is the distinction to encode: 401 means this credential is no good, so re-mint and retry once. 403 means the installation is not permitted, and no amount of retrying will change it — surface it to whoever can grant the capability.

Status Code Cause
404 cloud_session_not_found No such session, or it belongs to another tenant
404 cloud_turn_not_found No such turn in this session
404 cloud_run_not_found No such run in this session
404 cloud_artifact_not_found No such artifact, or it is not publicly visible
404 cloud_publication_not_found No such publication
404 cloud_publication_merge_not_found No such merge under that publication
404 cloud_session_access_not_found GET …/execution on a session you cannot see, or that does not exist
404 environment_not_found No environment with this id or name belongs to this organization, or the requested revision does not exist

404 is also what you get for a resource that exists but belongs to a different organization — the API does not distinguish “absent” from “not yours”, because doing so would leak the existence of other tenants’ data. Two practical consequences: a 404 right after a successful PUT usually means an id case or typo mismatch, and a 404 on a session you are sure exists usually means the token belongs to a different installation.

409 covers two very different situations, and the code tells you which.

These mean the same caller-owned id was reused for different content. They are never transient and retrying is pointless.

Code Operation Trigger
cloud_session_conflict Session create or an environment write with If-Match Same Session id with a different normalized create command (including environment selection), or an environment revision mismatch
cloud_turn_conflict PUT …/turns/{turnId} Same id, different message or replyTo
cloud_publication_conflict PUT …/publications/{publicationId} Same id, a different stored request hash
cloud_interaction_conflict PUT …/turns/{turnId} with replyTo, or PUT …/interactions/{interactionId}/decision The interaction is already resolved or expired, or another approval answer won; a decision conflict includes the winning outcome when one exists

The rule that keeps you out of this: an id identifies one exact command. To send different content, mint a new id. Replaying an identical body is always safe and returns 200 (or 202 with created: false for turns).

An oversized publication body or merge commitMessage is not one of these. Either field is checked twice — 65 536 characters at the route, then 65 536 UTF-8 bytes behind it — and both refusals are a 400: invalid_request on the character bound, invalid_input on the byte bound. The request is malformed, not in conflict with a stored one, so there is no conflict to resolve and no new id to mint.

cloud_publication_merge_conflict is the exception and it is not always your bug, so it gets its own row:

Code Operation Triggers
cloud_publication_merge_conflict PUT …/merges/{mergeId} (a) the same merge id with a different stored request hash — your bug, permanent; (b) a different, brand-new merge id while another merge for the same publication is still admitted, attempted, waiting or succeeded — transient in case (b) except for succeeded; (c) the first-party /cloud request’s expectedHeadSha does not match the locked publication head — merge_head_mismatch, which admits no merge

You cannot tell these apart from the public code alone. GET …/publications/{publicationId}/merges pages every merge for that publication, so you can see whether one is already live and, if so, resume polling that one instead of minting another. For a first-party head mismatch, refresh and review the publication before retrying. A publication admits at most one live merge and at most one successful merge ever.

Code Meaning Retry-After
cloud_run_in_progress A non-terminal run blocks this action, or a fork source changed during capture 2
cloud_session_finalizing The previous run is being closed out and its transcript captured 2
cloud_publication_in_progress A publication for this session is not terminal yet —
cloud_agent_locked Session PATCH would change runtime or model after a Turn exists. Select the agent on a new Turn instead —
cloud_publication_merge_not_ready A when_ready merge is still waiting on checks or protection —

Only the first two carry a delay. For the rest, back off on your own schedule — a few seconds is right for cloud_publication_in_progress; a when_ready merge is a background wait measured in minutes, so poll it slowly.

Each command checks the Session’s durable state when it commits. A Turn submitted after Archive receives cloud_session_not_found; Archive following a Turn with an active Run receives cloud_run_in_progress. Publish after Archive also receives cloud_session_not_found, while Archive following publication admission receives cloud_publication_in_progress. A merge submitted before its Publication succeeds receives cloud_publication_merge_not_ready. Changing an agent after Turn admission receives cloud_agent_locked; submitting a Turn against an agent changed since preflight receives cloud_session_conflict. Re-read the Session or Publication before retrying a state conflict.

Code Meaning What to do
cloud_conversation_busy A plain turn was submitted while an interaction is open, or another command holds the session’s conversation Answer the open interaction with replyTo. Never retry the plain turn
cloud_turn_unresolved A turn id already exists but its run does not — an inconsistent replay, not an interaction problem Re-PUT the identical turn once; if it persists, mint a new turn id
cloud_agent_configuration_invalid (400) The agent selection cannot run: an unknown runtime, or a native Turn switched from a foreign runtime without a model Use an advertised runtime and supply a catalog model for a native Turn that cannot inherit one
cloud_agent_model_unavailable (400) The explicit model is not one this organization may spend on, or a new native Session omitted model and no agent slot is available Send a model from models.items in GET /v1/capabilities, or configure model access; exact Session replays keep their stored model
cloud_workspace_expired The sandbox lease has ended Read continuation on the session to see whether work can resume
cloud_workspace_expiring The lease is too close to its deadline to start this work Retry shortly; a new lease will be taken
cloud_resume_unavailable The session cannot be continued from its last checkpoint Start a new session
cloud_turn_execution_lost The execution backing this turn disappeared Re-submit as a new turn id
cloud_turn_limit_reached (409) The session reached its durable 1,000-turn boundary Start a new session; exact retries of already accepted turn ids still work
environment_archived (409) The selected environment is archived Unarchive it or choose a live environment
environment_name_conflict (409) Another live environment has this name, ignoring case Rename it or archive the other environment
quota_exceeded (409) The organization already has 200 live environments, or its environment archives would exceed 1 GiB Archive an environment before creating or unarchiving; remove archives from documents and let retention collect them

cloud_turn_limit_reached is a per-session state boundary, so archiving another session cannot change it.

Status Code Meaning Retry?
502 cloud_turn_start_failed The turn was admitted but starting execution failed Yes — re-PUT the same turn id
502 cloud_environment_setup_failed The pinned environment setup failed or timed out; the apply event has a redacted output tail No. Fix the environment, then submit a new Turn
502 cloud_run_control_failed A control operation on the run (such as cancel) failed downstream Yes, with backoff
503 cloud_workspace_starting The sandbox is being provisioned. Retry-After: 2 Yes
503 cloud_workspace_unavailable No sandbox could be provisioned Yes, with backoff
503 cloud_runtime_outdated The requested runtime is too old for this session No. Create a new session
503 cloud_runtime_account_unavailable No configured account can currently admit this runtime Yes if discovery advertises temporary unavailability; otherwise connect an account
503 service_unavailable The API is restarting for a WAMP release, no API server is up behind the edge yet, or the database could not serve the request in time (transient contention under load). Retry-After: 2 Yes, after Retry-After. See Releases

A durable Run that exhausts bounded dispatch attempts reports the terminal Event code runtime_account_unavailable (without the cloud_ HTTP prefix). It is not still queued: reconnect an account, then submit a new caller-owned Turn.

The 502 codes are the ambiguous ones: the command may or may not have taken effect. This is exactly what caller-owned ids are for — re-send the identical PUT and the response tells you which world you are in.

A WAMP release restarts the API. For a few seconds, requests are answered with 503 service_unavailable and Retry-After: 2 — by the stopping server, or by the edge while no server is up. Wait Retry-After and send the identical request again. A request that was already running when the restart began can be cut off, so treat a PUT like any ambiguous failure: re-send the same id, never a new one.

The same coded answer arrives outside a release when the database could not serve a request in time — a transaction that waited too long behind other work on the same Session, a write conflict, or no free connection. Handle it exactly like the restart: wait Retry-After and send the identical request.

More rarely the proxy in front of the API fails on its own and answers 502, 503 or 504 with no JSON error field — an HTML page or an empty body. The missing code is the signal: no WAMP server answered, and the request may or may not have reached one. Retry an idempotent request — a GET, or a PUT that carries your own id — with capped exponential backoff and jitter for up to about 30 seconds, then surface the failure. Do not resend a POST such as run cancellation on that answer; read the resource first.

GET /v1/sessions/{sessionId}/repository/review can fail for repository-level reasons. The codes below are the one family on this page with no cloud_ prefix, and they arrive in the same { "error": "<code>" } body as every other /v1 failure.

Status Code Meaning
409 no_changes The agent changed nothing. There is nothing to publish
409 not_repository The session has no repository checkout
409 repository_conflict The checkout is not in a state that can be reviewed
409 review_changed The tree moved while the review was being computed
409 branch_conflict The working branch conflicts with what is on the remote
413 review_too_large The diff exceeds the review budget
404 not_found The workspace or repository is gone
502 checkout_failed The base revision could not be checked out into the sandbox
502 review_failed The diff could not be computed
502 publish_failed The reviewed tree could not be committed or pushed
502 workspace_lost The sandbox backing the checkout disappeared mid-operation

no_changes is the common one and it is not really an error — it is the honest answer when an agent decided nothing needed changing. Handle it as a normal outcome in your product.

branch_conflict is the one row on this table you should not write an HTTP handler for. A push that conflicts is discovered after the publication is admitted, so the PUT answers 2xx and the conflict arrives as publication.lastError.code with the publication terminal in failed — exactly like the merge-phase codes below. A client that treats the 2xx as “pushed” reports a conflicting push as a success. Branch on the publication’s terminal status, never on the status of the request that started it. See Push directly to a branch.

Everything the provider answers is mapped to its own code rather than collapsed into “GitHub is down”, because the four cases need four different reactions.

Status Code Meaning Retry?
404 repository_not_found GitHub answered 404 or 410 — deleted, renamed, or never visible to this grant No
409 pull_request_changed The pull-request head moved after the publication recorded it No. Re-review, republish, then merge the new head
409 pull_request_closed Someone closed the pull request No. There is nothing to merge
409 merge_not_ready Checks pending, a review outstanding, or the pull request is still a draft Yes with mode: "when_ready"; for a draft, republish with draft: false
409 merge_not_allowed GitHub refused the merge for this actor or strategy — commonly branch protection, or a strategy the repository disallows No. Change strategy or ask an administrator
403 repository_read_only The GitHub App installation cannot write the repository — its contents permission is read-only, or the repository is not in the installation No. Ask an administrator
403 github_request_refused A permanent 4xx refusal: an organization policy, an unsatisfied SAML SSO authorization, a missing App permission, a rejected payload No. Retrying is pure waste
429 github_rate_limited GitHub is throttling. The response carries the wait GitHub itself asked for in Retry-After Yes, after Retry-After
401 not_connected No GitHub connection backs this grant No. An administrator must connect one
401 authorization_expired The connection’s authorization lapsed No. An administrator must re-authorize
503 not_configured GitHub integration is not configured on this deployment Yes, but alert the operator
502 github_unavailable GitHub was unreachable or returned 5xx Yes, with backoff
502 invalid_state / oauth_failed / account_already_connected / invalid_return_url Connection-flow faults. These belong to the browser authorization flow and should not reach a /v1 caller; if one does, it is a server-side fault No. Report it

github_rate_limited is the one worth special-casing. It is the only code in this table whose Retry-After comes from the provider rather than from WAMP, and hammering through it is what deepens a secondary rate limit. Honour the header.

The four merge-phase codes — pull_request_changed, pull_request_closed, merge_not_ready and merge_not_allowed — never reach you as an HTTP status. Publishing and merging are asynchronous, and the merge reconciler catches all four and records them, so they arrive only inside a 200 body: as merge.lastError.code, and as the errorCode of wamp.publication.merge_failed. Their 409 is what the code means — the observed pull request’s own state, not an upstream fault — and it is what a synchronous answer would carry if one ever existed. Branch on the code; do not write a 502 handler expecting these. See Failure codes inside successful responses.

Not every failure is an HTTP error. Long-running commands report their own outcome in a 200 body:

Field Where
run.lastError.code GET …/runs/{runId}
publication.lastError.code GET …/publications/{publicationId}
merge.lastError.code GET …/merges/{mergeId}
data.errorCode wamp.publication.failed, wamp.publication.merge_failed events

These are diagnostic strings drawn from several error families, and the set is open — treat an unrecognized value as a permanent failure of that command and report it verbatim rather than guessing.

A publication or merge can also surface any code from Repository and GitHub errors above, or any of the HTTP codes on this page, because the failure is recorded with whatever code raised it. Beyond those, each command has its own family.

publication.lastError.code — the publish state machine’s own codes:

Code Meaning
publication_failed The fallback when no more specific code applies
publication_not_found The publication vanished between admission and execution
publication_in_progress Another publication for this session is not terminal
publication_idempotency_conflict The stored request hash for this id does not match
publication_session_archived The session was archived before the publish ran
publication_workspace_changed The reviewed tree moved before the commit was made
publication_authority_lost The credential’s authority was revoked mid-command
publication_repository_grant_required The session’s repository grant is missing, revoked, or does not reach this repository
publication_claim_lost The worker’s claim on the command expired and another took over
publication_state_conflict The publication was not in a state this step could advance
publication_invariant_violation A durable invariant failed. Report it

merge.lastError.code — the merge state machine’s own codes:

Code Meaning
merge_failed The fallback when no more specific code applies
merge_not_found The merge vanished between admission and execution
merge_already_admitted Another merge for this publication is already live
merge_idempotency_conflict The stored request hash for this id does not match
merge_publication_not_ready The publication has not reached succeeded
merge_session_archived The session was archived, so commands are refused
merge_authority_lost The credential’s authority was revoked mid-command
merge_claim_lost The worker’s claim expired and another took over
merge_state_conflict The merge was not in a state this step could advance

Repository-grant failures — the grant service’s own codes. Both publications and merges spread this family in, because the grant behind the session is re-checked at every attempt:

Code Meaning
installation_not_found The GitHub App installation the grant belongs to is not connected to the organization
grant_not_found The grant backing the session is missing or revoked
branch_not_found The branch the session pinned does not resolve in the repository. Reaches a /v1 caller as 400 cloud_repository_branch_not_found
grant_idempotency_conflict The same grant id was requested with different content
active_grant_exists An active grant already exists for that GitHub App installation
github_connection_required No GitHub connection is available to back the grant
repository_permission_denied GitHub refused the write: the person creating the grant lacks push on the repository, the App installation can no longer write it, or an organization policy such as unsatisfied SAML SSO blocks it

run.lastError.code values are catalogued with the events that carry them, in Events.

Note that lastError can be present on a non-failed resource: a waiting merge carries lastError.code: "merge_not_ready" while it is still retrying. Read status first, lastError second.

const RETRY_AFTER_HEADER = new Set([
'cloud_workspace_starting', // 503
'cloud_session_finalizing', // 409
'cloud_run_in_progress', // 409
'cloud_rate_limit_exceeded', // 429
'cloud_auth_rate_limit_exceeded',
'identity_service_unavailable',
'cloud_rate_limit_unavailable',
'service_unavailable', // 503, a release restart or database contention
// Not a rate limit and not an outage: the bounded set of calls this server may
// have in flight against its own identity and catalog dependency is spent.
'cloud_agent_catalog_unavailable', // 503
// An environment write that adds a catalog extension could not reach the catalog.
'cloud_extension_catalog_unavailable', // 503
// The wait here is GitHub's own, not ours. Ignoring it deepens the limit.
'github_rate_limited', // 429, repository/review
]);
const BACKOFF = new Set([
'cloud_workspace_unavailable',
'cloud_runtime_account_unavailable',
'cloud_publication_in_progress',
'cloud_run_control_failed',
'github_unavailable',
'internal_error',
]);
// Not retryable at all: the agent is waiting on you, and nothing times it out.
const ANSWER_THE_INTERACTION = new Set(['cloud_conversation_busy']);
// The proxy in front of the API answered: no WAMP server wrote this response.
const EDGE_FAILURE = new Set([502, 503, 504]);
/**
* @param {number} status
* @param {string | undefined} code the body's `error`; undefined when the body is not JSON or has none
* @param {string | null} retryAfterHeader
* @param {boolean} idempotent a GET, or a PUT that carries your own id
*/
function plan(status, code, retryAfterHeader, idempotent) {
if (status === 401) return { action: 'remint-once' };
if (code === undefined) {
// Back off for up to about 30 seconds, then surface it.
return EDGE_FAILURE.has(status) && idempotent
? { action: 'retry-backoff' }
: { action: 'fail' };
}
if (ANSWER_THE_INTERACTION.has(code)) return { action: 'answer-interaction' };
if (RETRY_AFTER_HEADER.has(code)) {
return { action: 'retry', delayMs: (Number(retryAfterHeader) || 2) * 1000 };
}
if (BACKOFF.has(code)) return { action: 'retry-backoff' };
// 502 on an idempotent PUT: re-send the SAME id.
if (status === 502) return { action: 'resend-same-id' };
return { action: 'fail' };
}

Everything not in those sets is terminal. In particular: never retry a 400, a 403, a 404, or a 410 — each of them means the request itself was wrong, and a retry loop on them is how an integration turns a bug into a rate limit.

Give the whole loop a budget measured in time, not attempts: three attempts two seconds apart can end inside one restart. The source App SDK keeps retrying a read, or a request that carries your own id, until it has failed for 20 seconds and at least three times.

Two conflicts are the exceptions to “never retry a …_conflict”: cloud_publication_merge_conflict clears once the merge holding that publication ends failed — but never after one has succeeded, because a publication may land at most once, ever — and cloud_conversation_busy clears when you answer the open interaction, never on its own.