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.

The reference tracks the latest published SDK line. The caller-agent and API-key lifecycle classes (CallerAgentMismatchError, CallerAgentResolutionError, KeyExpiredError, KeyRotationExpiredError, KeyInactiveError, and KeyAppNotFoundError) require Python alter-sdk>=0.23.1 and TypeScript @alter-ai/alter-sdk>=0.23.1; 0.22.0 does not export them. AmbiguousGrantError.candidates_truncated / .candidatesTruncated is newer still and is not present on the 0.24.2 line — on an SDK without it the attribute reads as absent, which client code should treat as “not reported”, never as “the list is complete”. The private-memory API (MemoryIsolationError and its code, ScopedMemoryStore, and the assertion verifier) is unreleased: it ships in the next SDK release after 0.27.0, which does not export it. See the language-specific export indexes for the exact names and upgrade commands.

AlterSDKError
├── AlterValueError — SDK rejected caller input; fix the code
├── MemoryIsolationError — private-memory identity/partition not verified; branch on code
├── BackendError — Alter backend returned an error
│ ├── ServerFeatureUnsupportedError — the backend predates a feature this call used
│ ├── BlockedDestinationError — destination failed the host/path allowlist or network security check
│ ├── 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 — rate limit, Alter's or an intermediary's (non-JSON 429); scope may be unset
│ └── 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 or IDP sign-in 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: when a managed-secret delegate call passed delegable as True / true and the message says the new child has no further hop, the held grant is already delegable, so call again with delegable omitted or False / false (details). Otherwise, 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 check required_any_of — when true, any ONE scope in missing satisfies the route (for example the approval status poll accepts approvals:read or proxy:execute), so add a single one rather than all of them; when false, use a key whose explicit scopes include every entry in 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. Also raised for a non-JSON 429 from an intermediary such as an edge provider; then the limiting layer is unknown, scope is unset and retry_after is set only when the response carried a Retry-After header. Response: wait retry_after seconds when set (otherwise bounded exponential backoff with jitter) and retry the same request; scope, when set, 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.
  • “The requested permissions need Alter’s approval.” Catch ConnectConfigError and check the error code for managed_oauth_scope_approval_required (details["error"] / details.error on a create-session failure, details["error_code"] / details.error_code on a Connect poll failure). Trigger: the scopes the app requests exceed what Alter’s managed OAuth client is approved for. Response: never retry — the response is retryable: false and every fresh Connect session fails identically until the approval changes. Contact Alter to have the scopes approved, or narrow the app’s configured scopes to the approved set, and only then create a fresh session.
  • “The identity provider cannot sign users in to approve a delegation.” Catch ConnectConfigError and check details["error"] / details.error for idp_signin_not_configured (HTTP 409). Trigger: create_managed_secret_connect_session() / createManagedSecretConnectSession() — the user approves a managed-secret delegation only after signing in through the application’s identity provider, and that provider has no usable sign-in client. The reason is at details["details"]["reason"] / details.details.reason: missing_client_id or missing_client_secret — add the sign-in client ID and secret to the identity provider; issuer_cannot_sign_in — the issuer validates tokens but cannot sign users in, so use an issuer that supports OpenID Connect sign-in (for WorkOS, the AuthKit issuer https://<slug>.authkit.app). Response: never retry — the response is retryable: false and every new session fails the same way until an administrator changes the identity provider. OAuth Connect sessions never raise it: when the identity provider cannot sign users in, an OAuth delegation goes through the provider’s own authorization instead. The hosted page’s own sign-in can also report no_idp_configured, but never to an SDK: creating a session already requires an identity provider to verify the user’s token.
  • “The user has nothing this delegation could use.” Catch ConnectConfigError and check details["error"] / details.error for no_eligible_grants (HTTP 409). Trigger: create_managed_secret_connect_session() / createManagedSecretConnectSession() with a key bound to no managed agent (an app key, or an agent-typed alter_ak_… key from API Keys), for a user who holds no credential that could be delegated to the agent. details["details"]["reason"] / details.details.reason names the cause (see Delegation request codes). Response: fix that cause, then create a new session; retrying unchanged gets the same answer. A managed agent’s own key never receives it.
  • “The Alter backend is older than this SDK feature.” Catch ServerFeatureUnsupportedError (a BackendError subclass). Trigger: Agent.create_managed_secret_connect_session() / agent.createManagedSecretConnectSession(), or delegable on a managed-secret session, against a backend that predates them. Response: use the App method without the new option until the backend supports it; retrying cannot help.
  • “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).

Private memory identity or partition verification failed. A direct child of AlterSDKError, not a BackendError: it is raised locally by memory_scope() / memoryScope(), by ScopedMemoryStore, and by the assertion verifier, never from an HTTP response. Branch on the stable code attribute, never on the message (the message only repeats the code as a prefix). The value is one of the closed MEMORY_ISOLATION_CODES vocabulary, identical in both SDKs; MemoryIsolationCode is the exported Literal / union type.

codeMeaningRecovery
memory_identity_unverifiedThe IdentityContext was constructed, deserialized, copied or mutated in-process and carries no provenance.Resolve identity through the authenticated SDK, or verify an assertion against the configured issuer, audience, trusted keys and application.
memory_live_refresh_requiredA resolved snapshot was bound to a ScopedMemoryStore without a resolver.Pass refresh_identity / refreshIdentity, or bind an assertion-backed identity.
memory_refresh_not_liveThe resolver returned an identity minted before the operation started (a cached or memoized result).Make the resolver call Alter on every invocation.
memory_identity_expiredThe assertion-backed identity is past its signed expiry.Obtain and verify a fresh assertion.
memory_assertion_invalidThe token failed verification. details["reason"] / details.reason is one of malformed, header, key, signature, lifetime, claims or binding; no token, claim or key material is ever included.key usually means a rotated or mis-pasted JWKS; lifetime an expired token or clock skew beyond the 5-second iat leeway; binding the wrong application or agent. Fix the verifier configuration, or obtain a fresh assertion.
memory_agent_requiredNo authenticated agent is bound (an application-only identity).Resolve or assert through an agent key.
memory_user_requiredUser mode was requested for a headless identity.Pass agent_only=True / { agentOnly: true } for a headless agent.
memory_user_not_activeThe represented user is not active.Deny; a suspended user cannot open memory until reinstated.
memory_user_cannot_be_droppedAgent-only mode was requested for an identity that represents a user.Use user mode; agent-only mode never drops a user.
memory_identity_changedThe refreshed identity resolves to a different partition than the one the store was bound to.Create a new scoped store for the new principal.
memory_store_scope_violationThe store returned a result outside the requested partition.Fail closed and correct the store integration.

A malformed path, key, value, query, filter, pagination argument, option or verifier argument is a programming error and raises AlterValueError instead, so a MemoryIsolationError handler only ever sees an authorization outcome. Serialized identity JSON does not establish trust; headless agents require explicit agent-only mode. See memory isolation and migration.

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. Bounded when candidates_truncated is true.
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. Bounded when candidates_truncated is true. 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. Bounded when candidates_truncated is true.
candidates_truncatedTrue when more grants matched than the error can carry. It bounds every disambiguator above — candidates, account_identifiers, app_user_ids and grant_ids are all drawn from the same capped set — so an absence from any of them is not evidence the grant, account or user does not exist. Narrow the request, or list the application’s grants and pick a grant_id. False (or absent) means the set was not capped.

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. The causes:

  • No consenting user. The held grant does not trace back to a user who approved it, so no one consented to sharing it onward.
  • The held grant is not delegable. The user did not allow onward sharing when they approved the delegation. Ask for it again through a Connect session with delegable set to True / true, which the user decides on the consent screen. In the wallet the user can allow it for a managed secret by delegating to the agent again with sharing on; for an OAuth connection the wallet can only create a new grant for the agent with sharing on, which has a different grant id.
  • The operator ceiling is off. For a managed secret, the secret’s delegation policy does not allow re-delegable grants. For an OAuth provider, the provider’s Agent sharing setting is off. An administrator turns it on. The ceiling is checked on every onward call, so turning it off refuses further delegation from grants that were already delegable.

It is also raised when delegable is True / true and the ceiling’s depth leaves the new child no further hop, since the child could never delegate onward.

detail_reason (Python) / detailReason (TypeScript) names the cause, so a caller branches on it rather than on the message: no_consenting_user, holder_not_delegable, ceiling_off, no_onward_hop or not_requested. It is None / null when the backend sent none (an older backend), and an unknown value is passed through unchanged. Omit delegable or pass False / false (neither triggers this depth check), or have an administrator raise the depth ceiling while it is below the cap of 8. A child that would itself sit deeper than the ceiling allows is refused with HTTP 422 delegation_depth_exceeded, raised as BackendError.

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. With required_any_of, alternatives — any one suffices.
grantedScopes the key has, post-intersection with constraints.
missingThe unsatisfied scopes. Without required_any_of: required \ granted, all needed. With it: every accepted alternative — add any one.
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.
required_any_ofTrue when the route accepts ANY ONE of required (OR semantics — e.g. the approval status poll accepts approvals:read or proxy:execute). False (default): every scope is required.

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.

A 400 from proxy_request() / proxyRequest() that surfaces as a plain BackendError with details['error'] / details.error set to invalid_url: Alter cannot send the request URL, for example because its host name is not a valid internationalized domain name. It applies to every proxied call, whether or not the call requires approval. Nothing is created or forwarded. Fix the URL; retrying it unchanged gets the same answer.

A 422 from proxy_request() / proxyRequest() that surfaces as a plain BackendError with details['error'] / details.error set to invalid_header_name: a request header name is not a valid HTTP header name (an RFC 9110 token: letters, digits and !#$%&'*+-.^_`|~, with no spaces). Nothing is created or forwarded. Both SDKs refuse such a name locally with AlterValueError before sending, so this error reaches an application only from another client, or from a request approved before the rule applied (which then fails instead of running). Rename the header and retry.

A 422 from proxy_request() / proxyRequest() that surfaces as a plain BackendError with details['error'] / details.error set to query_too_long and details['max_url_length'] / details.max_url_length set to 65536: a URL Alter would have to send is longer than 65,536 characters once percent-encoded, the most Alter’s HTTP client will send. Shorter URLs are forwarded and the provider applies its own limit. Length is measured as sent, so repeated list values and multi-byte characters count in full (an emoji is twelve encoded characters). The limit is the same whether the query is in url or in the separate query parameters. Two checks apply, and both refuse before anything reaches the provider:

  • The URL as written: the request’s url with the separate query parameters merged in. Checked when the call arrives, before any approval is created.
  • The URL as sent: the same URL plus any query-parameter credential Alter adds for the grant. Checked just before sending, so a request that fits as written can still be refused here; an approved request refused here fails.

Shorten the query, or move the data into the request body if the provider accepts it there, and retry. (request() builds its URL in the application and applies the same 65,536-character limit to url with its query parameters, raising AlterValueError before sending.)

A 422 from proxy_request() / proxyRequest() that surfaces as a plain BackendError with details['error'] / details.error set to query_params_unmergeable: the separate query-parameters argument could not be merged into the request URL because a parameter value has a shape the HTTP layer cannot encode. Length is never this error: a URL that is too long once merged is query_too_long, and a URL Alter cannot parse is refused earlier with 400 invalid_url. The request is rejected before any policy or credential work runs and nothing is forwarded to the provider. (request() merges its query parameters locally, so it raises a client-side error for the same inputs and never produces this code.) Flatten the query parameter values to strings, numbers, booleans, or lists of those, and retry.

These server-side failures arrive as a plain BackendError. Switch on details['error'] / details.error, and read the HTTP status from status_code / statusCode. On a 503 the SDKs copy the Retry-After header into details['retry_after_seconds'] / details.retry_after_seconds. A 502 carries no retry hint. Neither SDK retries any of these on its own.

The provider-call codes come from proxy_request() / proxyRequest(), where Alter makes the provider call. request() calls the provider from the application itself, so the same failures surface there as NetworkError or ProviderAPIError.

details['error']HTTPretry_after_secondsMeaningRecovery
provider_timeout502Not setThe provider did not answer within the call’s timeout. The request may have reached the provider.Retry with backoff. Before repeating a call that is not idempotent, check whether the provider applied it.
provider_unreachable502Not setAlter could not complete a connection to the provider, or the connection failed before a response arrived. The request may have reached the provider.Same as provider_timeout. If it persists, the provider or its address is down.
provider_unavailable5031Recent calls to this provider kept failing, so Alter is not sending it calls for a short cooldown. The provider was not contacted.Retry after the interval. The call was not sent, so a retry is safe for any method.
provider_busy5031Too many calls to this provider’s host were already in flight. The provider was not contacted.Retry after the interval. The call was not sent.
app_concurrency_limit5031The application already had as many concurrent proxied calls in flight as one application may hold. The provider was not contacted.Retry after the interval. If it recurs, run fewer proxied calls in parallel. The call was not sent.
egress_pool_exhausted5031Alter had no outbound connection free to start the call. This is Alter’s own capacity, not the provider’s or the application’s. The provider was not contacted.Retry after the interval. The call was not sent.
dns_resolution_timeout5035The provider’s host name could not be resolved in time, or the DNS resolver answered with a temporary failure (SERVFAIL). This says nothing about the request. The provider was not contacted.Retry after the interval. The call was not sent. If it persists for one host, that host’s DNS is failing. A host name that does not exist is a 400 dns_resolution_failed instead, which a retry cannot fix. Returned only for a host the grant may reach: the destination allowlist is checked before any lookup.
overloaded5031The Alter instance that received the call was already running as many proxied calls as it accepts at once, so it refused this one before resolving its grant or credential. The provider was not contacted.Retry after the interval; another instance often serves the retry. The call was not sent.

A refused 503 call has not reached the provider, so retrying it never repeats an action. The one-second intervals are short because each of those refusals is a condition of the Alter instance that answered, and the next attempt is often served by another instance. The five seconds for dns_resolution_timeout are not: a DNS resolver that timed out or answered with a temporary failure usually gives the same answer for a few seconds, whichever instance asks. For a call that waited for approval, Alter retries every code in this table except overloaded itself a bounded number of times after the approval (overloaded refuses a call when it is made, before any approval exists), and reports an ApprovalExecutionFailedError if those attempts run out; its message then starts exhausted <attempts> retries: and names the code.

Credential-store outages. request() and proxy_request() / proxyRequest() both read the credential from Alter’s credential store, for OAuth and managed-secret grants alike. When that store cannot be reached, times out, or refuses the read for rate, even after Alter’s own retries, the call fails with:

details['error']HTTPretry_after_secondsMeaningRecovery
service_unavailable5035Alter’s credential store was temporarily unavailable. The message is “Credential storage temporarily unavailable”. Nothing about the grant or the stored credential changed.Retry after the interval. Do not reconnect the account or replace the credential.

A failure a retry cannot fix (a stored credential Alter cannot read back) is a 502 instead, with no retry hint: credential_unavailable, or credential_storage_corrupted for a managed-secret credential read by request(). Contact support; retrying does not help.

Any call can fail with HTTP 503 and details['error'] / details.error set to database_unavailable, raised as a plain BackendError with details['retry_after_seconds'] / details.retry_after_seconds set to 2. Alter lost its database connection while it was handling the request, for example during a database failover, or the database ended the session (a failover shutdown, a killed session). The message is “The database connection was lost while handling the request. A read can be retried. A write may or may not have been applied; check its result before retrying it.”

The connection can be lost after the request’s change was saved, or while it was being saved, so the error does not say whether a change was applied:

  • A read changes nothing. Retry it after the interval.
  • A call that changes something may already have taken effect. Check its result before retrying it: list or fetch the resource a create would have made, and after a mint look for the new key and revoke one you do not keep. Retrying a create or a mint blind can leave a duplicate.

Neither SDK retries it on its own, except that await_approval() / awaitApproval() keeps polling through it as through any transient 503. The alter CLI retries it for reads and for PUT, PATCH and DELETE requests, which set state, and never for POST requests (retries).

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. It is also raised for a non-JSON 429 from an intermediary (see Intermediary limits below), where scope is unknown and retry_after may be unset.

Version compatibility: BlockedDestinationError, and RateLimitError for a non-JSON 429, require an SDK release after 0.27.0 (alter-sdk / @alter-ai/alter-sdk). Version 0.27.0 and earlier raise a generic BackendError for both responses.

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.

Intermediary limits. An edge or shared-IP limiter can reject a request before the API-key limiter runs. A remaining API-key budget therefore does not prove that every applicable budget remains available. If that layer returns a non-JSON 429, both SDKs raise RateLimitError, retain HTTP status 429 in details.status_code, and parse Retry-After when present. scope, limit, and window_seconds stay None (undefined in TypeScript): the SDK does not invent an API-key limit or attribute an unidentified response to an edge provider. Missing or invalid retry guidance also stays unset; use bounded backoff appropriate to your operation. No raw intermediary response body is exposed on the exception. A last-observed API-key snapshot describes the origin’s last reported budget, not the unidentified limiter’s remaining capacity.

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.

The SDKs absorb a rate limit instead of raising it only inside their two polling loops. A poll already owns a deadline, and by the time it runs the user may already have authorized — so raising there would strand a completed authorization behind a transient throttle. The sign-in poll (poll_auth_session / pollAuthSession, and authenticate()) treats a throttle like any other transient response and retries at its own poll interval until its timeout. The headless Connect poll (poll_connect_session / pollConnectSession, and the polling half of connect()) waits for the interval the service asks for and keeps polling, surfacing ConnectTimeoutError with reason "rate_limited" only if the entire timeout is consumed. Every other method hands the caller the number to sleep.

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.

A 503 whose error is service_unavailable on an API-key- or personal-access-token-authenticated call, or on minting a key, an agent key or a personal access token, means Alter could not verify or issue credentials at that moment. It is transient and says nothing about the credential itself: the response carries Retry-After: 15, the SDKs raise BackendError with status_code 503 and details["retry_after_seconds"] set, and the CLI retries read commands after that interval on its own. Retry after the interval; do not replace the key. The same code on request() or proxy_request() / proxyRequest() with a 5-second interval and the message “Credential storage temporarily unavailable” means the stored credential could not be read at that moment; see credential-store outages.

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. A key created before the September 2026 key-security upgrade also raises this, with an explanatory hint; it can no longer authenticate — create a new key (details).
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 a call that names a target agent — Connect session creation (agent, delegated_agent_id / delegatedAgentId), the recovery-session helper and delegate() — when that agent is not an active agent of the app (details.error == "agent_not_found"), and 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 every call from a paused agent’s key — credential calls, list_grants(), delegate(), Connect session minting, key derivation, catalog reads — with deliberate exceptions: agent.me() keeps working so a paused workload can self-diagnose, and telemetry ingestion (span emission and the SDK’s automatic provider-call audit reporting) keeps flowing so pausing an agent never blinds its audit trail. Suspension is containment: a paused agent cannot enumerate its grants or acquire new access.
AgentRevokedErrorRecreate the terminally revoked agent and issue new keys. Same whole-surface 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 call — request() / 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 slug is not in the agent’s scope allowlist. Widen the allowlist on the agent. The allowlist is a ceiling, never a binding: a grant the agent holds no delegation for is the opaque not-found (GrantNotFoundError) on every surface, including revoke, whatever the allowlist says.
legacy_managed_secret_name_rejected422BackendErrorAn agent create or update added an entry under the legacy managed_secrets allowlist key, which matches secrets by display name. List each secret’s slug under managed_secret_slugs instead; err.details['hint'] / err.details.hint names the key. Entries an agent already stores under managed_secrets may be kept or removed. Nothing was saved.
invalid_scope_constraint422BackendErrorcreate_connect_session(..., agent=..., scope_constraint=[...]) named scope atoms outside every allowed provider’s effective scopes for the session — a delegation can only narrow, never widen. When only some providers are affected the session is created and scope_constraint_warnings names them instead. Narrow the constraint to a subset of the provider’s scopes, or widen the app’s configured scopes.
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.

Creating a Connect session that delegates to an agent, and approving it, can be refused with these codes. no_eligible_grants arrives as ConnectConfigError and agent_not_found as AgentNotFoundError; the rest arrive as BackendError. Switch on details['error'] (Python) / details.error (TypeScript). Only service_unavailable is transient; for every other code, retrying the same request gets the same answer.

details['error']HTTPRaised byMeaningRecovery
agent_cannot_delegate_to_other_agent400OAuth and managed-secret session creation with an agent’s own keyThe request named a different active agent of the app as the delegation target. An agent’s key may request a delegation only for itself.Omit the target (agent on OAuth; the Agent method for managed secrets sends none) so the session is for the calling agent, or create the session with an app key to name another agent.
agent_requires_user_token400OAuth session creationagent was set without a user token, so no user could consent to the delegation.Pass the user’s JWT as user_token / userToken, or configure a user-token getter.
delegated_agent_id_required422Managed-secret session creation with a key bound to no managed agent (an app key, or an agent-typed alter_ak_… key from API Keys)The request named no agent. Only an agent’s own key may leave the target out. The App method always sends it; the cause is the Agent method on an Agent constructed directly from such a key.Send delegated_agent_id, use App.get_agent() / App.getAgent() for an application key, or ask from the agent with the managed agent’s own key (alter agents mint-key).
agent_not_found404OAuth and managed-secret session creation, and delegate()The named agent is not an active agent of this app (unknown, paused or revoked). Raised as AgentNotFoundError. On session creation it is reported before the identity provider’s readiness is checked.Correct the agent id or name, or resume the agent.
no_eligible_grants409Managed-secret session creation with a key bound to no managed agent (an app key, including an Agent from App.get_agent() / App.getAgent(), or an agent-typed alter_ak_… key from API Keys)The user holds no credential this delegation could use, so nobody could approve it. No session was created. details['details']['reason'] / details.details.reason is no_grant, grant_expired, delegation_depth_exceeded, group_source_not_allowed or agent_not_allowlisted. A managed agent’s own key never receives it.Fix the named cause (reasons table), then create a new session.
service_unavailable503OAuth and managed-secret session creation with a user tokenThe user token verified, but its user could not be synchronized at that moment (a transient lock or database fault); nothing was committed and no session was created. Arrives as BackendError with status_code / statusCode 503 and details['retry_after_seconds'] / details.retry_after_seconds from the Retry-After header.Retry after the interval.
identity_provider_inactive409OAuth and managed-secret session creation with a user tokenThe app’s identity provider is disabled, so its users cannot be synchronized. BackendError with status 409 and no retry hint.An administrator re-enables the identity provider. Retrying does not help until then.
identity_group_name_ambiguous409OAuth and managed-secret session creation with a user tokenTwo groups of the app’s identity provider share a display name the token’s group claim names, so the user’s memberships cannot be resolved. BackendError with status 409 and no retry hint.An administrator renames or merges one of the groups. Retrying does not help until then.
invalid_origin400OAuth and managed-secret session creationallowed_origin is not a single origin: it has a path, query, fragment or credentials, a wildcard host, is *, empty or has no scheme, uses http:// on a non-loopback host in production, or is longer than 300 characters. Managed-secret session creation formerly answered *, an empty value and a value without a scheme with a request-validation 422, and both session routes answered a value over 300 characters that way; they now return this code for them too.Pass the page’s exact origin, such as https://app.example.com, or omit the parameter.
agent_allowlist_exceeded422Managed-secret delegation approval on the hosted Connect page, and delegating from the WalletThe agent’s capability allowlist no longer names the secret, for example because an administrator narrowed it after the page loaded. Nothing was delegated. The user sees this on the page; it is not sent to the application. An SDK that receives it raises BackendError.An administrator adds the secret to the agent’s allowlist, or the user picks another agent. Distinct from the runtime 403 agent_scope_not_allowed, which refuses a call an agent makes.
delegation_methods_exceed_parent422Delegating from the Wallet with Allowed request methodsThe user asked to let the agent use an HTTP method their own access does not allow. A delegation can only narrow the methods it hands on. Nothing was delegated. The user sees this in the Wallet; it is not sent to the application.Choose only methods the user’s access allows (the dialog offers exactly those after it reloads).
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. One cause is credential_slot_changed: after approval the grant’s credential settings started filling a header or query parameter the approved request sets, so it cannot run as approved.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.

Before an approval exists, the request that would create it can be refused with a typed status. These arrive as BackendError (not an ApprovalError — there is no approval_id yet); switch on details["error"] / details.error:

errorStatusMeaningRecovery
too_many_pending_approvals429The grant is at its pending-approval cap.Wait for existing approvals to be decided or expire; the response carries Retry-After.
approval_config_error422HITL is enabled but no approver can be resolved — for example, an OAuth approval rule with no approvers on a connection no end user connected, or a managed-secret grant with no approvers.The app administrator names an explicit approver on the approval rule; retrying does not help.
credential_slot_in_request422The request sets a header or query parameter where Alter places the grant’s credential (details["fields"] / details.fields names each one, such as query.api_key or header.x-tenant-token). The approver could never see that value, so no approval was created.Remove those fields; Alter supplies the credential after approval. (A call that does not require approval is not refused: those fields are removed before the credential is added.)
approval_request_unsupported422The approver could not be shown this request in full, so its approval could never be approved. Applies to a request no operation catalog describes, where the approver sees every value in full, however long and however many: at most 1,000,000 characters of field names and values together (counted on the values themselves, not their display; a query value shown in its encoded form is counted on what it decodes to), nesting at most 32 levels, no two fields shown under the same name, and header names that are HTTP tokens. details.field_detail is always present and names the field and the reason, never a value. For a catalogued operation it applies whenever the operation’s parameters cannot be read because of the request itself: a body that is not a JSON object, a Content-Type the operation is not read from, or a field that is missing or of the wrong type. No approval was created. (If the catalog itself cannot read an operation, whatever is sent, the approval is still created and the Wallet marks it not approvable.)Send a request within those bounds, or one that has the shape the operation expects, or ask the administrator to route the operation without approval.
invalid_url400Alter cannot send the request URL (for example, a host name that is not a valid internationalized domain name). Every proxied call with such a URL is refused with this error, whether or not it requires approval, before quota, policy, credential or approval work (see invalid_url for the full order), so no approval is created.Fix the URL; retrying it unchanged gets the same answer.
approval_freeze_failed503The request payload could not be captured for the approver; nothing was created.Transient. Retry after Retry-After (details.retry_after_seconds).
approval_creation_contended503Approval creation for this grant was contended and timed out; nothing was created.Transient. Retry after Retry-After (details.retry_after_seconds); the retry is safe because no approval was persisted.

Both 503s are safe to retry as-is: the refusal happens before any approval row or approver notification exists, so a retry can never duplicate an approval.

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.
ConnectDeniedErrorThe user refused authorization and the session had nothing left to try. A session that can still continue — two or more allowed providers, a grant already authorized in this session, or an account the user has already connected and can still approve — returns the user to the provider picker and stays pending instead, so this is not raised.Respect the decision; start another flow only when the user chooses to retry. Sessions that can still continue end in a later terminal state, or in ConnectTimeoutError if the user abandons the flow.
ConnectConfigErrorOAuth client is misconfigured (wrong redirect URI, invalid client ID/secret), fresh provider authorization is unavailable (provider_configuration_unavailable / shared_dev_credential_unavailable), the scopes the app requests exceed what Alter’s managed OAuth client is approved for (managed_oauth_scope_approval_required), or — on managed-secret session creation — the app’s identity provider cannot sign users in to approve the delegation (idp_signin_not_configured) or, for a key bound to no managed agent, the user holds no credential the delegation could use (no_eligible_grants).For a misconfiguration or an unavailable provider, ask the app administrator to correct the provider configuration or availability, then create a fresh session. For managed_oauth_scope_approval_required the app administrator cannot fix it: the approval ceiling sits on Alter’s managed OAuth client, and the Dev Portal offers only the already-approved scopes. Contact Alter to have the requested scopes approved, or narrow the app’s configured scopes to the approved set, and only then create a fresh session. For idp_signin_not_configured, read the reason at details["details"]["reason"] / details.details.reason: missing_client_id or missing_client_secret — the app administrator adds the sign-in client ID and secret to the identity provider; issuer_cannot_sign_in — the administrator switches to an issuer that supports OpenID Connect sign-in (for WorkOS, the AuthKit issuer https://<slug>.authkit.app). Both responses carry retryable: false — every fresh session fails identically until the configuration changes. For no_eligible_grants, read the reason at details["details"]["reason"] / details.details.reason and fix that cause (Delegation request codes) before creating a new 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", "session_expired", or "rate_limited".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. A user who signed in to the provider but closed Connect without confirming also ends here; when the Alter deployment holds new connections until Confirm, no connection from that session is usable. For "rate_limited" the authorization may well have succeeded — re-poll the same token with a fresh timeout, and raise the key’s per-minute limit if Connect flows run concurrently.

@alter-ai/connect exports the AlterError interface, not exception classes:

interface AlterError {
code: string;
message: string;
details?: Record<string, unknown>;
failedGrants?: ConnectFailedGrant[];
}

Two launch-time codes are specific to the inline (embedded) flow and reject the open() promise rather than reaching onError: invalid_container (the container option is not an element attached to the document) and embed_unsupported_origin (the host page has no http(s) origin, or is itself inside an iframe). One inline flow code reaches onError: embed_load_timeout, when the frame delivers no message within 15 seconds of mounting. See the Connect SDK types reference.

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, embed_load_timeout, grant_policy_application_failed, and grant_activation_failed; errors posted by the hosted flow preserve their server-supplied code, so that set is intentionally open. In inline mode the hosted page posts its bind refusals with embed_origin_mismatch, embed_not_allowed, session_expired, embed_verify_failed, or cookies_required, and renders the error in place; a 409 embed_mode_conflict (the session already continued in a new window) is not an error, and the widget offers that window again.

  • For connect_denied, respect the provider decision. This code is terminal only when the session has nothing left to try — which includes a multi-provider picker whose every offered provider the user has now declined; a session that can still continue returns to the picker instead of emitting an error, and reports the refusal as a non-terminal provider_declined event so the application still learns about it without the Connect UI closing. Server-side pollers observe the same refusals: poll_connect_session() / pollConnectSession() accept an on_event / onEvent callback fired per declined provider, and a timeout after denials lists them in ConnectTimeoutError.details. A provider failure that was not a refusal — an outage, a rotated client credential — reports as provider_error on that same channel, so do not treat every non-terminal notice as a user decision.
  • For popup_blocked, ask the user to allow the popup and retry.
  • For embed_load_timeout, the inline frame never reported in: confirm that the host page’s origin equals the session’s allowed origin, that the page is not itself inside an iframe, and that the Alter host is reachable, then mint a fresh session and retry.
  • For an inline bind refusal (embed_origin_mismatch, embed_not_allowed, session_expired, embed_verify_failed, cookies_required), the widget has already rendered the error in place; mint a fresh session with the correct allowedOrigin rather than reopening the same one.
  • 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.
  • For grant_activation_failed, no usage limit was at fault: every selected connection could not be completed when the user confirmed it (for example, the same account was connected again in a newer Connect session). Start a new connection. Server-side, the same outcome raises ConnectFlowError with details.error_code set to grant_activation_failed; grant_policy_application_failed is the usage-limit counterpart.

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.
75BUSYNothing was saved or applied because a concurrent change held a lock. Retry the same command unchanged, with backoff; after a busy policy apply, do not re-plan or edit the plan file.

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).

For CredentialRevokedError, the recovery helper also carries the error’s grant_id as the Connect repair target. Reconnecting the same provider account replaces the broken shared credential, preserves the existing connection instead of adding a duplicate, and reports operation="reauth" (operation: "reauth" in TypeScript). Sibling connections sharing that credential pick up the replacement on their next call.

Repairing a specific connection is an optimization, not a requirement. An error raised by a backend predating this contract carries no connection id, so the helper names no target and mints an ordinary session. Pass user_token (userToken) so the session can address a connection that belongs to an end user — without it only application-owned connections are addressable. If the named connection is rejected when the session is created — it is not addressable by this caller, or it is no longer active — the helper falls back to an ordinary Connect session, so reconnecting always remains possible. That fallback session usually reports operation="creation", but it follows the standard rules, so it still reports operation="reauth" if it ends up restoring an existing connection for the same account.

One connection shape is not repaired in place by an ordinary reconnect: a connection whose default grant is currently delegated to an agent. Reconnecting it without naming a target restores the connection but returns a new connection id, because silently re-pointing a handle an agent is actively delegating from would change that agent’s access rather than repair anything. Passing the error to the recovery helper (which names the target explicitly) preserves the id in that case too — another reason to prefer it over an unaddressed reconnect.

The fallback covers rejection at session creation only. If the named connection is accepted and then stops being active before consent completes, the flow fails rather than quietly connecting something else.

Reconnecting must use the same provider account. If a different account authorizes, or the provider returns no verifiable account identity, the session fails rather than silently rewriting the credential behind the existing connection.

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. Check candidates_truncated before telling a user their grant is missing: when it is true every disambiguator on the error (candidates, account_identifiers, app_user_ids, grant_ids) is a bounded sample, and the right move is to narrow the request or list grants rather than report “not found”. 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.

BlockedDestinationError is a BackendError subclass raised when a proxy call’s destination fails the provider’s host/path allowlist or the network security check (HTTP 422). When a call held for approval is refused as the approved call executes, the approval wait raises ApprovalExecutionFailedError instead. Correct the host or URL; retrying the same destination will not fix a policy refusal. Read details["error"] / details.error for blocked_destination and details["request_id"] / details.request_id for the response correlation ID when available. The exception preserves HTTP status without disclosing resolved private addresses. With the destination allowlist enforced (the default), the allowlist is checked before any DNS lookup: a host outside the grant’s allowed hosts is refused with blocked_destination without being resolved, so the two DNS codes below are only ever returned for a host the call may reach. A host name that does not exist remains a separate dns_resolution_failed diagnostic (HTTP 400, a plain BackendError; correct the host), and a resolver that timed out or failed temporarily is the retryable 503 dns_resolution_timeout. No provider HTTP request was made in any of these cases.

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.