Skip to content

Reference

Errors

Every exception class, what it means, what to do.

The 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 class names; the snippets below are Python but the TS hierarchy is identical (camelCase fields).

AlterSDKError
├── AlterValueError — SDK rejected caller input; fix the code
├── BackendError — Alter backend returned an error
│ ├── ReAuthRequiredError — user must re-authorize via Connect
│ │ ├── GrantExpiredError
│ │ ├── GrantRevokedError
│ │ ├── CredentialRevokedError
│ │ └── GrantDeletedError
│ ├── GrantNotFoundError — wrong grant_id; fix the code
│ ├── AmbiguousGrantError — multiple grants matched; pick one
│ ├── PolicyViolationError — policy denied; may resolve later
│ ├── 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
│ ├── SiblingLabelConflictError — label already in use on this connection
│ ├── TokenRefreshInProgressError — transient 409; retry
│ └── AgentError — managed-agent management
│ ├── InvalidKeyError
│ ├── AgentNotFoundError
│ ├── AgentNameExistsError
│ ├── MeRequiresAgentKeyError
│ ├── KeyRevokedError
│ ├── AgentInactiveError
│ ├── AgentRevokedError
│ ├── KeyNotFoundError
│ ├── KeyAlreadyRevokedError
│ ├── LastActiveKeyError
│ ├── AgentCannotMintSubagentsError
│ ├── AgentScopeNarrowingNotSupportedError
│ ├── IdempotencyKeyBodyMismatchError
│ ├── IdempotencyKeyAgentRevokedError
│ └── IdempotencyKeyAgentInactiveError
├── ConnectFlowError — Connect flow failed
│ ├── ConnectDeniedError — user clicked Deny
│ ├── ConnectConfigError — OAuth app misconfigured
│ └── ConnectTimeoutError — user didn't complete in time
├── 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.
  • “A transient backend condition.” Catch TokenRefreshInProgressError. Trigger: a parallel refresh holds the lock. Response: retry with backoff.
  • “A network problem.” Catch NetworkError. Trigger: connection failure, DNS, timeout. Response: retry with backoff (catch TimeoutError separately if a different retry profile is needed for timeouts vs connection refused).

Raised when provider-based resolution matches more than one grant. Alter never silently picks a grant — there is no “default grant” or “most recently connected wins” behavior. The error enumerates the candidates so the caller (or the end user, conversationally) chooses explicitly. The candidates are always scoped to the caller’s own accessible grants — the error can never reveal a grant the caller could not use.

AttributeDescription
provider_idThe provider whose grants matched.
candidatesOne entry per matching grant: grant_id and label (the sibling address — retry with provider + label), plus, where the caller may see them, account_identifier and account_display_name. Retry with the chosen grant_id — the universal disambiguator.
account_identifiersList of matching accounts; show to the user to pick.
account_was_providedTrue if account= was already passed and still produced an ambiguity (sign of a mis-set account).
app_user_ids(Agent flavor) UUIDs of the users with matching delegations. For an agent holding delegations from multiple users, candidates carry grant IDs and labels only — other users’ account identifiers are never exposed to the agent.
grant_ids(Managed-secret flavor) IDs of the matching grants when one user delegated multiple managed-secret grants of the same template to the agent. Retry with the chosen grant_id.

Pass the chosen grant_id (or account=<chosen> / label=<chosen> / user_token=<jwt> for the account / label / agent flavors) to disambiguate on retry. For production workloads, prefer addressing grants by grant_id from the start — the Connect flow returns it at consent time, and it stays unambiguous no matter how many grants a user adds later.

Raised when raw-token retrieval is attempted against a grant whose policy carries method/endpoint restrictions. Restricted grants are proxy-only by design — a token handed to the caller could not be held to the restriction — and there is no opt-out. Switch the call to the SDK’s proxied execution path (proxy_request() / proxyRequest()); restriction denials there surface as PolicyViolationError.

Raised by mint_grant() / mintGrant() when the requested label is already held by an active grant on the same connection. Carries label. Pick a different label, or revoke the holder first — revoking a grant frees its label.

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

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

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

AttributeDescription
grant_idThe grant that needs re-authorization.
provider_idThe provider.
missing_scopesWhen parsed from a WWW-Authenticate: insufficient_scope challenge, the specific scopes missing. None when the source was a pre-flight backend flag.
status_code, response_bodyThe provider’s raw response.

