Reference
Errors
Error contracts and recovery across the SDKs, Connect, and CLI.
The Python and TypeScript server SDK error hierarchy is organized by developer action — the action to take is the first thing to know when an exception is caught. Both SDKs expose the same exception class names; the snippets below are Python but the TypeScript hierarchy is identical (camelCase fields). The Python export index and TypeScript export index cover language-specific imports and field spelling.
AlterSDKError├── AlterValueError — SDK rejected caller input; fix the code├── BackendError — Alter backend returned an error│ ├── ReAuthRequiredError — user must re-authorize via Connect│ │ ├── GrantExpiredError│ │ ├── GrantRevokedError│ │ ├── CredentialRevokedError│ │ └── GrantDeletedError│ ├── GrantNotFoundError — wrong grant_id; fix the code│ │ └── AgentDelegationMissingError — agent path: grant not delegated to this agent│ ├── AmbiguousGrantError — multiple grants matched; pick one│ ├── PolicyViolationError — policy denied; may resolve later│ │ ├── StepUpRequiredError — user session is too old; re-authenticate│ │ └── RedactDischargeFailedError — required redaction failed; fix body/rule│ ├── InsufficientScopeError — key scopes don't cover the route│ ├── NoDelegatedGrantError — agent has no access path to the provider│ ├── RestrictedGrantRequiresProxyError — restricted grant; use the proxy call path│ ├── GrantNotDelegableError — onward delegation is not permitted│ ├── SiblingLabelConflictError — label already in use on this connection│ ├── TokenRefreshInProgressError — transient 409; retry│ ├── QuotaExceededError — request-quota window exhausted; wait retry_after│ ├── RateLimitError — Alter's own rate limit; transient, retry after retry_after│ └── AgentError — managed-agent management│ ├── InvalidKeyError│ ├── AgentNotFoundError│ ├── AgentNameExistsError│ ├── AgentConcurrentUpdateError│ ├── MeRequiresAgentKeyError│ ├── KeyRevokedError│ ├── AgentInactiveError│ ├── AgentRevokedError│ ├── KeyNotFoundError│ ├── KeyAlreadyRevokedError│ ├── LastActiveKeyError│ ├── AgentKeyLimitError│ ├── AgentCannotMintSubagentsError│ ├── IdempotencyKeyBodyMismatchError│ ├── IdempotencyKeyAgentRevokedError│ └── IdempotencyKeyAgentInactiveError├── ConnectFlowError — Connect flow failed│ ├── ConnectDeniedError — user clicked Deny│ ├── ConnectConfigError — OAuth app misconfigured│ └── ConnectTimeoutError — no callback before deadline/expiry├── ProviderAPIError — provider returned 4xx/5xx│ ├── ScopeReauthRequiredError — 403 + scope mismatch; re-authorize│ └── ProviderUnauthorizedError — 401; token rejected provider-side; re-authorize├── ApprovalError — HITL approval branch│ ├── ApprovalDeniedError│ ├── ApprovalExpiredError│ ├── ApprovalTimeoutError│ └── ApprovalExecutionFailedError└── NetworkError └── TimeoutError — request timed outCatching by action
Section titled “Catching by action”The hierarchy is built so that catching a parent class handles every leaf that requires the same response.
- “The user needs to re-authorize.” Catch
ReAuthRequiredError. Triggers: revoked grant, expired grant, broken credential, deleted grant. Response: show the Connect widget. - “The user needs to re-authorize with a wider scope.” Catch
ScopeReauthRequiredError(subclass ofProviderAPIError). Trigger: the provider returned 403 and the grant’s stored scopes don’t cover the route. Response: show the Connect widget; the new authorization upgrades the grant in place. - “The provider rejected the credential.” Catch
ProviderUnauthorizedError(subclass ofProviderAPIError). Trigger: the provider returned 401 on arequest()call — the credential was revoked, expired, or otherwise invalidated provider-side. One exception: a 401 whoseWWW-Authenticatechallenge carrieserror="insufficient_scope"(RFC 6750) means the credential is still valid but lacks a scope, so it surfaces as a genericProviderAPIErrorinstead. Response: for an OAuth grant, show the Connect widget (a new Connect session re-authorizes the grant); for a managed-secret grant, update the stored secret with a valid value. - “The agent has no access to this provider.” Catch
NoDelegatedGrantError. Trigger: an agent calledrequest(provider=...)and no delegation or agent-owned managed-secret grant resolved. Response: prompt a user to delegate, or issue a managed-secret grant to the agent. - “The agent passed a grant_id it can’t reach.” Catch
AgentDelegationMissingError(a subclass ofGrantNotFoundError, so an existingGrantNotFoundErrorcatch still fires). Trigger: an agent calledrequest(grant_id=...)/proxy_request(grant_id=...)and the grant is not delegated to this agent — or a user/basegrant_idwas passed where the agent’s own delegation id (fromlist_grants) was required. Response: delegate the agent via Connect, or resolve by provider (omitgrant_id) to route through the agent’s existing delegations. - “The user session is too old for this operation.” Catch
StepUpRequiredError. Trigger: acontent_matchstep-up obligation requires a fresher identity session. Response: re-authenticate the user, obtain a fresh user session, and retry. - “Required request redaction could not be applied.” Catch
RedactDischargeFailedError. The provider request was denied before forwarding. Response: fix the request body shape or the rule’sredact.fields; retry only after the mismatch is corrected. - “This grant cannot be delegated onward.” Catch
GrantNotDelegableError. Response: establish a grant with onward delegation enabled at the source; for a managed secret, the operator must also permit re-delegation. - “The calling key lacks a required scope.” Catch
InsufficientScopeError. Response: rotate a version-stale key whenscope_version_mismatchis true; otherwise use a key whose explicit scopes includemissing. - “A transient backend condition.” Catch
TokenRefreshInProgressError. Trigger: a parallel refresh holds the lock. Response: retry with backoff. - “The quota window is exhausted.” Catch
QuotaExceededError. Trigger: a request-quota policy denied the call — the fixed window’s limit is spent. Response: waitretry_afterseconds (the time until the window resets), then retry. The SDKs never auto-retry this error — retrying before the window resets is denied again. - “You are sending requests too fast.” Catch
RateLimitError. Trigger: the platform rate limit for the API key (or the organization-wide aggregate, when one is configured) is exhausted — a transient throttle, not a denial. Response: waitretry_afterseconds and retry the same request;scopesays which ceiling was hit (api_key— slow this key down or spread load across keys;organization— every key shares one budget, so slowing one caller may not be enough). Distinct fromQuotaExceededError(a policy denial — retrying inside the window is denied again) and from a billing-period cap, which stays a genericBackendError. - “A network problem.” Catch
NetworkError. Trigger: connection failure, DNS, timeout. Response: retry with backoff (catchTimeoutErrorseparately if a different retry profile is needed for timeouts vs connection refused).
Selected exception details
Section titled “Selected exception details”AmbiguousGrantError
Section titled “AmbiguousGrantError”Raised when provider-based resolution matches more than one grant. Alter never silently picks a grant — there is no “default grant” or “most recently connected wins” behavior. The error enumerates the candidates so the caller (or the end user, conversationally) chooses explicitly. The candidates are always scoped to the caller’s own accessible grants — the error can never reveal a grant the caller could not use.
| Attribute | Description |
|---|---|
provider_id | The provider whose grants matched. |
candidates | One entry per matching grant: grant_id and label (the sibling address — retry with provider + label), plus, where the caller may see them, account_identifier and account_display_name. Retry with the chosen grant_id — the universal disambiguator. |
account_identifiers | List of matching accounts; show to the user to pick. |
account_was_provided | True if account= was already passed and still produced an ambiguity (sign of a mis-set account). |
app_user_ids | (Agent flavor) UUIDs of the users with matching delegations. For an agent holding delegations from multiple users, candidates carry grant IDs and labels only — other users’ account identifiers are never exposed to the agent. |
grant_ids | (Managed-secret flavor) IDs of the matching grants when one user delegated multiple managed-secret grants of the same template to the agent. Retry with the chosen grant_id. |
Pass the chosen grant_id (or account=<chosen> / label=<chosen> /
user_token=<jwt> for the account / label / agent flavors) to disambiguate
on retry. For production workloads, prefer addressing grants by grant_id
from the start — the Connect flow returns it at consent time, and it stays
unambiguous no matter how many grants a user adds later.
RestrictedGrantRequiresProxyError
Section titled “RestrictedGrantRequiresProxyError”Raised when raw-token retrieval is attempted against a grant whose policy
carries method/endpoint restrictions. Restricted grants are proxy-only by
design — a token handed to the caller could not be held to the restriction —
and there is no opt-out. Switch the call to the SDK’s proxied execution path
(proxy_request() / proxyRequest()); restriction denials there surface as
PolicyViolationError.
GrantNotDelegableError
Section titled “GrantNotDelegableError”Raised when agent.delegate() tries to delegate a grant that does not permit
onward delegation. Existing grant authority cannot be widened by retrying the
same call. Establish the source grant with delegation enabled; managed secrets
must also allow re-delegable grants at the operator-configured ceiling.
AgentDelegationMissingError
Section titled “AgentDelegationMissingError”Raised on the agent path when a grant_not_found 404 comes back for an
explicit grant_id. A subclass of GrantNotFoundError, so existing
except GrantNotFoundError / instanceof GrantNotFoundError handlers keep
catching it unchanged. For an agent caller the bare 404 is ambiguous — the
grant may exist but not be delegated to this agent, or a user/base grant_id
was passed where the agent’s own delegation id (from list_grants /
listGrants) was required (an agent can only address its own delegation by
id). The App/operator path is unaffected — there grant_not_found genuinely
means “no such grant / not owned”, so it stays a plain GrantNotFoundError.
Inherits GrantNotFoundError’s recovery-context fields (provider_id /
agent_id / app_user_id, populated only on identity-mode raises). Recover by
delegating the agent through Connect (create_connect_session /
createConnectSession), or resolve by provider (omit grant_id) so the call
routes through the agent’s existing delegations.
SiblingLabelConflictError
Section titled “SiblingLabelConflictError”Raised by mint_grant() / mintGrant() when the requested label is already
held by an active grant on the same connection. Carries label. Pick a
different label, or revoke the holder first — revoking a grant frees its
label.
InsufficientScopeError
Section titled “InsufficientScopeError”Raised when the calling key — possibly after per-call attenuation — lacks a scope the backend route requires.
| Attribute | Description |
|---|---|
required | Scopes the route requires. |
granted | Scopes the key has, post-intersection with constraints. |
missing | required \ granted. |
scope_version | Catalog version the key was minted against. |
current_scope_version | Catalog version the server is on. |
scope_version_mismatch | True iff the missing scope only exists at the server’s version — rotate the key. |
documentation_url | Per-scope docs link. |
ScopeReauthRequiredError
Section titled “ScopeReauthRequiredError”Raised when the provider returns 403 and the grant’s stored scopes don’t cover the route.
| Attribute | Description |
|---|---|
grant_id | The grant that needs re-authorization. |
provider_id | The provider. |
missing_scopes | When parsed from a WWW-Authenticate: insufficient_scope challenge, the specific scopes missing. None when the source was a pre-flight backend flag. |
status_code, response_body | The provider’s raw response. |
Pattern:
try: await app.request(...)except ScopeReauthRequiredError as e: session = await app.create_connect_session(allowed_providers=[e.provider_id]) notify_user(session.connect_url)ProviderUnauthorizedError
Section titled “ProviderUnauthorizedError”Raised when the provider returns 401 to a request() call — the provider rejected the credential (revoked, expired, or otherwise invalidated provider-side). The 401 sibling of ScopeReauthRequiredError; both extend ProviderAPIError, so existing ProviderAPIError handlers keep catching it. A 401 whose WWW-Authenticate challenge carries error="insufficient_scope" (RFC 6750) is deliberately excluded — the credential is still valid, it just lacks a scope — and surfaces as a generic ProviderAPIError instead.
| Attribute | Description |
|---|---|
grant_id | The grant whose credential was rejected. |
provider_id | The provider. |
status_code, response_body | The provider’s raw response. |
Recovery depends on the grant family:
- OAuth grant — re-authorize via a new Connect session (same pattern as
ScopeReauthRequiredError:create_connect_session(allowed_providers=[e.provider_id])and surface the URL to the user). - Managed-secret grant — the stored secret is no longer accepted by the provider; update it with a valid value.
PolicyViolationError
Section titled “PolicyViolationError”Raised when a policy denies the call.
| Attribute | Description |
|---|---|
policy_error | Backend-supplied identifier for the violated policy (e.g., outside_business_hours, ip_not_allowed, rate_limit_exceeded). |
May resolve on its own (time-of-day window opens, rate limit decays). One
policy denial does not raise this class: a request-quota window denial
raises QuotaExceededError instead, which carries the
seconds until the window resets.
StepUpRequiredError
Section titled “StepUpRequiredError”A typed PolicyViolationError for a content_match step-up obligation. Its
policy_error / policyError is always step_up_required, and
max_session_age_seconds / maxSessionAgeSeconds carries the rule’s freshness
ceiling when supplied. Re-authenticate the user to obtain a fresh identity
session, then retry the original request.
RedactDischargeFailedError
Section titled “RedactDischargeFailedError”A typed PolicyViolationError raised when a content_match redact obligation
could not safely transform the request body. Its policy_error /
policyError is always redact_discharge_failed. The request fails closed and
is not forwarded to the provider. Correct the request shape or the rule’s
redact.fields before retrying.
QuotaExceededError
Section titled “QuotaExceededError”Raised when a request-quota policy rule’s window limit is exhausted. This is a policy denial, not a transient outage — retrying before the window resets is denied again, so the SDKs never auto-retry it.
| Attribute | Description |
|---|---|
retry_after | Seconds until the current quota window resets, taken from the Retry-After response header when present and the body value otherwise, and capped at 2,678,400 seconds (31 days) (see the backoff ceiling). The cap is deliberately far larger than RateLimitError’s: a quota window is a calendar period, so a monthly quota legitimately means weeks, and clamping it to an hour would wake a caller ~700 times over a month instead of once. May be None (undefined in TypeScript) when the backend didn’t supply either. |
Recovery: wait retry_after seconds, then retry. A 429 relayed from the
provider itself (an upstream rate limit passed through the proxied call path)
is not a quota denial and surfaces as a generic BackendError instead.
RateLimitError and the platform rate limit
Section titled “RateLimitError and the platform rate limit”Raised when the platform rate limit is exhausted — a transient throttle, not a
denial: the same request succeeds once the window drains. Both SDKs surface
this as a typed RateLimitError.
The limit. Every API key allows 1,000 requests per minute by default. Two independent controls adjust it, and they are checked separately.
Per key — a key can be minted with a custom limit (for example, alter keys mint --rate-limit-rpm), and an organization policy can set a maximum any
single key may be held to. The effective per-key limit is:
| Key minted with an explicit limit? | Organization sets a per-key maximum? | Effective limit |
|---|---|---|
| yes | yes | the lower of the two — the maximum is a cap, never a boost |
| yes | no | the key’s own limit |
| no | yes | the organization’s maximum (it also serves as the default) |
| no | no | 1,000 per minute |
Across the organization — a policy can additionally set an aggregate limit
that every key in the organization shares. It is checked after a request
clears its own per-key limit, so exceeding it is reported separately with
scope: "organization". It is unset by default.
The 429 response body. On the wire the fields are nested under a detail
object; the SDKs unwrap it for you and expose the fields on the typed
RateLimitError. A raw HTTP caller sees:
{ "detail": { "error": "rate_limit_exceeded", "scope": "api_key", "message": "API key rate limit exceeded (1000 requests per 60s). Retry after 2s.", "limit": 1000, "window_seconds": 60, "retry_after": 2 }}| Field | Description |
|---|---|
error | Always rate_limit_exceeded. |
scope | Which ceiling was hit: ip (a per-client-IP gate that fronts the unauthenticated surfaces, evaluated before any key is resolved), api_key (this key’s own limit), or organization (an organization-wide aggregate every key shares). |
limit | The limit that was exceeded, in requests per window — the ceiling named by scope, so on an organization rejection it is the organization aggregate, not this key’s limit. |
window_seconds | The window length in seconds (60). |
retry_after | Seconds to wait before retrying — the time until the next unit of budget frees up, not the whole window. Budget is replenished continuously rather than all at once when the window ends, so this is often a second or two even on a 60-second window. Honour it instead of sleeping window_seconds. |
message | Human-readable summary of the above. |
On the typed SDK exception, scope, limit and window_seconds /
windowSeconds are optional — not every response carrying this error code
supplies them — so check for a missing value before branching on one.
Response headers:
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying. The SDKs’ typed retry_after / retryAfter field prefers this header over the body value. |
RateLimit-Policy | Standard quota policy metadata (q quota and w window seconds) following the IETF RateLimit fields draft. |
RateLimit | Standard current service limit for the binding policy (r remaining and t seconds to the effective window end). |
X-RateLimit-Limit | The ceiling this response was metered against — never assume it is this key’s own limit. On a 429 it is the ceiling named by scope: the key’s limit for api_key, the organization-wide aggregate for organization. On a success it is the ceiling the reported X-RateLimit-Remaining belongs to: the key’s own limit, or the organization aggregate when that is the tighter of the two. |
X-RateLimit-Remaining | Requests remaining against the binding constraint — the lower of what is left on this key and, when an organization aggregate is configured, what is left on that aggregate. 0 on a 429. |
X-RateLimit-Reset | Unix timestamp at which the current rate-limit window resets — the same meaning the header carries on GitHub and Stripe. It is not a per-request retry instant: Retry-After is the authoritative wait for the rejected call. Sleep until Retry-After to retry one call; read X-RateLimit-Reset to know when the full budget returns. |
X-Alter-Rate-Limited-Scope | ip, api_key, or organization — the same value as the body’s scope. Present only on this limit’s 429s — it distinguishes a platform throttle from other 429 responses (a quota policy denial or a billing-period cap). |
The standard pair and compatibility X-RateLimit-* headers also ride on
successful responses to SDK calls, so a well-behaved client can throttle
itself before it ever sees a 429. They are not attached to every Alter endpoint
— only to requests this limit meters.
Treat their absence as unknown, never as unlimited: a response without them
says nothing about how much budget is left, and a client that reads “no headers”
as “no limit” will keep hammering the very endpoint that stopped reporting.
Both SDKs parse the compatibility triple, or the standard pair when the triple
is absent, into a frozen snapshot and expose the latest reading as
last_rate_limit / lastRateLimit, on the client and on App / Agent alike.
It is updated by every call the limit meters — successful calls included — so
the self-throttling above is something an application can actually implement:
import asynciofrom datetime import datetime, timezone
await app.request("GET", url, grant_id=grant_id)
budget = app.last_rate_limitif budget is not None and budget.remaining < 10: # reset_at is an aware UTC datetime read from the server's clock. await asyncio.sleep((budget.reset_at - datetime.now(timezone.utc)).total_seconds())await app.request("GET", url, { grantId });
const budget = app.lastRateLimit;if (budget !== null && budget.remaining < 10) { await new Promise((r) => setTimeout(r, budget.resetAt.getTime() - Date.now()));}The snapshot carries limit, remaining and reset_at / resetAt, with the
same meanings as the headers — including the caveat that they describe the
binding constraint, which may be the organization aggregate rather than this
key’s own ceiling. It is None / null until a metered call completes, and
stays at its last reading when a later response carries no headers: absence is
unknown, so the value is never silently cleared.
The alter CLI surfaces the same reading on its metered commands:
alter sdk-passthrough resolve-identity includes a rate_limit object in its
JSON output (null when the backend reported none), and
alter sdk-passthrough request prints an alter-rate-limit: line to stderr
alongside the provider’s own response headers.
Recovery: wait retry_after seconds and retry the same request. If scope is
api_key, slow this key down or spread load across keys; if organization,
every key in the organization shares one budget, so slowing a single caller
may not be enough — raise the ceiling or reduce aggregate load.
The typed scope field stays a plain string rather than a closed enum, so a
ceiling added in a later release does not become an unparseable response for an
application pinned to an older SDK. Both SDKs export the currently known values
as RATE_LIMIT_SCOPE_VALUES (the ordered values ip, api_key, organization,
outermost-first), so a handler can branch on the shared constant instead of a
hand-typed literal — and should treat an unrecognized value as
some ceiling was hit, never as malformed.
RateLimitError is raised the same way from every SDK surface, including the
managed-agent calls (agents.create(), agents.list(), …): the platform limit
meters every API-key-authenticated request, not only credential retrieval.
The backoff ceiling
Section titled “The backoff ceiling”retry_after / retryAfter is capped, whether the value came from the
Retry-After header or from the response body. The documented recovery is to
sleep the value, and an unbounded number — from a malformed header, or from an
intermediate proxy that rewrote Retry-After — would park a worker for as long
as that number says with no way to recover.
The cap depends on which 429 it is, because the two describe different clocks:
| Error | Cap | Why |
|---|---|---|
RateLimitError | 3,600 seconds (one hour) | The platform’s own retry hint is bounded well below one hour. The larger SDK ceiling rejects corrupted or hostile guidance without clipping a legitimate platform response. |
QuotaExceededError | 2,678,400 seconds (31 days) | A quota rule’s window is a calendar period — minute, hour, day or month — and the response reports the longest window that is exhausted. A monthly quota legitimately means weeks. 31 days is the longest such window, so every real hint passes through untouched while a nonsensical value is still bounded. |
Honour the value you are given rather than a fixed sleep: on a monthly quota it is the difference between waking once and waking hundreds of times.
LastActiveKeyError
Section titled “LastActiveKeyError”Raised by agents.revoke_key() / agents.revokeKey() when revoking would leave the agent with zero non-revoked keys.
Recovery: mint a replacement first, deploy it, then retry the revoke. Or pass force=True / { force: true } to deliberately brick the agent.
AgentError family
Section titled “AgentError family”All inherit from BackendError. Each carries a stable code attribute (agent_not_found, agent_inactive, key_revoked, …). Switch on err.code (not on the message) when handling backend categories. KeyRevokedError and KeyAlreadyRevokedError deliberately share key_revoked; use the typed class when the distinction between authentication rejection and an invalid key-lifecycle transition matters.
Every backend-originated error also exposes the HTTP status as
status_code (Python) / statusCode (TypeScript). A 500/503 response with a
valid Retry-After header carries the bounded value in
details["retry_after_seconds"] / details.retry_after_seconds.
AgentConcurrentUpdateError (agent_concurrent_update, HTTP 409) means a
managed-agent update used a stale expected_version / expectedVersion.
Fetch the current agent, recompute the full desired allowlist from that fresh
record, and retry with its version. Do not replay a stale scope payload:
scope updates replace the complete allowlist and could otherwise restore
access another operator removed.
| Error | Recovery |
|---|---|
InvalidKeyError | Replace the unknown/deleted key with a valid app or agent key. |
KeyExpiredError | Mint and deploy a key whose configured expiry is still in the future. |
KeyRotationExpiredError | Replace the rotated key with its active successor. |
KeyInactiveError | Replace the key or restore its supported active lifecycle state. |
KeyAppNotFoundError | Retry only after the owning application is restored; this normally indicates backend data corruption. |
AgentNotFoundError | Correct the agent id/name, or recreate an agent that no longer exists. Also raised by ANY credential call when the client’s caller identifier names no active managed agent (details.error == "caller_agent_unresolved") — there, fix the asserted caller identity (the agent may have been revoked). |
CallerAgentMismatchError | Use the agent bound to the key, or use a key bound to the asserted caller agent. |
CallerAgentResolutionError | Retry after details.retry_after_seconds; persistent failures require operator investigation. |
AgentNameExistsError | Choose another name, or revoke the existing active agent before reusing it. |
AgentConcurrentUpdateError | Fetch fresh state, recompute the complete desired allowlist, and retry with the fresh version. |
MeRequiresAgentKeyError | Call me() from an SDK instance using the agent’s own key; app-key callers should fetch the agent through the app namespace. |
KeyRevokedError, KeyAlreadyRevokedError | Mint and deploy a replacement key; a revoked key cannot return to an active lifecycle state. |
AgentInactiveError | Have an operator resume the agent, then retry. Raised by credential calls — request() and proxy_request() — and ONLY by those: the agent-management namespace deliberately skips the lifecycle check so agent.me() keeps working for a paused workload to self-diagnose. |
AgentRevokedError | Recreate the terminally revoked agent and issue new keys. Same credential-only reach as AgentInactiveError, though revoking an agent revokes its keys in the same operation, so the usual rejection after a revoke is KeyRevokedError; this class covers the narrow race where a key authenticated just before the revoke committed. |
KeyNotFoundError | Correct the key id and verify that it belongs to the targeted agent. |
LastActiveKeyError | Mint and deploy a replacement first, or explicitly force the revoke when bricking the agent is intended. |
AgentKeyLimitError | The agent is at its per-agent active-key cap. Revoke a key it no longer uses, then retry the mint — the cap counts non-revoked keys, so a revoke frees a slot immediately. |
AgentCannotMintSubagentsError | Use an app key for agent-management operations. |
IdempotencyKeyBodyMismatchError | Replay the original body with that idempotency key, or use a fresh key for the changed operation. |
IdempotencyKeyAgentRevokedError | Use a fresh idempotency key to create a new agent. |
IdempotencyKeyAgentInactiveError | Resume the cached agent, or use a fresh idempotency key to create another agent. |
Agent codes without typed SDK wrappers
Section titled “Agent codes without typed SDK wrappers”A few backend agent codes have no dedicated exception subclass. They are still stable contracts. Which class carries them depends on the call that produced them, not on the status:
- A code raised by a credential call —
request()/proxy_request()— on a 403 arrives asPolicyViolationError. Branch onerr.policy_error(Python) /err.policyError(TypeScript), the dedicated field for exactly this. - A code raised by an agent-management call arrives as a plain
BackendError, whatever its status.
In every case err.details['error'] (Python) / err.details.error (TypeScript) also carries the code, so a single branch on details handles the whole table uniformly.
details['error'] | HTTP | Surfaces as | Meaning |
|---|---|---|---|
agent_expected_version_required | 422 | BackendError | A direct request supplied scopes without expected_version. The published SDKs reject this as AlterValueError before the network; fetch the agent and pass its current version. |
agent_scope_not_allowed | 403 | PolicyViolationError | The agent key called a route whose target provider / OAuth scope / managed-secret name is not in the agent’s scope allowlist. Widen the allowlist on the agent. |
idempotency_record_corrupted | 500 | BackendError | An idempotency replay pointed at an agent or key that no longer exists. Retry with a fresh idempotency key; the underlying corruption is operator-investigable. |
An agent key that calls any agent-management operation — including reading another agent’s record — is refused with AgentCannotMintSubagentsError (see the table above); use an app key for management calls and agent.me() for self-introspection.
Approval errors
Section titled “Approval errors”| Error | Meaning | Recovery |
|---|---|---|
ApprovalDeniedError | Approver clicked Deny. | The safe decision reason is the exception message; surface it to the requester. Use approval_id for correlation. |
ApprovalExpiredError | Window elapsed; no decision made. | Re-issue the call to create a new approval. |
ApprovalTimeoutError | SDK gave up waiting; the approval may still be pending. | Persist approval_id and re-poll. A transient polling failure is preserved as the exception cause. |
ApprovalExecutionFailedError | Approved, but the eventual provider call failed. | Inspect the safe message, which prefers the execution error, fix the underlying grant/policy/provider failure, and re-issue the call. |
ApprovalError.details contains the approval_id when one is available; it
does not contain the decision or execution reason.
Server SDK Connect flow errors
Section titled “Server SDK Connect flow errors”These exceptions belong to the Python and TypeScript server SDKs’ headless Connect helpers. They are not exported by the browser Connect package.
| Error | Meaning | Recovery |
|---|---|---|
ConnectFlowError | Generic flow failure, including an unavailable/expired token on the first poll, an unknown terminal state, or a total usage-limit application failure. | Inspect the safe message. For a total usage-limit failure, inspect details["failed_grants"] / details.failed_grants. An unavailable first poll deliberately does not disclose whether the session existed; create a fresh session. |
ConnectDeniedError | User clicked Deny on the provider’s consent screen. | Respect the decision; start another flow only when the user chooses to retry. |
ConnectConfigError | OAuth client is misconfigured (wrong redirect URI, invalid client ID/secret). | Correct the provider configuration in the portal, then create a fresh session. |
ConnectTimeoutError | Alter observed a pending session but received no completion callback before the local deadline or server expiry. details["reason"] / details.reason is "poll_deadline_elapsed" or "session_expired". | Re-poll the same token after a local deadline while the server session remains valid; create a fresh session after server expiry. A provider may have kept an error on its own page, the browser may have closed, or the user may have abandoned the flow; without a callback Alter cannot truthfully name which one occurred. |
Browser Connect SDK errors
Section titled “Browser Connect SDK errors”@alter-ai/connect exports the AlterError interface, not exception classes:
interface AlterError { code: string; message: string; details?: Record<string, unknown>; failedGrants?: ConnectFailedGrant[];}Flow failures arrive through onError and the error event. Branch on
error.code, never message. The browser SDK’s own current codes are
popup_blocked, redirect_error, invalid_oauth_url, invalid_response, and
grant_policy_application_failed; errors posted by the hosted flow preserve
their server-supplied code, so that set is intentionally open.
- For
popup_blocked, ask the user to allow the popup and retry. - For
redirect_error, restore browser storage access and retry. - For
invalid_oauth_urlorinvalid_response, stop and report the malformed flow rather than treating it as success. - For
grant_policy_application_failed, inspectfailedGrants. A partial completion is not an error: it arrives throughonSuccess, with failures incompletion.failedGrants.
Calls rejected at the local lifecycle boundary use Error objects with a
code: invalid_options means fix the open() arguments, while
sdk_destroyed means create a new SDK instance. Passing the reserved
baseURL configuration throws a plain Error without an AlterError code;
omit that field. Popup closure is an onExit transition, not an error. See the
complete browser code and delivery table
and callback guarantees.
CLI exit contract
Section titled “CLI exit contract”The alter CLI is an executable surface, not an exception-class API. Scripts
branch on its stable process status and use stderr only for diagnostics. The
CLI scripting reference is the
language-specific source of truth.
| Status | Name | Recovery |
|---|---|---|
0 | OK | Continue; the command succeeded. |
1 | ERROR | Inspect stderr; fix the reported runtime/server problem or retry a transient network failure. |
2 | USAGE | Correct the flag, argument, configuration, or input format. |
3 | AUTH | Run alter auth login to replace a missing, expired, or revoked PAT. |
4 | NOT_FOUND | Correct the resource identifier or handle absence as an expected script branch. |
5 | CONFLICT | Reconcile the current resource state or confirmation requirement, then retry. |
6 | RATE_LIMIT | Retry with backoff. |
7 | FORBIDDEN | Two causes, and the printed message says which: the PAT lacks a required explicit scope (re-mint or select one that has it — signing in again with the same scopes does not help), or the organization’s plan does not include the feature (upgrade the plan). |
8 | CANCELLED | Treat the operation as not performed; in automation, supply the command’s documented confirmation flag. |
Recovering from missing-grant errors
Section titled “Recovering from missing-grant errors”When a request fails because the user hasn’t authorized the provider yet, the SDK exposes recovery context on the typed error so a re-consent flow can be driven without manual derivation:
NoDelegatedGrantErrorcarriesprovider_id/agent_id/app_user_id(camelCase in TypeScript).GrantNotFoundErrorcarriesprovider_id/agent_id/app_user_idwhen the raise was identity-mode.GrantExpiredErrorcarriesprovider_id/agent_id/app_user_idwhen the expiry was raised on the delegation path (a TTL-lapsed delegation chain); the root-path TTL expiry leaves them unset.CredentialRevokedErrorcarriesprovider_id/app_user_id(noagent_id— credential revocation is grant-scoped, not agent-scoped).
Python
from alter_sdk import NoDelegatedGrantError
try: await app.request(provider="slack", user_token=jwt, url=..., method=...)except NoDelegatedGrantError as e: # 1. Mint a recovery Connect session using the error's context. session = await app.create_connect_session_for_error( e, allowed_origin="https://app.example.com", ) # 2. Surface the URL to the user. redirect_user(session.connect_url) # 3. Poll until consent completes. results = await app.poll_connect_session(session.session_token) # 4. Retry with the freshly-minted grant_id. response = await app.request(grant_id=results[0].grant_id, url=..., method=...)TypeScript
import { HttpMethod, NoDelegatedGrantError } from "@alter-ai/alter-sdk";
try { await app.request(HttpMethod.GET, "...", { provider: "slack", userToken: jwt });} catch (e) { if (e instanceof NoDelegatedGrantError) { // 1. Mint a recovery Connect session. const session = await app.createConnectSessionForError(e, { allowedOrigin: "https://app.example.com", }); // 2. Surface the URL. redirectUser(session.connectUrl); // 3. Poll until consent completes. const results = await app.pollConnectSession(session.sessionToken); // 4. Retry. const response = await app.request(HttpMethod.GET, "...", { grantId: results[0].grantId, }); } else { // Re-throw every other error class — silently swallowing // NetworkError, BackendError, or programming bugs would hide // the real failure. throw e; }}When recovery context is available
Section titled “When recovery context is available”| Error | Identity-mode raise (carries context) | Direct-mode raise (context is None) |
|---|---|---|
NoDelegatedGrantError | Agent-runtime resolution with provider= + optional user_token= | n/a — always identity-mode |
GrantNotFoundError | Identity-mode lookup by (user, provider) failed | Caller passed an explicit grant_id= that doesn’t exist |
CredentialRevokedError | Token refresh hit a permanent failure (provider revoked, refresh token expired) | n/a — always carries a grant_id |
GrantExpiredError | Delegation-chain TTL expiry can carry provider/agent/user context | Root-path TTL expiry leaves recovery context unset |
create_connect_session_for_error / createConnectSessionForError accepts
NoDelegatedGrantError, GrantNotFoundError (including its agent subclass),
and CredentialRevokedError. It raises AlterValueError when required
provider context is unavailable, and also when NoDelegatedGrantError lacks
its required agent context. For direct-mode GrantNotFoundError (a stale
grant_id), call create_connect_session /
createConnectSession directly with the correct provider. GrantExpiredError
exposes recovery context on delegation-chain failures but is not accepted by
the convenience helper; use the explicit session-creation method when
re-consent is appropriate.
What recovery does NOT cover
Section titled “What recovery does NOT cover”AmbiguousGrantError— surfacecandidates(each carries agrant_id, the universal disambiguator) to let the user or agent pick, then retry with the chosengrant_id. For the OAuth account flavor,account_identifiers+account=and, for the agent flavor,app_user_ids+user_token=are alternates — and the only usable disambiguators when account/user fields are empty are thegrant_ids incandidates. Minting a new session would be the wrong remediation here.