Skip to content

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 out

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 of ProviderAPIError). 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 of ProviderAPIError). Trigger: the provider returned 401 on a request() call — the credential was revoked, expired, or otherwise invalidated provider-side. One exception: a 401 whose WWW-Authenticate challenge carries error="insufficient_scope" (RFC 6750) means the credential is still valid but lacks a scope, so it surfaces as a generic ProviderAPIError instead. 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 called request(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 of GrantNotFoundError, so an existing GrantNotFoundError catch still fires). Trigger: an agent called request(grant_id=...) / proxy_request(grant_id=...) and the grant is not delegated to this agent — or a user/base grant_id was passed where the agent’s own delegation id (from list_grants) was required. Response: delegate the agent via Connect, or resolve by provider (omit grant_id) to route through the agent’s existing delegations.
  • “The user session is too old for this operation.” Catch StepUpRequiredError. Trigger: a content_match step-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’s redact.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 when scope_version_mismatch is true; otherwise use a key whose explicit scopes include missing.
  • “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: wait retry_after seconds (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: wait retry_after seconds and retry the same request; scope says 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 from QuotaExceededError (a policy denial — retrying inside the window is denied again) and from a billing-period cap, which stays a generic BackendError.
  • “A network problem.” Catch NetworkError. Trigger: connection failure, DNS, timeout. Response: retry with backoff (catch TimeoutError separately if a different retry profile is needed for timeouts vs connection refused).

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.

AttributeDescription
provider_idThe provider whose grants matched.
candidatesOne 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_identifiersList of matching accounts; show to the user to pick.
account_was_providedTrue 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.

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.

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.

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.

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.

Raised when the calling key — possibly after per-call attenuation — lacks a scope the backend route requires.

AttributeDescription
requiredScopes the route requires.
grantedScopes the key has, post-intersection with constraints.
missingrequired \ granted.
scope_versionCatalog version the key was minted against.
current_scope_versionCatalog version the server is on.
scope_version_mismatchTrue iff the missing scope only exists at the server’s version — rotate the key.
documentation_urlPer-scope docs link.

Raised when the provider returns 403 and the grant’s stored scopes don’t cover the route.

AttributeDescription
grant_idThe grant that needs re-authorization.
provider_idThe provider.
missing_scopesWhen 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_bodyThe 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)

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.

AttributeDescription
grant_idThe grant whose credential was rejected.
provider_idThe provider.
status_code, response_bodyThe 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.

Raised when a policy denies the call.

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

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.

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.

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.

AttributeDescription
retry_afterSeconds 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
yesyesthe lower of the two — the maximum is a cap, never a boost
yesnothe key’s own limit
noyesthe organization’s maximum (it also serves as the default)
nono1,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
}
}
FieldDescription
errorAlways rate_limit_exceeded.
scopeWhich 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).
limitThe 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_secondsThe window length in seconds (60).
retry_afterSeconds 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.
messageHuman-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:

HeaderDescription
Retry-AfterSeconds to wait before retrying. The SDKs’ typed retry_after / retryAfter field prefers this header over the body value.
RateLimit-PolicyStandard quota policy metadata (q quota and w window seconds) following the IETF RateLimit fields draft.
RateLimitStandard current service limit for the binding policy (r remaining and t seconds to the effective window end).
X-RateLimit-LimitThe 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-RemainingRequests 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-ResetUnix 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-Scopeip, 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 asyncio
from datetime import datetime, timezone
await app.request("GET", url, grant_id=grant_id)
budget = app.last_rate_limit
if 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.

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:

ErrorCapWhy
RateLimitError3,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.
QuotaExceededError2,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.

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.

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.

ErrorRecovery
InvalidKeyErrorReplace the unknown/deleted key with a valid app or agent key.
KeyExpiredErrorMint and deploy a key whose configured expiry is still in the future.
KeyRotationExpiredErrorReplace the rotated key with its active successor.
KeyInactiveErrorReplace the key or restore its supported active lifecycle state.
KeyAppNotFoundErrorRetry only after the owning application is restored; this normally indicates backend data corruption.
AgentNotFoundErrorCorrect 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).
CallerAgentMismatchErrorUse the agent bound to the key, or use a key bound to the asserted caller agent.
CallerAgentResolutionErrorRetry after details.retry_after_seconds; persistent failures require operator investigation.
AgentNameExistsErrorChoose another name, or revoke the existing active agent before reusing it.
AgentConcurrentUpdateErrorFetch fresh state, recompute the complete desired allowlist, and retry with the fresh version.
MeRequiresAgentKeyErrorCall me() from an SDK instance using the agent’s own key; app-key callers should fetch the agent through the app namespace.
KeyRevokedError, KeyAlreadyRevokedErrorMint and deploy a replacement key; a revoked key cannot return to an active lifecycle state.
AgentInactiveErrorHave 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.
AgentRevokedErrorRecreate 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.
KeyNotFoundErrorCorrect the key id and verify that it belongs to the targeted agent.
LastActiveKeyErrorMint and deploy a replacement first, or explicitly force the revoke when bricking the agent is intended.
AgentKeyLimitErrorThe 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.
AgentCannotMintSubagentsErrorUse an app key for agent-management operations.
IdempotencyKeyBodyMismatchErrorReplay the original body with that idempotency key, or use a fresh key for the changed operation.
IdempotencyKeyAgentRevokedErrorUse a fresh idempotency key to create a new agent.
IdempotencyKeyAgentInactiveErrorResume the cached agent, or use a fresh idempotency key to create another agent.

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 callrequest() / proxy_request() — on a 403 arrives as PolicyViolationError. Branch on err.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']HTTPSurfaces asMeaning
agent_expected_version_required422BackendErrorA 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_allowed403PolicyViolationErrorThe 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_corrupted500BackendErrorAn 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.

ErrorMeaningRecovery
ApprovalDeniedErrorApprover clicked Deny.The safe decision reason is the exception message; surface it to the requester. Use approval_id for correlation.
ApprovalExpiredErrorWindow elapsed; no decision made.Re-issue the call to create a new approval.
ApprovalTimeoutErrorSDK 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.
ApprovalExecutionFailedErrorApproved, 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.

These exceptions belong to the Python and TypeScript server SDKs’ headless Connect helpers. They are not exported by the browser Connect package.

ErrorMeaningRecovery
ConnectFlowErrorGeneric 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.
ConnectDeniedErrorUser clicked Deny on the provider’s consent screen.Respect the decision; start another flow only when the user chooses to retry.
ConnectConfigErrorOAuth client is misconfigured (wrong redirect URI, invalid client ID/secret).Correct the provider configuration in the portal, then create a fresh session.
ConnectTimeoutErrorAlter 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.

@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_url or invalid_response, stop and report the malformed flow rather than treating it as success.
  • For grant_policy_application_failed, inspect failedGrants. A partial completion is not an error: it arrives through onSuccess, with failures in completion.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.

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.

StatusNameRecovery
0OKContinue; the command succeeded.
1ERRORInspect stderr; fix the reported runtime/server problem or retry a transient network failure.
2USAGECorrect the flag, argument, configuration, or input format.
3AUTHRun alter auth login to replace a missing, expired, or revoked PAT.
4NOT_FOUNDCorrect the resource identifier or handle absence as an expected script branch.
5CONFLICTReconcile the current resource state or confirmation requirement, then retry.
6RATE_LIMITRetry with backoff.
7FORBIDDENTwo 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).
8CANCELLEDTreat the operation as not performed; in automation, supply the command’s documented confirmation flag.

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:

  • NoDelegatedGrantError carries provider_id / agent_id / app_user_id (camelCase in TypeScript).
  • GrantNotFoundError carries provider_id / agent_id / app_user_id when the raise was identity-mode.
  • GrantExpiredError carries provider_id / agent_id / app_user_id when the expiry was raised on the delegation path (a TTL-lapsed delegation chain); the root-path TTL expiry leaves them unset.
  • CredentialRevokedError carries provider_id / app_user_id (no agent_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;
}
}
ErrorIdentity-mode raise (carries context)Direct-mode raise (context is None)
NoDelegatedGrantErrorAgent-runtime resolution with provider= + optional user_token=n/a — always identity-mode
GrantNotFoundErrorIdentity-mode lookup by (user, provider) failedCaller passed an explicit grant_id= that doesn’t exist
CredentialRevokedErrorToken refresh hit a permanent failure (provider revoked, refresh token expired)n/a — always carries a grant_id
GrantExpiredErrorDelegation-chain TTL expiry can carry provider/agent/user contextRoot-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.

  • AmbiguousGrantError — surface candidates (each carries a grant_id, the universal disambiguator) to let the user or agent pick, then retry with the chosen grant_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 the grant_ids in candidates. Minting a new session would be the wrong remediation here.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.