Errors and retries
The SDK never calls process.exit. Errors come out of exactly three openings: at assembly / option time they are thrown as TansrSdkError; runtime model and tool errors are always expressed through the event stream (turn.error / tool.failed / turn.aborted); platform gateway error codes hide inside the payloads of those two. Decide which kind it is first, then decide whether to retry.
Kind one: TansrSdkError (assembly / option time)
Section titled “Kind one: TansrSdkError (assembly / option time)”These are programming or environment errors thrown synchronously or on the first next() / await createSession(); retrying does not help — read the message and fix the code or the environment:
| code | When | Meaning and handling |
|---|---|---|
invalid_options |
Synchronous | An illegal option combination (fields of the three tiers mixed / a tool name outside the vocabulary / local configuration on the token tier / explicit adjudication together with permission.mode). Read the message and fix the code |
assembly_failed |
Assembly | Managed / token assembly failed (configuration unavailable / alias resolution failed / bundle fetch failed); cause preserves the underlying error |
capability_disabled |
Assembly | A capability flag is off but was selected explicitly; the message includes console repair guidance |
session_closed |
Runtime | A state-changing method was called after close() |
session_not_found |
Assembly | The resume target is not in the store — check with list() or create anew |
session_store_corrupted |
Assembly / persisting | The session storage is corrupted — never silent; delete the session directory and start over |
checkpoints_not_wired |
Runtime | checkpoint() / listCheckpoints() / deleteCheckpoint() or an explicit compact({ checkpoint: true }) was called without checkpoint storage wired |
checkpoint_store_corrupted |
Runtime | A snapshot file is corrupted; deleteCheckpoint() that id and continue |
turn_running |
Runtime | An idle-only method was called while a turn was running; retry after await session.idle() — the only SDK error that means “come back in a moment” |
checkpoint_not_found |
Runtime | The snapshot named by fork() / exportCheckpoint() does not exist |
checkpoint_import_invalid |
Runtime | The imported bytes are not a valid export; tampering is rejected with nothing written to disk |
session_store_not_wired |
Runtime | fork() needs the session store and none was given |
platform_request_failed |
Runtime | The token tier’s direct session.platform.transcribe() / speak() failed (PlatformRequestError); status / platformCode / platformMessage carry the gateway facts, and status 0 = the platform was never reached. Since 0.19.0 failures of non-chat operations through createPlatformNonchatClient use the same code but throw PlatformNonchatError (status / platformCode / outcomeUnknown / taskId?): outcomeUnknown: true means the outcome of the submission is unknown — keep the original idempotency key and taskId, query the original task with getTask(), and never resubmit under a new key |
lifecycle_failed |
Cleanup | SdkLifecycleError: the cleanup settlement of query() failed (the session-level drain() / closeAsync() do not throw; they express it as a result with status: 'failed'); failures lists each stage and code, cleanup can be called again to continue reclaiming when present, and result is the completed QueryResult when present (see Lifecycle and cleanup) |
import { TansrSdkError, createSession } from '@tansr/sdk';
try { session = await createSession({ token, baseUrl, store, resume: { sessionId, store } });} catch (e) { if (e instanceof TansrSdkError && e.code === 'session_not_found') { session = await createSession({ token, baseUrl, store }); // 确实不在 store 才新建 } else { throw e; // session_store_corrupted 等恒上抛,不静默开空会话 }}Kind two: errors in the event stream
Section titled “Kind two: errors in the event stream”| Event | Meaning | What to do |
|---|---|---|
turn.error { scope, message, recoverable, errorKind?, detail? } |
An error within a turn | Look at recoverable: true is advisory (persistence failure, compaction failure, platform notice), goes into SessionView.notices, and the session continues; false means this turn failed and goes into lastError |
turn.aborted { reason } |
A turn was aborted | reason is a terminal word: a user interrupt() gives aborted_*; max_turns / budget_exceeded are caps; client_gone only appears in the session service form |
tool.failed { errorType } |
Tool execution failed | timeout / unknown_tool and so on; the model sees a structured failure result and reroutes on its own, so you normally do not need to step in |
tool.completed { isError: true } |
The tool ran but the result is an error | Provider failures and timeouts of the four media tools take this shape; data.errorCode carries the platform code |
tool.permission.decided { allow: false, decisionSource } |
Permission denied | policy = the fail-closed denial of a missing askUser; wire up permission.askUser or adjust the rules |
turn.error.detail is an optional structured field. Available since SDK 0.13.0: the four platform plan codes carry detail: { scope: 'plan' }, the same key and value as error.detail.scope on the session service HTTP surface, so the client can tell “local rate limiting” from “plan quota” apart.
Kind three: platform gateway error codes
Section titled “Kind three: platform gateway error codes”On the token tier, platform error codes appear in the first segment of the turn.error message, in tool.completed.data.errorCode, in PlatformRequestError.platformCode / PlatformNonchatError.platformCode, and in the response of your server’s token minting call. The authoritative table of all codes is the error code reference; this page only covers the dozen or so you hit most often and how to handle them:
Retry, waiting according to Retry-After
Section titled “Retry, waiting according to Retry-After”| Code | HTTP | Handling |
|---|---|---|
rate_limited |
429 | Platform rate limiting, always with Retry-After (whole seconds). Wait for that value and retry; do not resend more densely |
upstream_error / upstream_disconnected |
502 | A failure on the upstream model side; retry after exponential backoff with jitter |
upstream_timeout and upstream_connect_timeout / upstream_ttfb_timeout / upstream_idle_timeout / upstream_total_timeout |
504 | Upstream timeout; same backoff as above |
plan_gate_unavailable |
503 | A transient outage of a platform gatekeeping dependency, Retry-After: 5; existing sessions are unaffected |
Plan usage 429s: zero automatic retries
Section titled “Plan usage 429s: zero automatic retries”| Code | HTTP | Handling |
|---|---|---|
plan_concurrency_exceeded |
429 | The plan’s concurrency hard cap, Retry-After: 30. Only new sessions are rejected; running sessions are never cut. The SDK does zero retries — resending the same request inside the window is bound to be rejected again, and automatic retries from a multi-session host only cause a thundering herd. Let the user resend manually after 30 seconds; surface detail.upgradeUrl in your monitoring, it is the developer’s signal to upgrade the tier |
plan_end_users_exceeded |
429 | The monthly end-user count of the test period is exhausted; token minting path only |
quota_exceeded and the per-surface *_quota_exceeded |
429 | The daily quota is exhausted; wait for the quota window to refresh, do not retry |
Do not retry; fix the configuration or the code
Section titled “Do not retry; fix the configuration or the code”| Code | HTTP | Handling |
|---|---|---|
unauthorized / token_expired |
401 | The session token expired or was revoked. This is the only code that invalidates the token — go back to your server and mint a new one. The client-side SDK re-fetches the token through the login state at most 2 times; if it is still 401 it enters the terminal state and the host reconnects explicitly |
insufficient_balance |
402 | The application wallet balance is insufficient; it heals after a top up, retrying does not help |
model_not_authorized |
403 | The model reference is not in the application’s authorization set; grant it in the console |
forbidden |
403 | A ring-3 capability flag is off (the gateway enforcement surface); detail carries guidance on enabling it |
plan_required / plan_tier_insufficient |
403 | The account that owns the application has no valid plan, or its tier does not include the capability — a developer assembly error that should never reach end users; detail.upgradeUrl points to the console plan page |
payload_too_large |
413 | Over the body cap (for example raw audio for speech-to-text > 24 MiB, detail.maxRawBytes); split or compress, not retryable |
validation_failed / bad_request |
400 | The payload shape is illegal; read the message and fix the request |
session_archived |
410 | The platform session has been archived (after the 30-day inactivity lease expired); this session can no longer be renewed, resume will fail, create a new session |
context_window_exceeded |
400 | The request exceeds the model’s context window; compact() first or switch to a model with a larger window |
Retries the kernel already does for you
Section titled “Retries the kernel already does for you”Transient failures at the model request layer need no retry from you: every entry on the fallback chain has its own retries (backoff 500 ms → 30 s, 3 attempts in total, honouring Retry-After first); once exhausted it switches to the next entry and emits cost.provider.switched (from / to / reason / attempts / failures). On a genuine context overflow (upstream 413) the kernel forces one compaction and retries. Your retry logic only needs to cover the session-level failures in the two “retry” tables above.
How it looks in the session service form
Section titled “How it looks in the session service form”With @tansr/serve, the same platform codes reach mobile clients after server-side mapping: the plan concurrency cap maps to 429 rate_limited + Retry-After + detail: { scope: 'plan' }; plan_required / plan_tier_insufficient pass through as 403 with the original code + detail.scope: 'plan'; upstream rate limiting / 5xx / network errors map to 503 upstream_unavailable. The client-side backoff discipline is unchanged: when a 429 / 503 carries Retry-After, wait for the whole-second value; on a 5xx without Retry-After, use exponential backoff with jitter. Details in v2 protocol and webhooks.
The above describes the error body { error: { code, message, detail? } } of the /v2 direct surface, whose bytes are unchanged from @tansr/serve 0.13.0 through 0.15.1; since 0.15.0 legacy-prefix responses only gain the two response headers Deprecation / Link (ignored by @tansr/api-client 0.3.x / 0.4.0), with body and status code unchanged.
The unified error envelope on /api
Section titled “The unified error envelope on /api”Since @tansr/serve 0.13.0 (released 2026-10-02) new integrations use the unified call surface /api, where every JSON error ≥ 400 no longer takes a per-family shape but one unified envelope:
{ "contract": "unified-v1", "traceId": "<x-request-id>", "requestId": "<客户端幂等键或 null>", "code": "capacity_exceeded", "status": 429, "retryAction": "same-request", "message": "...", "detail": { "domainCode": "rate_limited", "domainStatus": 429 } }codeis one of 19 unified codes (invalid_request/protocol_mismatch/unauthorized/forbidden/not_found/method_not_allowed/gone/conflict/stale_generation/gap/capability_unavailable/capacity_exceeded/payload_too_large/upstream_unavailable/result_unknown/rejected/internal_error, plus the facade’s ownprecondition_failedandnot_canonical); the original family code (such asrate_limited,session_ended,session_not_foundabove) is kept only indetail.domainCode, and the original wire status indetail.domainStatus. Oneswitch (code)covers every domain; readdomainCodeonly when you need family-level distinctions.retryActionis one of 6 actions and decides your next step:nonenot retryable;same-requestreplay the same request under the same key (wait first whenRetry-After/detail.retryAfterMsis present);query-statuscheck the receipt / status first;rebindrebuild the binding;refreshre-read the revision and retry;rediscoverrerun capability discovery and retry.result_unknown/commit_unknownonly allowquery-statusorrebind; resending under a different key is forbidden — it amounts to accepting a duplicated side effect.- Since 0.14.0 rejections of the three request headers
Idempotency-Key/If-Match+ETag/deadlineuse this same envelope without new error codes; the detailed reason is indetail.reason(such asidempotency_key_reused,if_match_stale,deadline_exceeded);412 precondition_failedcan since then mean either a stale closure (rediscover) or a stale resource revision (refresh) — distinguish bydetail.domainCode. - A response without the
tansr-contractheader, or/apireturning non-JSON / a proxy page, is always reported as “contract unavailable”; you must not guess that it is an older server and switch back to/v2.
The ./api sub-entry of @tansr/api-client 0.3.0 and later (GA 2026-10-04; currently 0.4.0, 2026-10-06, with the ./api sub-entry and root entry unchanged) packages this decoding as ApiError (readers for code / retryAction / domainCode / domainStatus / closureId) and the retryAdvice / retrySameRequest helpers, always downgrading result_unknown to query-status. The full table of unified codes, the retryAction source mapping and the per-family domainCode cross-reference are in Unified API headers and error envelope.
Was this page helpful?
Thanks for your feedback.