Pattern:

try:
await app.request(...)
except ScopeReauthRequiredError as e:
session = await app.create_connect_session(allowed_providers=[e.provider_id])
notify_user(session.connect_url)

Raised when the provider returns 401 to a request() call — the provider rejected the credential (revoked, expired, or otherwise invalidated provider-side). The 401 sibling of ScopeReauthRequiredError; both extend ProviderAPIError, so existing ProviderAPIError handlers keep catching it. A 401 whose WWW-Authenticate challenge carries error="insufficient_scope" (RFC 6750) is deliberately excluded — the credential is still valid, it just lacks a scope — and surfaces as a generic ProviderAPIError instead.

AttributeDescription
grant_idThe grant whose credential was rejected.
provider_idThe provider.
status_code, response_bodyThe provider’s raw response.

Recovery depends on the grant family:

  • OAuth grant — re-authorize via a new Connect session (same pattern as ScopeReauthRequiredError: create_connect_session(allowed_providers=[e.provider_id]) and surface the URL to the user).
  • Managed-secret grant — the stored secret is no longer accepted by the provider; update it with a valid value.

Raised when a policy denies the call.

AttributeDescription
policy_errorBackend-supplied identifier for the violated policy (e.g., outside_business_hours, ip_not_allowed, rate_limit_exceeded).

May resolve on its own (time-of-day window opens, rate limit decays).

Raised by agents.revoke_key() 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 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 multiple agent error types.

A few backend agent codes surface as a plain BackendError rather than a dedicated subclass. They are still stable contracts — branch on err.details['error'] (Python) / err.details.error (TypeScript) when handling them.

details['error']HTTPMeaning
agent_cannot_read_peer_agents403An agent key tried to read another agent’s record. The canonical self-introspection path is agent.me().
agent_scope_not_allowed403The agent key called a route whose target provider / OAuth scope / managed-secret name is not in the agent’s scope allowlist. Widen the allowlist on the agent.
idempotency_record_corrupted500An idempotency replay pointed at an agent or key that no longer exists. Retry with a fresh idempotency key; the underlying corruption is operator-investigable.
ErrorMeaningRecovery
ApprovalDeniedErrorApprover clicked Deny.Inspect e.details for the approver’s reason; surface to the requester.
ApprovalExpiredErrorWindow elapsed; no decision made.Re-issue the call or widen the policy’s window.
ApprovalTimeoutErrorSDK gave up waiting; row may still be pending.Persist approval_id and re-poll later.
ApprovalExecutionFailedErrorApproved, but the eventual provider call failed.Inspect e.details. Common cause: grant revoked between approval and execution.
ErrorMeaning
ConnectDeniedErrorUser clicked Deny on the provider’s consent screen.
ConnectConfigErrorOAuth client is misconfigured (wrong redirect URI, invalid client ID/secret). Check the provider configuration in the portal.
ConnectTimeoutErrorUser did not complete OAuth within the session lifetime.

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.
  • CredentialRevokedError carries provider_id / app_user_id (no agent_id — credential revocation is grant-scoped, not agent-scoped).

Python

from alter_sdk import NoDelegatedGrantError
try:
await app.request(provider="slack", user_token=jwt, url=..., method=...)
except NoDelegatedGrantError as e:
# 1. Mint a recovery Connect session using the error's context.
session = await app.create_connect_session_for_error(
e,
allowed_origin="https://app.example.com",
)
# 2. Surface the URL to the user.
redirect_user(session.connect_url)
# 3. Poll until consent completes.
results = await app.poll_connect_session(session.session_token)
# 4. Retry with the freshly-minted grant_id.
response = await app.request(grant_id=results[0].grant_id, url=..., method=...)

TypeScript

import { 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

create_connect_session_for_error raises AlterValueError when recovery context isn’t available — for direct-mode GrantNotFoundError (stale grant_id), the SDK can’t infer which provider to re-consent to, so the developer must call create_connect_session directly with the right allowed_providers=.

  • AmbiguousGrantError — surface candidates (each carries a grant_id, the universal disambiguator) to let the user or agent pick, then retry with the chosen grant_id. For the OAuth account flavor, account_identifiers + account= and, for the agent flavor, app_user_ids + user_token= are alternates — and the only usable disambiguators when account/user fields are empty are the grant_ids in candidates. Minting a new session would be the wrong remediation here.