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 outCatching by action
Section titled “Catching by action”The hierarchy is built so that catching a parent class handles every leaf that requires the same response.
- “The user needs to re-authorize.” Catch
ReAuthRequiredError. Triggers: revoked grant, expired grant, broken credential, deleted grant. Response: show the Connect widget. - “The user needs to re-authorize with a wider scope.” Catch
ScopeReauthRequiredError(subclass ofProviderAPIError). Trigger: the provider returned 403 and the grant’s stored scopes don’t cover the route. Response: show the Connect widget; the new authorization upgrades the grant in place. - “The provider rejected the credential.” Catch
ProviderUnauthorizedError(subclass ofProviderAPIError). Trigger: the provider returned 401 on arequest()call — the credential was revoked, expired, or otherwise invalidated provider-side. One exception: a 401 whoseWWW-Authenticatechallenge carrieserror="insufficient_scope"(RFC 6750) means the credential is still valid but lacks a scope, so it surfaces as a genericProviderAPIErrorinstead. Response: for an OAuth grant, show the Connect widget (a new Connect session re-authorizes the grant); for a managed-secret grant, update the stored secret with a valid value. - “The agent has no access to this provider.” Catch
NoDelegatedGrantError. Trigger: an agent calledrequest(provider=...)and no delegation or agent-owned managed-secret grant resolved. Response: prompt a user to delegate, or issue a managed-secret grant to the agent. - “The agent passed a grant_id it can’t reach.” Catch
AgentDelegationMissingError(a subclass ofGrantNotFoundError, so an existingGrantNotFoundErrorcatch still fires). Trigger: an agent calledrequest(grant_id=...)/proxy_request(grant_id=...)and the grant is not delegated to this agent — or a user/basegrant_idwas passed where the agent’s own delegation id (fromlist_grants) was required. Response: delegate the agent via Connect, or resolve by provider (omitgrant_id) to route through the agent’s existing delegations. - “The user session is too old for this operation.” Catch
StepUpRequiredError. Trigger: acontent_matchstep-up obligation requires a fresher identity session. Response: re-authenticate the user, obtain a fresh user session, and retry. - “Required request redaction could not be applied.” Catch
RedactDischargeFailedError. The provider request was denied before forwarding. Response: fix the request body shape or the rule’sredact.fields; retry only after the mismatch is corrected. - “This grant cannot be delegated onward.” Catch
GrantNotDelegableError. Response: when a managed-secretdelegatecall passeddelegableasTrue/trueand the message says the new child has no further hop, the held grant is already delegable, so call again withdelegableomitted orFalse/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 whenscope_version_mismatchis true; otherwise checkrequired_any_of— when true, any ONE scope inmissingsatisfies the route (for example the approval status poll acceptsapprovals:readorproxy:execute), so add a single one rather than all of them; when false, use a key whose explicit scopes include every entry inmissing. - “A transient backend condition.” Catch
TokenRefreshInProgressError. Trigger: a parallel refresh holds the lock. Response: retry with backoff. - “The quota window is exhausted.” Catch
QuotaExceededError. Trigger: a request-quota policy denied the call — the fixed window’s limit is spent. Response: waitretry_afterseconds (the time until the window resets), then retry. The SDKs never auto-retry this error — retrying before the window resets is denied again. - “You are sending requests too fast.” Catch
RateLimitError. Trigger: the platform rate limit for the API key (or the organization-wide aggregate, when one is configured) is exhausted — a transient throttle, not a denial. Also raised for a non-JSON 429 from an intermediary such as an edge provider; then the limiting layer is unknown,scopeis unset andretry_afteris set only when the response carried aRetry-Afterheader. Response: waitretry_afterseconds 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 fromQuotaExceededError(a policy denial — retrying inside the window is denied again) and from a billing-period cap, which stays a genericBackendError. - “The requested permissions need Alter’s approval.” Catch
ConnectConfigErrorand check the error code formanaged_oauth_scope_approval_required(details["error"]/details.erroron a create-session failure,details["error_code"]/details.error_codeon 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 isretryable: falseand 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
ConnectConfigErrorand checkdetails["error"]/details.errorforidp_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 atdetails["details"]["reason"]/details.details.reason:missing_client_idormissing_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 issuerhttps://<slug>.authkit.app). Response: never retry — the response isretryable: falseand 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 reportno_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
ConnectConfigErrorand checkdetails["error"]/details.errorforno_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-typedalter_ak_…key from API Keys), for a user who holds no credential that could be delegated to the agent.details["details"]["reason"]/details.details.reasonnames 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(aBackendErrorsubclass). Trigger:Agent.create_managed_secret_connect_session()/agent.createManagedSecretConnectSession(), ordelegableon 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 (catchTimeoutErrorseparately if a different retry profile is needed for timeouts vs connection refused).
Selected exception details
Section titled “Selected exception details”MemoryIsolationError
Section titled “MemoryIsolationError”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.
code | Meaning | Recovery |
|---|---|---|
memory_identity_unverified | The 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_required | A resolved snapshot was bound to a ScopedMemoryStore without a resolver. | Pass refresh_identity / refreshIdentity, or bind an assertion-backed identity. |
memory_refresh_not_live | The resolver returned an identity minted before the operation started (a cached or memoized result). | Make the resolver call Alter on every invocation. |
memory_identity_expired | The assertion-backed identity is past its signed expiry. | Obtain and verify a fresh assertion. |
memory_assertion_invalid | The 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_required | No authenticated agent is bound (an application-only identity). | Resolve or assert through an agent key. |
memory_user_required | User mode was requested for a headless identity. | Pass agent_only=True / { agentOnly: true } for a headless agent. |
memory_user_not_active | The represented user is not active. | Deny; a suspended user cannot open memory until reinstated. |
memory_user_cannot_be_dropped | Agent-only mode was requested for an identity that represents a user. | Use user mode; agent-only mode never drops a user. |
memory_identity_changed | The 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_violation | The 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.
AmbiguousGrantError
Section titled “AmbiguousGrantError”Raised when provider-based resolution matches more than one grant. Alter never silently picks a grant — there is no “default grant” or “most recently connected wins” behavior. The error enumerates the candidates so the caller (or the end user, conversationally) chooses explicitly. The candidates are always scoped to the caller’s own accessible grants — the error can never reveal a grant the caller could not use.
| Attribute | Description |
|---|---|
provider_id | The provider whose grants matched. |
candidates | One entry per matching grant: grant_id and label (the sibling address — retry with provider + label), plus, where the caller may see them, account_identifier and account_display_name. Retry with the chosen grant_id — the universal disambiguator. |
account_identifiers | List of matching accounts; show to the user to pick. Bounded when candidates_truncated is true. |
account_was_provided | True if account= was already passed and still produced an ambiguity (sign of a mis-set account). |
app_user_ids | (Agent flavor) UUIDs of the users with matching delegations. 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_truncated | True 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.
RestrictedGrantRequiresProxyError
Section titled “RestrictedGrantRequiresProxyError”Raised when raw-token retrieval is attempted against a grant whose policy
carries method/endpoint restrictions. Restricted grants are proxy-only by
design — a token handed to the caller could not be held to the restriction —
and there is no opt-out. Switch the call to the SDK’s proxied execution path
(proxy_request() / proxyRequest()); restriction denials there surface as
PolicyViolationError.
GrantNotDelegableError
Section titled “GrantNotDelegableError”Raised when agent.delegate() tries to delegate a grant that does not permit
onward delegation. Existing grant authority cannot be widened by retrying the
same call. 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
delegableset toTrue/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.
AgentDelegationMissingError
Section titled “AgentDelegationMissingError”Raised on the agent path when a grant_not_found 404 comes back for an
explicit grant_id. A subclass of GrantNotFoundError, so existing
except GrantNotFoundError / instanceof GrantNotFoundError handlers keep
catching it unchanged. For an agent caller the bare 404 is ambiguous — the
grant may exist but not be delegated to this agent, or a user/base grant_id
was passed where the agent’s own delegation id (from list_grants /
listGrants) was required (an agent can only address its own delegation by
id). The App/operator path is unaffected — there grant_not_found genuinely
means “no such grant / not owned”, so it stays a plain GrantNotFoundError.
Inherits GrantNotFoundError’s recovery-context fields (provider_id /
agent_id / app_user_id, populated only on identity-mode raises). Recover by
delegating the agent through Connect (create_connect_session /
createConnectSession), or resolve by provider (omit grant_id) so the call
routes through the agent’s existing delegations.
SiblingLabelConflictError
Section titled “SiblingLabelConflictError”Raised by mint_grant() / mintGrant() when the requested label is already
held by an active grant on the same connection. Carries label. Pick a
different label, or revoke the holder first — revoking a grant frees its
label.
InsufficientScopeError
Section titled “InsufficientScopeError”Raised when the calling key — possibly after per-call attenuation — lacks a scope the backend route requires.
| Attribute | Description |
|---|---|
required | Scopes the route requires. With required_any_of, alternatives — any one suffices. |
granted | Scopes the key has, post-intersection with constraints. |
missing | The unsatisfied scopes. Without required_any_of: required \ granted, all needed. With it: every accepted alternative — add any one. |
scope_version | Catalog version the key was minted against. |
current_scope_version | Catalog version the server is on. |
scope_version_mismatch | True iff the missing scope only exists at the server’s version — rotate the key. |
documentation_url | Per-scope docs link. |
required_any_of | True 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. |
ScopeReauthRequiredError
Section titled “ScopeReauthRequiredError”Raised when the provider returns 403 and the grant’s stored scopes don’t cover the route.
| Attribute | Description |
|---|---|
grant_id | The grant that needs re-authorization. |
provider_id | The provider. |
missing_scopes | When parsed from a WWW-Authenticate: insufficient_scope challenge, the specific scopes missing. None when the source was a pre-flight backend flag. |
status_code, response_body | The provider’s raw response. |
Pattern:
try: await app.request(...)except ScopeReauthRequiredError as e: session = await app.create_connect_session(allowed_providers=[e.provider_id]) notify_user(session.connect_url)ProviderUnauthorizedError
Section titled “ProviderUnauthorizedError”Raised when the provider returns 401 to a request() call — the provider rejected the credential (revoked, expired, or otherwise invalidated provider-side). The 401 sibling of ScopeReauthRequiredError; both extend ProviderAPIError, so existing ProviderAPIError handlers keep catching it. A 401 whose WWW-Authenticate challenge carries error="insufficient_scope" (RFC 6750) is deliberately excluded — the credential is still valid, it just lacks a scope — and surfaces as a generic ProviderAPIError instead.
| Attribute | Description |
|---|---|
grant_id | The grant whose credential was rejected. |
provider_id | The provider. |
status_code, response_body | The provider’s raw response. |
Recovery depends on the grant family:
- OAuth grant — re-authorize via a new Connect session (same pattern as
ScopeReauthRequiredError:create_connect_session(allowed_providers=[e.provider_id])and surface the URL to the user). - Managed-secret grant — the stored secret is no longer accepted by the provider; update it with a valid value.
PolicyViolationError
Section titled “PolicyViolationError”Raised when a policy denies the call.
| Attribute | Description |
|---|---|
policy_error | Backend-supplied identifier for the violated policy (e.g., outside_business_hours, ip_not_allowed, rate_limit_exceeded). |
May resolve on its own (time-of-day window opens, rate limit decays). One
policy denial does not raise this class: a request-quota window denial
raises QuotaExceededError instead, which carries the
seconds until the window resets.
StepUpRequiredError
Section titled “StepUpRequiredError”A typed PolicyViolationError for a content_match step-up obligation. Its
policy_error / policyError is always step_up_required, and
max_session_age_seconds / maxSessionAgeSeconds carries the rule’s freshness
ceiling when supplied. Re-authenticate the user to obtain a fresh identity
session, then retry the original request.
RedactDischargeFailedError
Section titled “RedactDischargeFailedError”A typed PolicyViolationError raised when a content_match redact obligation
could not safely transform the request body. Its policy_error /
policyError is always redact_discharge_failed. The request fails closed and
is not forwarded to the provider. Correct the request shape or the rule’s
redact.fields before retrying.
invalid_url
Section titled “invalid_url”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.
invalid_header_name
Section titled “invalid_header_name”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.
query_too_long
Section titled “query_too_long”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
urlwith 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.)
query_params_unmergeable
Section titled “query_params_unmergeable”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.
Provider-call and credential-store codes
Section titled “Provider-call and credential-store codes”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'] | HTTP | retry_after_seconds | Meaning | Recovery |
|---|---|---|---|---|
provider_timeout | 502 | Not set | The 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_unreachable | 502 | Not set | Alter 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_unavailable | 503 | 1 | Recent 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_busy | 503 | 1 | Too 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_limit | 503 | 1 | The 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_exhausted | 503 | 1 | Alter 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_timeout | 503 | 5 | The 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. |
overloaded | 503 | 1 | The 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'] | HTTP | retry_after_seconds | Meaning | Recovery |
|---|---|---|---|---|
service_unavailable | 503 | 5 | Alter’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.
database_unavailable
Section titled “database_unavailable”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).
QuotaExceededError
Section titled “QuotaExceededError”Raised when a request-quota policy rule’s window limit is exhausted. This is a policy denial, not a transient outage — retrying before the window resets is denied again, so the SDKs never auto-retry it.
| Attribute | Description |
|---|---|
retry_after | Seconds until the current quota window resets, taken from the Retry-After response header when present and the body value otherwise, and capped at 2,678,400 seconds (31 days) (see the backoff ceiling). The cap is deliberately far larger than RateLimitError’s: a quota window is a calendar period, so a monthly quota legitimately means weeks, and clamping it to an hour would wake a caller ~700 times over a month instead of once. May be None (undefined in TypeScript) when the backend didn’t supply either. |
Recovery: wait retry_after seconds, then retry. A 429 relayed from the
provider itself (an upstream rate limit passed through the proxied call path)
is not a quota denial and surfaces as a generic BackendError instead.
RateLimitError and the platform rate limit
Section titled “RateLimitError and the platform rate limit”Raised when the platform rate limit is exhausted — a transient throttle, not a
denial: the same request succeeds once the window drains. Both SDKs surface
this as a typed RateLimitError. 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, andRateLimitErrorfor 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 genericBackendErrorfor 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 |
|---|---|---|
| yes | yes | the lower of the two — the maximum is a cap, never a boost |
| yes | no | the key’s own limit |
| no | yes | the organization’s maximum (it also serves as the default) |
| no | no | 1,000 per minute |
Across the organization — a policy can additionally set an aggregate limit
that every key in the organization shares. It is checked after a request
clears its own per-key limit, so exceeding it is reported separately with
scope: "organization". It is unset by default.
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 }}| Field | Description |
|---|---|
error | Always rate_limit_exceeded. |
scope | Which ceiling was hit: ip (a per-client-IP gate that fronts the unauthenticated surfaces, evaluated before any key is resolved), api_key (this key’s own limit), or organization (an organization-wide aggregate every key shares). |
limit | The limit that was exceeded, in requests per window — the ceiling named by scope, so on an organization rejection it is the organization aggregate, not this key’s limit. |
window_seconds | The window length in seconds (60). |
retry_after | Seconds to wait before retrying — the time until the next unit of budget frees up, not the whole window. Budget is replenished continuously rather than all at once when the window ends, so this is often a second or two even on a 60-second window. Honour it instead of sleeping window_seconds. |
message | Human-readable summary of the above. |
On the typed SDK exception, scope, limit and window_seconds /
windowSeconds are optional — not every response carrying this error code
supplies them — so check for a missing value before branching on one.
Response headers:
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying. The SDKs’ typed retry_after / retryAfter field prefers this header over the body value. |
RateLimit-Policy | Standard quota policy metadata (q quota and w window seconds) following the IETF RateLimit fields draft. |
RateLimit | Standard current service limit for the binding policy (r remaining and t seconds to the effective window end). |
X-RateLimit-Limit | The ceiling this response was metered against — never assume it is this key’s own limit. On a 429 it is the ceiling named by scope: the key’s limit for api_key, the organization-wide aggregate for organization. On a success it is the ceiling the reported X-RateLimit-Remaining belongs to: the key’s own limit, or the organization aggregate when that is the tighter of the two. |
X-RateLimit-Remaining | Requests remaining against the binding constraint — the lower of what is left on this key and, when an organization aggregate is configured, what is left on that aggregate. 0 on a 429. |
X-RateLimit-Reset | Unix timestamp at which the current rate-limit window resets — the same meaning the header carries on GitHub and Stripe. It is not a per-request retry instant: Retry-After is the authoritative wait for the rejected call. Sleep until Retry-After to retry one call; read X-RateLimit-Reset to know when the full budget returns. |
X-Alter-Rate-Limited-Scope | ip, api_key, or organization — the same value as the body’s scope. Present only on this limit’s 429s — it distinguishes a platform throttle from other 429 responses (a quota policy denial or a billing-period cap). |
The standard pair and compatibility X-RateLimit-* headers also ride on
successful responses to SDK calls, so a well-behaved client can throttle
itself before it ever sees a 429. They are not attached to every Alter endpoint
— only to requests this limit meters.
Treat their absence as unknown, never as unlimited: a response without them
says nothing about how much budget is left, and a client that reads “no headers”
as “no limit” will keep hammering the very endpoint that stopped reporting.
Both SDKs parse the compatibility triple, or the standard pair when the triple
is absent, into a frozen snapshot and expose the latest reading as
last_rate_limit / lastRateLimit, on the client and on App / Agent alike.
It is updated by every call the limit meters — successful calls included — so
the self-throttling above is something an application can actually implement:
import asynciofrom datetime import datetime, timezone
await app.request("GET", url, grant_id=grant_id)
budget = app.last_rate_limitif budget is not None and budget.remaining < 10: # reset_at is an aware UTC datetime read from the server's clock. await asyncio.sleep((budget.reset_at - datetime.now(timezone.utc)).total_seconds())await app.request("GET", url, { grantId });
const budget = app.lastRateLimit;if (budget !== null && budget.remaining < 10) { await new Promise((r) => setTimeout(r, budget.resetAt.getTime() - Date.now()));}The snapshot carries limit, remaining and reset_at / resetAt, with the
same meanings as the headers — including the caveat that they describe the
binding constraint, which may be the organization aggregate rather than this
key’s own ceiling. It is None / null until a metered call completes, and
stays at its last reading when a later response carries no headers: absence is
unknown, so the value is never silently cleared.
The alter CLI surfaces the same reading on its metered commands:
alter sdk-passthrough resolve-identity includes a rate_limit object in its
JSON output (null when the backend reported none), and
alter sdk-passthrough request prints an alter-rate-limit: line to stderr
alongside the provider’s own response headers.
Recovery: wait retry_after seconds and retry the same request. If scope is
api_key, slow this key down or spread load across keys; if organization,
every key in the organization shares one budget, so slowing a single caller
may not be enough — raise the ceiling or reduce aggregate load.
The typed scope field stays a plain string rather than a closed enum, so a
ceiling added in a later release does not become an unparseable response for an
application pinned to an older SDK. Both SDKs export the currently known values
as RATE_LIMIT_SCOPE_VALUES (the ordered values ip, api_key, organization,
outermost-first), so a handler can branch on the shared constant instead of a
hand-typed literal — and should treat an unrecognized value as
some ceiling was hit, never as malformed.
RateLimitError is raised the same way from every SDK surface, including the
managed-agent calls (agents.create(), agents.list(), …): the platform limit
meters every API-key-authenticated request, not only credential retrieval.
The 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.
The backoff ceiling
Section titled “The backoff ceiling”retry_after / retryAfter is capped, whether the value came from the
Retry-After header or from the response body. The documented recovery is to
sleep the value, and an unbounded number — from a malformed header, or from an
intermediate proxy that rewrote Retry-After — would park a worker for as long
as that number says with no way to recover.
The cap depends on which 429 it is, because the two describe different clocks:
| Error | Cap | Why |
|---|---|---|
RateLimitError | 3,600 seconds (one hour) | The platform’s own retry hint is bounded well below one hour. The larger SDK ceiling rejects corrupted or hostile guidance without clipping a legitimate platform response. |
QuotaExceededError | 2,678,400 seconds (31 days) | A quota rule’s window is a calendar period — minute, hour, day or month — and the response reports the longest window that is exhausted. A monthly quota legitimately means weeks. 31 days is the longest such window, so every real hint passes through untouched while a nonsensical value is still bounded. |
Honour the value you are given rather than a fixed sleep: on a monthly quota it is the difference between waking once and waking hundreds of times.
LastActiveKeyError
Section titled “LastActiveKeyError”Raised by agents.revoke_key() / agents.revokeKey() when revoking would leave the agent with zero non-revoked keys.
Recovery: mint a replacement first, deploy it, then retry the revoke. Or pass force=True / { force: true } to deliberately brick the agent.
AgentError family
Section titled “AgentError family”All inherit from BackendError. Each carries a stable code attribute (agent_not_found, agent_inactive, key_revoked, …). Switch on err.code (not on the message) when handling backend categories. KeyRevokedError and KeyAlreadyRevokedError deliberately share key_revoked; use the typed class when the distinction between authentication rejection and an invalid key-lifecycle transition matters.
Every backend-originated error also exposes the HTTP status as
status_code (Python) / statusCode (TypeScript). A 500/503 response with a
valid Retry-After header carries the bounded value in
details["retry_after_seconds"] / details.retry_after_seconds.
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.
| Error | Recovery |
|---|---|
InvalidKeyError | Replace 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). |
KeyExpiredError | Mint and deploy a key whose configured expiry is still in the future. |
KeyRotationExpiredError | Replace the rotated key with its active successor. |
KeyInactiveError | Replace the key or restore its supported active lifecycle state. |
KeyAppNotFoundError | Retry only after the owning application is restored; this normally indicates backend data corruption. |
AgentNotFoundError | Correct the agent id/name, or recreate an agent that no longer exists. Also raised by 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). |
CallerAgentMismatchError | Use the agent bound to the key, or use a key bound to the asserted caller agent. |
CallerAgentResolutionError | Retry after details.retry_after_seconds; persistent failures require operator investigation. |
AgentNameExistsError | Choose another name, or revoke the existing active agent before reusing it. |
AgentConcurrentUpdateError | Fetch fresh state, recompute the complete desired allowlist, and retry with the fresh version. |
MeRequiresAgentKeyError | Call me() from an SDK instance using the agent’s own key; app-key callers should fetch the agent through the app namespace. |
KeyRevokedError, KeyAlreadyRevokedError | Mint and deploy a replacement key; a revoked key cannot return to an active lifecycle state. |
AgentInactiveError | Have an operator resume the agent, then retry. Raised by 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. |
AgentRevokedError | Recreate 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. |
KeyNotFoundError | Correct the key id and verify that it belongs to the targeted agent. |
LastActiveKeyError | Mint and deploy a replacement first, or explicitly force the revoke when bricking the agent is intended. |
AgentKeyLimitError | The agent is at its per-agent active-key cap. Revoke a key it no longer uses, then retry the mint — the cap counts non-revoked keys, so a revoke frees a slot immediately. |
AgentCannotMintSubagentsError | Use an app key for agent-management operations. |
IdempotencyKeyBodyMismatchError | Replay the original body with that idempotency key, or use a fresh key for the changed operation. |
IdempotencyKeyAgentRevokedError | Use a fresh idempotency key to create a new agent. |
IdempotencyKeyAgentInactiveError | Resume the cached agent, or use a fresh idempotency key to create another agent. |
Agent codes without typed SDK wrappers
Section titled “Agent codes without typed SDK wrappers”A few backend agent codes have no dedicated exception subclass. They are still stable contracts. Which class carries them depends on the call that produced them, not on the status:
- A code raised by a credential call —
request()/proxy_request()— on a 403 arrives asPolicyViolationError. Branch onerr.policy_error(Python) /err.policyError(TypeScript), the dedicated field for exactly this. - A code raised by an agent-management call arrives as a plain
BackendError, whatever its status.
In every case err.details['error'] (Python) / err.details.error (TypeScript) also carries the code, so a single branch on details handles the whole table uniformly.
details['error'] | HTTP | Surfaces as | Meaning |
|---|---|---|---|
agent_expected_version_required | 422 | BackendError | A direct request supplied scopes without expected_version. The published SDKs reject this as AlterValueError before the network; fetch the agent and pass its current version. |
agent_scope_not_allowed | 403 | PolicyViolationError | The agent key called a route whose target provider / OAuth scope / managed-secret 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_rejected | 422 | BackendError | An 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_constraint | 422 | BackendError | create_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_corrupted | 500 | BackendError | An idempotency replay pointed at an agent or key that no longer exists. Retry with a fresh idempotency key; the underlying corruption is operator-investigable. |
An agent key that calls any agent-management operation — including reading another agent’s record — is refused with AgentCannotMintSubagentsError (see the table above); use an app key for management calls and agent.me() for self-introspection.
Delegation request codes
Section titled “Delegation request codes”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'] | HTTP | Raised by | Meaning | Recovery |
|---|---|---|---|---|
agent_cannot_delegate_to_other_agent | 400 | OAuth and managed-secret session creation with an agent’s own key | The 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_token | 400 | OAuth session creation | agent 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_required | 422 | Managed-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_found | 404 | OAuth 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_grants | 409 | Managed-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_unavailable | 503 | OAuth and managed-secret session creation with a user token | The 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_inactive | 409 | OAuth and managed-secret session creation with a user token | The 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_ambiguous | 409 | OAuth and managed-secret session creation with a user token | Two 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_origin | 400 | OAuth and managed-secret session creation | allowed_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_exceeded | 422 | Managed-secret delegation approval on the hosted Connect page, and delegating from the Wallet | The 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_parent | 422 | Delegating from the Wallet with Allowed request methods | The 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). |
Approval errors
Section titled “Approval errors”| Error | Meaning | Recovery |
|---|---|---|
ApprovalDeniedError | Approver clicked Deny. | The safe decision reason is the exception message; surface it to the requester. Use approval_id for correlation. |
ApprovalExpiredError | Window elapsed; no decision made. | Re-issue the call to create a new approval. |
ApprovalTimeoutError | SDK gave up waiting; the approval may still be pending. | Persist approval_id and re-poll. A transient polling failure is preserved as the exception cause. |
ApprovalExecutionFailedError | Approved, but the eventual provider call failed. 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.
Approval creation refusals
Section titled “Approval creation refusals”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:
error | Status | Meaning | Recovery |
|---|---|---|---|
too_many_pending_approvals | 429 | The grant is at its pending-approval cap. | Wait for existing approvals to be decided or expire; the response carries Retry-After. |
approval_config_error | 422 | HITL 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_request | 422 | The 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_unsupported | 422 | The 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_url | 400 | Alter 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_failed | 503 | The request payload could not be captured for the approver; nothing was created. | Transient. Retry after Retry-After (details.retry_after_seconds). |
approval_creation_contended | 503 | Approval 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.
Server SDK Connect flow errors
Section titled “Server SDK Connect flow errors”These exceptions belong to the Python and TypeScript server SDKs’ headless Connect helpers. They are not exported by the browser Connect package.
| Error | Meaning | Recovery |
|---|---|---|
ConnectFlowError | Generic flow failure, including an unavailable/expired token on the first poll, an unknown terminal state, or a total usage-limit application failure. | Inspect the safe message. For a total usage-limit failure, inspect details["failed_grants"] / details.failed_grants. An unavailable first poll deliberately does not disclose whether the session existed; create a fresh session. |
ConnectDeniedError | The 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. |
ConnectConfigError | OAuth 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. |
ConnectTimeoutError | Alter observed a pending session but received no completion callback before the local deadline or server expiry. details["reason"] / details.reason is "poll_deadline_elapsed", "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. |
Browser Connect SDK errors
Section titled “Browser Connect SDK errors”@alter-ai/connect exports the AlterError interface, not exception classes:
interface AlterError { code: string; message: string; details?: Record<string, unknown>; failedGrants?: ConnectFailedGrant[];}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-terminalprovider_declinedevent so the application still learns about it without the Connect UI closing. Server-side pollers observe the same refusals:poll_connect_session()/pollConnectSession()accept anon_event/onEventcallback fired per declined provider, and a timeout after denials lists them inConnectTimeoutError.details. A provider failure that was not a refusal — an outage, a rotated client credential — reports asprovider_erroron 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 correctallowedOriginrather than reopening the same one. - For
redirect_error, restore browser storage access and retry. - For
invalid_oauth_urlorinvalid_response, stop and report the malformed flow rather than treating it as success. - For
grant_policy_application_failed, inspectfailedGrants. A partial completion is not an error: it arrives throughonSuccess, with failures incompletion.failedGrants. - 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 raisesConnectFlowErrorwithdetails.error_codeset togrant_activation_failed;grant_policy_application_failedis 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.
CLI exit contract
Section titled “CLI exit contract”The alter CLI is an executable surface, not an exception-class API. Scripts
branch on its stable process status and use stderr only for diagnostics. The
CLI scripting reference is the
language-specific source of truth.
| Status | Name | Recovery |
|---|---|---|
0 | OK | Continue; the command succeeded. |
1 | ERROR | Inspect stderr; fix the reported runtime/server problem or retry a transient network failure. |
2 | USAGE | Correct the flag, argument, configuration, or input format. |
3 | AUTH | Run alter auth login to replace a missing, expired, or revoked PAT. |
4 | NOT_FOUND | Correct the resource identifier or handle absence as an expected script branch. |
5 | CONFLICT | Reconcile the current resource state or confirmation requirement, then retry. |
6 | RATE_LIMIT | Retry with backoff. |
7 | FORBIDDEN | Two causes, and the printed message says which: the PAT lacks a required explicit scope (re-mint or select one that has it — signing in again with the same scopes does not help), or the organization’s plan does not include the feature (upgrade the plan). |
8 | CANCELLED | Treat the operation as not performed; in automation, supply the command’s documented confirmation flag. |
75 | BUSY | Nothing 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. |
Recovering from missing-grant errors
Section titled “Recovering from missing-grant errors”When a request fails because the user hasn’t authorized the provider yet, the SDK exposes recovery context on the typed error so a re-consent flow can be driven without manual derivation:
NoDelegatedGrantErrorcarriesprovider_id/agent_id/app_user_id(camelCase in TypeScript).GrantNotFoundErrorcarriesprovider_id/agent_id/app_user_idwhen the raise was identity-mode.GrantExpiredErrorcarriesprovider_id/agent_id/app_user_idwhen the expiry was raised on the delegation path (a TTL-lapsed delegation chain); the root-path TTL expiry leaves them unset.CredentialRevokedErrorcarriesprovider_id/app_user_id(noagent_id— credential revocation is grant-scoped, not agent-scoped).
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; }}When recovery context is available
Section titled “When recovery context is available”| Error | Identity-mode raise (carries context) | Direct-mode raise (context is None) |
|---|---|---|
NoDelegatedGrantError | Agent-runtime resolution with provider= + optional user_token= | n/a — always identity-mode |
GrantNotFoundError | Identity-mode lookup by (user, provider) failed | Caller passed an explicit grant_id= that doesn’t exist |
CredentialRevokedError | Token refresh hit a permanent failure (provider revoked, refresh token expired) | n/a — always carries a grant_id |
GrantExpiredError | Delegation-chain TTL expiry can carry provider/agent/user context | Root-path TTL expiry leaves recovery context unset |
create_connect_session_for_error / createConnectSessionForError accepts
NoDelegatedGrantError, GrantNotFoundError (including its agent subclass),
and CredentialRevokedError. It raises AlterValueError when required
provider context is unavailable, and also when NoDelegatedGrantError lacks
its required agent context. For direct-mode GrantNotFoundError (a stale
grant_id), call create_connect_session /
createConnectSession directly with the correct provider. GrantExpiredError
exposes recovery context on delegation-chain failures but is not accepted by
the convenience helper; use the explicit session-creation method when
re-consent is appropriate.
What recovery does NOT cover
Section titled “What recovery does NOT cover”AmbiguousGrantError— surfacecandidates(each carries agrant_id, the universal disambiguator) to let the user or agent pick, then retry with the chosengrant_id. Checkcandidates_truncatedbefore 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 thegrant_ids incandidates. Minting a new session would be the wrong remediation here.
Blocked destination
Section titled “Blocked destination”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.