Error Handling
Every exception the SDK raises, what triggers it, and how to recover
Error Handling
Both SDKs raise the same ten exceptions with the same names and the same inheritance. Everything on this page applies to Python and TypeScript alike unless a row says otherwise.
Exception hierarchy
ArmorIQException (base)
├── ConfigurationException
├── InvalidTokenException
│ └── TokenExpiredException
├── IntentMismatchException
├── MCPInvocationException
├── DelegationException
├── PolicyBlockedException
├── PolicyHoldException
└── RevokedTokenErrorOnly TokenExpiredException is nested: it extends InvalidTokenException, so
except InvalidTokenException also catches expiry. Every other class descends
directly from ArmorIQException.
All ten are importable from the package root.
from armoriq_sdk import (
ArmorIQException,
ConfigurationException,
DelegationException,
IntentMismatchException,
InvalidTokenException,
MCPInvocationException,
PolicyBlockedException,
PolicyHoldException,
RevokedTokenError,
TokenExpiredException,
)from armoriq_sdk.exceptions import ... also works and is equivalent.
import {
ArmorIQException,
ConfigurationException,
DelegationException,
IntentMismatchException,
InvalidTokenException,
MCPInvocationException,
PolicyBlockedException,
PolicyHoldException,
RevokedTokenError,
TokenExpiredException,
} from '@armoriq/sdk';What each one means
| Exception | Raised when | Recovery |
|---|---|---|
ConfigurationException | No API key or a malformed one at construction, or the proxy rejected the key as invalid/revoked during the startup check | Set ARMORIQ_API_KEY, or pass api_key / apiKey explicitly. Check the key in the API Keys dashboard |
InvalidTokenException | The proxy rejected the token with 401 or 403 (bad signature, revoked, malformed) | Capture a fresh plan and mint a new token |
TokenExpiredException | The token is past its validity window. Raised client-side before the request goes out, so it costs no round trip | Call get_intent_token / getIntentToken again on the same plan |
IntentMismatchException | The proxy returned 409: the action you invoked is not in the plan the token was minted for | Align the arguments with the plan, or capture a new plan that includes the action |
MCPInvocationException | Any other non-2xx from the proxy, or the MCP server itself errored | Inspect status_code / statusCode. Not always an ArmorIQ problem: a 5xx here usually means the MCP is down |
PolicyBlockedException | Policy evaluation returned a deny for this user and tool | Update the policy in the dashboard. Do not retry: the answer will not change |
PolicyHoldException | Policy requires human approval before the tool can run | Wait on the approval, then retry. See holds and approvals |
DelegationException | A delegation call failed: target unavailable, trust rejected, or a malformed subtask | Inspect delegation_id / delegationId and the delegation status |
RevokedTokenError | Reserved. See the note below | n/a |
Attributes
Every exception carries the message. These also carry structured fields, which are more useful than parsing the message string.
| Exception | Python | TypeScript |
|---|---|---|
InvalidTokenException | token_id | tokenId |
TokenExpiredException | token_id, expired_at | tokenId, expiredAt |
IntentMismatchException | action, plan_hash | action, planHash |
MCPInvocationException | mcp, action, status_code | mcp, action, statusCode |
DelegationException | target_agent, delegation_id, status_code | targetAgent, delegationId, statusCode |
PolicyBlockedException | enforcement_action, reason, metadata | enforcementAction, reason, metadata, matchedPolicy |
PolicyHoldException | delegation_context, metadata | delegationContext, metadata, matchedPolicy |
RevokedTokenError | token_id, reason | tokenId, reason |
ConfigurationException | message only | message only |
matchedPolicy on the two policy exceptions is TypeScript-only. The Python
equivalents do not carry it yet, so do not rely on it in cross-language code.
Failures raise, they do not come back in the result
There is no success flag to check. A failed invoke() raises; a successful
one returns an MCPInvocationResult.
The one exception is a tool that ran but reported its own error. When the MCP
returns an error payload rather than failing the transport, invoke() returns
normally with status set to "error":
result = client.invoke("analytics-mcp", "analyze", token, params)
if result.status == "error":
# The tool ran and returned an error payload. result.result holds it.
logger.warning("Tool reported an error: %s", result.result)const result = await client.invoke('analytics-mcp', 'analyze', token, params);
if (result.status === 'error') {
console.warn('Tool reported an error:', result.result);
}MCPInvocationResult carries mcp, action, result, status,
execution_time / executionTime, verified, and metadata. See
Data Models for the full shape.
How proxy responses map to exceptions
The proxy's HTTP status determines which exception you get:
| Status | Exception |
|---|---|
| 401, 403 | InvalidTokenException |
| 409 | IntentMismatchException (with action and plan_hash populated) |
| anything else non-2xx | MCPInvocationException (with status_code populated) |
Policy decisions are separate: the proxy returns a structured enforcement
response, and the SDK turns it into PolicyBlockedException or
PolicyHoldException rather than mapping a status code.
Catching exceptions
Order matters. Catch specific classes before their bases, and remember
TokenExpiredException is a subclass of InvalidTokenException.
import logging
from armoriq_sdk import (
ArmorIQException,
IntentMismatchException,
InvalidTokenException,
MCPInvocationException,
PolicyBlockedException,
PolicyHoldException,
TokenExpiredException,
)
logger = logging.getLogger(__name__)
captured = client.capture_plan(llm="gpt-4", prompt="Analyze the data", plan=plan_dict)
token = client.get_intent_token(captured)
try:
result = client.invoke("analytics-mcp", "analyze", token, params)
except TokenExpiredException as e:
# Before InvalidTokenException — it is a subclass.
logger.warning("Token %s expired at %s; re-minting", e.token_id, e.expired_at)
token = client.get_intent_token(captured)
result = client.invoke("analytics-mcp", "analyze", token, params)
except InvalidTokenException as e:
# Signature rejected or token revoked. A fresh token needs a fresh plan.
logger.error("Token %s rejected: %s", e.token_id, e)
captured = client.capture_plan(llm="gpt-4", prompt="Analyze the data", plan=plan_dict)
token = client.get_intent_token(captured)
result = client.invoke("analytics-mcp", "analyze", token, params)
except IntentMismatchException as e:
# The action was not in the plan this token covers.
logger.error("Action %s not in plan %s", e.action, e.plan_hash)
raise
except PolicyBlockedException as e:
# A deny. Retrying will not change the answer.
logger.error("Blocked by policy (%s): %s", e.enforcement_action, e.reason)
raise
except PolicyHoldException as e:
# Needs human approval. See the holds section below.
logger.info("Held for approval: %s", e.delegation_context)
raise
except MCPInvocationException as e:
# Often the MCP itself, not ArmorIQ.
logger.error("MCP %s.%s failed (HTTP %s): %s", e.mcp, e.action, e.status_code, e)
raise
except ArmorIQException:
# Anything else from the SDK.
logger.exception("ArmorIQ error")
raiseimport {
ArmorIQException,
IntentMismatchException,
InvalidTokenException,
MCPInvocationException,
PolicyBlockedException,
PolicyHoldException,
TokenExpiredException,
} from '@armoriq/sdk';
let captured = client.capturePlan('gpt-4', 'Analyze the data', planDict);
let token = await client.getIntentToken(captured);
try {
const result = await client.invoke('analytics-mcp', 'analyze', token, params);
} catch (error) {
if (error instanceof TokenExpiredException) {
// Before InvalidTokenException — it is a subclass.
console.warn(`Token ${error.tokenId} expired at ${error.expiredAt}; re-minting`);
token = await client.getIntentToken(captured);
await client.invoke('analytics-mcp', 'analyze', token, params);
} else if (error instanceof InvalidTokenException) {
console.error(`Token ${error.tokenId} rejected: ${error.message}`);
captured = client.capturePlan('gpt-4', 'Analyze the data', planDict);
token = await client.getIntentToken(captured);
await client.invoke('analytics-mcp', 'analyze', token, params);
} else if (error instanceof IntentMismatchException) {
console.error(`Action ${error.action} not in plan ${error.planHash}`);
throw error;
} else if (error instanceof PolicyBlockedException) {
console.error(`Blocked by policy (${error.enforcementAction}): ${error.reason}`);
throw error;
} else if (error instanceof PolicyHoldException) {
console.info('Held for approval:', error.delegationContext);
throw error;
} else if (error instanceof MCPInvocationException) {
console.error(`MCP ${error.mcp}.${error.action} failed (HTTP ${error.statusCode})`);
throw error;
} else if (error instanceof ArmorIQException) {
console.error(`ArmorIQ error: ${error.message}`);
throw error;
} else {
throw error;
}
}capture_plan and capturePlan raise a plain ValueError / Error, not an
ArmorIQException, when the plan is missing or has no steps key. A bare
except ArmorIQException will not catch it.
Holds and approvals
PolicyHoldException means the call is waiting on a human, not that it failed.
Catching it and giving up turns every approval into an error.
Two ways to handle it:
invoke_with_policy/invokeWithPolicycan poll for the decision for you. See the TypeScript SDK page for the options.- In session mode,
await_approvalwaits without blocking, so your app can stream held to approved live. See the Python SDK page for session setup.
The framework adapters do this already: on a hold they emit a hold event,
wait for the decision, and let the tool run only on an explicit approve. See
Strands for the event lifecycle.
RevokedTokenError is not raised yet
RevokedTokenError is exported from both SDKs and documented in their source as
the signal that the IAP revoked a token mid-flight (the proxy answers 401 with
X-Armoriq-Revoked: true).
No code path currently raises it. A 401 maps to InvalidTokenException
regardless of that header, so a revoked token surfaces as a generic invalid
token today.
Catch InvalidTokenException for now. It covers revocation along with signature
failures, and the recovery is the same either way: capture a fresh plan and mint
a new token.