Error handling
Exception hierarchy
Every exception the SDK raises inherits from NovaOSError. The root is deliberately protocol-neutral, not A2AError: most SDK modules (memory, mcp, llm, heartbeat, observability, gateway, dns) are plain NovaOS platform HTTP clients that never speak an agent wire protocol — only client/discovery do. A2A-specific errors get their own narrow subtree, A2AProtocolError, reserved for genuine A2A JSON-RPC errors rather than generalized to every SDK failure.
NovaOSError (base)
├── NovaOSConnectionError (retryable)
├── NovaOSAuthenticationError
│ └── MissingExecutionTokenError
├── NovaOSRateLimitError (retryable)
├── NovaOSValidationError (field, reason)
├── NovaOSAgentNotFoundError (agent_name)
├── NovaOSServerError (status_code; retryable for 429/502/503/504)
├── NovaOSDNSError
│ ├── NovaOSDNSConnectionError (retryable)
│ ├── NovaOSDNSRateLimitError (retryable)
│ └── NovaOSDNSAgentNotFoundError (agent_name)
├── SealedEnvelopeError
│ ├── SealedEnvelopeFormatError
│ └── RecipientKeyUnavailableError
├── SecretNotDeliveredError
├── AgentCardSignatureError
│ ├── NoAgentCardSignatureError
│ └── InvalidAgentCardSignatureError
└── A2AProtocolError (error_code, task_id)
├── TaskNotFoundError (-32001)
├── TaskNotCancelableError (-32002)
├── PushNotificationNotSupportedError (-32003)
├── UnsupportedOperationError (-32004)
├── ContentTypeNotSupportedError (-32005)
├── InvalidAgentResponseError (-32006)
├── ExtendedAgentCardNotConfiguredError (-32007)
├── ExtensionSupportRequiredError (-32008)
└── VersionNotSupportedError (-32009)
This is the 2.x renamed successor of an older ACP*-prefixed hierarchy with the same shape but no A2A subtree — see Migrating from 1.x if you're porting except ACP... handlers.
Every instance carries message, retry_after: float | None, and details: dict, plus is_retryable: bool (True for connection errors, rate limits, and server errors with a retryable status code). Subclass extras — field/reason, status_code, agent_name, error_code/task_id — are keyword-only, so NovaOSValidationError("m", 1.5) can't be mistaken for a positional retry_after the way an older ACPValidationError accepted.
try:
task = await client.send_message(message)
except NovaOSError as e:
if e.is_retryable:
await asyncio.sleep(e.retry_after or 1.0)
# retry
else:
raise
Handling specific failures
from novaos_client_sdk import (
NovaOSAgentNotFoundError,
NovaOSConnectionError,
NovaOSAuthenticationError,
NovaOSRateLimitError,
)
try:
task = await client.send_message(message, skill_id="screen")
except NovaOSAgentNotFoundError as e:
print(f"Agent not found: {e.agent_name}")
except NovaOSConnectionError:
print("Connection failed — retrying")
except NovaOSAuthenticationError:
print("Auth failed — refresh the token and retry")
except NovaOSRateLimitError as e:
print(f"Rate limited — wait {e.retry_after}s")
NovaOSValidationError additionally carries field and reason; NovaOSServerError carries status_code. See Authentication for MissingExecutionTokenError.
Graceful degradation with send_message_safe
task = await client.send_message_safe(message, default=None)
if task is None:
print("Execution failed, handled gracefully")
Only NovaOSAgentNotFoundError and NovaOSConnectionError are caught this way. Everything else — including A2AProtocolError — still propagates: an agent that answered -32001 was reached and had something to say, and swallowing that would turn a diagnosable failure into an empty result.
A2A protocol errors
A2AProtocolError means a remote agent replied with one of the A2A spec's own JSON-RPC error codes (spec 1.0.1), not that the HTTP call itself failed. TaskNotCancelableError, UnsupportedOperationError, and the rest above each set error_code on the class, so you can catch by type or branch on the code:
from novaos_client_sdk import TaskNotCancelableError, jsonrpc_error_code
try:
await client.cancel_task(task_id)
except TaskNotCancelableError:
print("Task already reached a terminal state")
except A2AProtocolError as e:
print(f"A2A error {jsonrpc_error_code(e)}: {e.message}")
a2a_error_for_code(code) and jsonrpc_error_code(error) round-trip between an exception class and a wire code; an unrecognized code falls back to the base A2AProtocolError instead of raising, so a peer on a newer spec revision degrades gracefully. error_from_json_rpc(error) builds the right exception from either the A2A codes above or the standard JSON-RPC transport codes (-32700…-32603), which land as NovaOSValidationError/NovaOSServerError — a malformed request is not an A2A protocol failure.
DNS-specific errors
Discovery failures (AgentDNSClient, discover_and_connect) raise NovaOSDNSError and its subtypes rather than the platform hierarchy, since a DNS lookup and an agent call are different trust boundaries:
from novaos_client_sdk import NovaOSDNSConnectionError, NovaOSDNSAgentNotFoundError
try:
record = await dns_client.get_agent("my-agent")
except NovaOSDNSConnectionError:
print("DNS service unavailable")
except NovaOSDNSAgentNotFoundError as e:
print(f"Not found in DNS: {e.agent_name}")
Retries, the circuit breaker, and ctx.tools
MemoryClient and ctx.tools.knowledge use the same CircuitBreaker class, each with its own instance (one tripping does not open the other): CLOSED lets requests through; five consecutive failures trip it OPEN for 30s (fail-fast, no calls out); a half-open probe then decides whether to close again or re-open, with exponential backoff (backoff_delay, base 1s, capped at 16s, jittered) applied between attempts.
Platform tool calls (see Platform tools) never raise for a failed call, even after retries are exhausted — they return a ToolResponse with success=False and an error message instead:
result = await ctx.tools.knowledge.search(query)
if not result.success:
ctx.logger.error("Search failed", extra={"error": result.error})
return SkillResult(output=None, status="FAILED", error=result.error)
Streaming errors
send_streaming_message yields A2A StreamResponse frames, not a dedicated error-frame type. A protocol failure (an unsupported operation, a card that overclaims streaming support) raises A2AProtocolError out of the iterator instead of being yielded; a well-formed stream that never reaches a terminal or interrupt state is itself a transport failure — check with is_last_frame, since A2A dropped ACP's final flag:
from novaos_client_sdk import A2AProtocolError, is_last_frame
try:
async for frame in client.send_streaming_message(message):
...
except A2AProtocolError as e:
print(f"Stream error: {e.message}")
The standardized ErrorMessage
Agents signal a structured error back to a caller with MessageBuilder.create_error_message(content, error_type, ...), which embeds an ErrorMessage (error_type: ErrorType, message, details, recovery_suggestions, retryable) as a metadata part on the reply. ErrorType covers validation_error, connection_error, not_found_error, permission_error, execution_error, and unexpected_error: