Errors
How the SDK surfaces failures, what each error means, and how to handle them in your agent or app.
All camelCase TypeScript properties use snake_case in the Python SDK (
rpcMessage→rpc_message,taskId→task_id, etc.). This applies consistently across all SDK methods and properties.
Error classes
The SDK defines these error classes. All are importable from @blocks-network/sdk (Node.js) or blocks_network (Python).
| Class | When thrown | Fatal? | Recovery |
|---|---|---|---|
RpcError | Any JSON-RPC error response from the backend | No | Inspect .code and .rpcMessage to decide |
BillingModeMismatchError | billingMode passed to TaskClient.create() doesn't match the target agent | No | Fix the billingMode parameter |
AnonTaskAccessDeniedError (Node.js) / AnonTaskAccessDenied (Python) | Anonymous consumer denied access (HTTP 403) | No | Authenticate with an API key |
StreamUnavailableError | Attempting to open a stream on a task that already reached a terminal state | No | Check task state before opening streams |
AgentAuthFatalError | API key permanently invalid (revoked, expired, account deleted) | Yes | Re-authenticate with blocks login --write-env |
StreamError | Real-time messaging failure | Depends | Check the fatal flag |
RpcError
Base error for any JSON-RPC error response from the Blocks backend. All structured API failures surface as RpcError or one of its subclasses.
| Property | Type | Description |
|---|---|---|
code | number | undefined | JSON-RPC error code (e.g. -32000, -32601) |
rpcMessage | string | Human-readable error description from the backend |
data | unknown | Optional structured payload with additional details |
When the backend rejects a request — invalid params, billing mismatch, internal error — it responds with a JSON-RPC error. Catch RpcError and inspect .code to decide whether to retry, surface the issue, or fall back:
TypeScript
import { TaskClient, textPart, RpcError } from '@blocks-network/sdk';
const client = await TaskClient.create({
billingMode: 'free',
apiKey: process.env.BLOCKS_API_KEY!,
});
try {
const session = await client.sendMessage({
agentName: 'my-agent',
requestParts: [textPart('Hello', 'request')],
});
} catch (err) {
if (err instanceof RpcError) {
if (err.code === -32602) {
console.error('Invalid request params:', err.rpcMessage);
} else if (err.code === -32603) {
console.error('Backend error — safe to retry:', err.rpcMessage);
} else {
console.error(`RPC error ${err.code}: ${err.rpcMessage}`);
}
}
}Python
from blocks_network import TaskClient, RpcError
client = await TaskClient.create(billing_mode="free", api_key=api_key)
try:
session = await client.send_message(
agent_name="my-agent",
request_parts=[{"part_id": "request", "text": "Hello"}],
)
except RpcError as err:
if err.code == -32602:
print(f"Invalid request params: {err.rpc_message}")
elif err.code == -32603:
print(f"Backend error — safe to retry: {err.rpc_message}")
else:
print(f"RPC error {err.code}: {err.rpc_message}")BillingModeMismatchError
Extends RpcError. Thrown when the billingMode passed to TaskClient.create() doesn't match the target agent's persisted billing mode. The backend rejects the request with JSON-RPC code -32000.
| Property | Type | Description |
|---|---|---|
expected | 'free' | 'paid' | The billing mode the agent expects |
got | 'free' | 'paid' | The billing mode you passed |
This error is not transient — retrying with the same parameters fails again. Fix the billingMode parameter to match the target agent.
TypeScript
import { TaskClient, BillingModeMismatchError } from '@blocks-network/sdk';
try {
const client = await TaskClient.create({
billingMode: 'free',
apiKey: process.env.BLOCKS_API_KEY!,
});
await client.sendMessage({ agentName: 'paid-agent', requestParts: [/* ... */] });
} catch (err) {
if (err instanceof BillingModeMismatchError) {
console.error(`Expected ${err.expected}, got ${err.got}. Fix your billingMode.`);
}
}Python
from blocks_network import TaskClient, BillingModeMismatchError
try:
client = await TaskClient.create(billing_mode="free", api_key=api_key)
await client.send_message(agent_name="paid-agent", request_parts=[...])
except BillingModeMismatchError as err:
print(f"Expected {err.expected}, got {err.got}. Fix your billing_mode.")AnonTaskAccessDeniedError
Thrown when an anonymous consumer (no API key, using the browser Playground) is denied access. The backend returns HTTP 403.
Python name: In the Python SDK, this class is
AnonTaskAccessDenied(noErrorsuffix). Import it asfrom blocks_network import AnonTaskAccessDenied.
This typically means the anonymous quota has been exceeded, the agent is private, or the agent requires payment. Authenticate with an API key to resolve.
StreamUnavailableError
Thrown when you call StreamRef.open() on a task that has already reached a terminal state (completed, failed, or canceled). Stream data is live-only — once a task terminates, its streams close and cannot be reopened.
| Property | Type | Description |
|---|---|---|
taskId | string | The task that terminated |
streamId | string | The stream you tried to open |
declaredStream | object | undefined | The stream declaration from the agent card |
terminalState | string | The task's terminal state |
This commonly happens when a consumer tries to open a stream after the agent has already finished processing. For example, if you subscribe to a task and then try to open its stream, but the agent completed instantly:
TypeScript
import { TaskClient, textPart, StreamUnavailableError } from '@blocks-network/sdk';
const session = await client.sendMessage({
agentName: 'fast-responder',
requestParts: [textPart('Quick question', 'request')],
});
// By the time we try to open the stream, the agent may have already finished
try {
const streamRef = await session.waitForStream('progress');
const streamClient = streamRef.open();
for await (const msg of streamClient.inbound) {
console.log('Stream chunk:', msg.data);
}
} catch (err) {
if (err instanceof StreamUnavailableError) {
// Task already completed — read artifacts directly instead of streaming
console.log(`Task already ${err.terminalState}, reading artifacts instead`);
const artifacts = session.listArtifacts();
}
}See Stream data for the full streaming guide.
AgentAuthFatalError
Thrown when the API key is permanently invalid — revoked, expired, or the account has been deleted. This is a fatal error: the agent cannot recover without new credentials.
When this happens, blocks run exits. Re-authenticate and restart:
blocks login --write-env
blocks runIf you're running the SDK directly (without the CLI), you'll catch this during agent startup or when the token refresh cycle detects the revocation:
TypeScript
import { startAgentInstance, AgentAuthFatalError } from '@blocks-network/sdk';
try {
const agent = await startAgentInstance({
apiKey: process.env.BLOCKS_API_KEY!,
handler: myHandler,
});
} catch (err) {
if (err instanceof AgentAuthFatalError) {
console.error('API key revoked or expired. Run: blocks login --write-env');
process.exit(1);
}
}See Builders authentication for how authentication works.
StreamError
An interface (not a thrown exception) delivered to StreamClient.onError() when the real-time messaging layer encounters a problem. You get a StreamClient by calling streamRef.open() on a StreamRef obtained from session.onStream() or session.waitForStream().
Not to be confused with
TaskSession.onError(), which fires when your own callbacks (e.g.onProgress,onArtifact) throw. See Handler exceptions.
| Property | Type | Description |
|---|---|---|
category | TransportCategory | Transport-neutral category (e.g. 'access_denied', 'timeout') |
error | any | Raw error data from the messaging layer |
channel | string | The stream channel affected |
timestamp | number | Unix milliseconds when the error occurred |
fatal | boolean | Whether the stream was force-terminated |
See Stream errors below for handling patterns.
HTTP status codes
The SDK handles certain HTTP status codes automatically. Others propagate to your code.
| Status | Meaning | SDK behavior | Your action |
|---|---|---|---|
| 401 | Unauthorized | Refreshes token, retries the request once | If it still fails, your API key is invalid — re-authenticate |
| 403 | Forbidden | Throws AnonTaskAccessDeniedError or stream rejection | Check permissions, billing mode, or invitation status |
| 429 | Rate limited | Propagated as RpcError (not retried) | Implement backoff in your application — see below |
| 5xx | Server error | Propagated as RpcError (not retried) | Implement retry logic if you need resilience against transient server failures |
JSON-RPC error codes
The Blocks backend uses JSON-RPC 2.0. Error responses include a numeric code and human-readable message.
| Code | Name | Description | SDK error class |
|---|---|---|---|
| -32000 | BillingModeMismatch | billingMode doesn't match the target agent | BillingModeMismatchError |
| -32600 | InvalidRequest | Malformed JSON-RPC request | RpcError |
| -32601 | MethodNotFound | RPC method does not exist or is unavailable | RpcError |
| -32602 | InvalidParams | Request validation failed (e.g. invalid partId) | RpcError |
| -32603 | InternalError | Backend processing error | RpcError |
Transient errors and retries
What the SDK retries automatically
You do not need retry logic for network issues
The SDK retries transient network failures (connection-level errors) automatically. HTTP-level errors like 429 and 5xx are not retried — implement your own retry logic for those.
The SDK treats these as transient and retries them:
| Error code | Meaning |
|---|---|
ENOTFOUND | DNS resolution failed |
ETIMEDOUT | Connection timed out |
ECONNRESET | Connection reset by peer |
NetworkIssues | Generic network failure |
Retry algorithm
| Parameter | Default |
|---|---|
| Max retries | 3 |
| Base delay | 500 ms |
| Formula | baseDelay × 2^attempt + random(0–100 ms) |
Actual delay per attempt:
| Attempt | Delay |
|---|---|
| 1 | 500–600 ms |
| 2 | 1000–1100 ms |
| 3 | 2000–2100 ms |
After 3 failed attempts, the error propagates to your code.
What you need to retry yourself
The SDK does not retry these automatically:
- 429 (rate limited): The backend is asking you to slow down. Implement exponential backoff.
- 5xx (server error): Transient server failures. Safe to retry with backoff — use the same pattern as 429.
- Business logic failures: A task with
state: 'failed'means the agent returned an error — retry the task or handle the failure. - BillingModeMismatchError: Fix the
billingModeparameter. Retrying with the same value always fails.
Handling 429 and 5xx — TypeScript
import { RpcError, TaskClient, textPart } from '@blocks-network/sdk';
function isRetryable(err: unknown): boolean {
return err instanceof RpcError && err.code !== undefined && (err.code === 429 || err.code >= 500);
}
async function withBackoff<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (isRetryable(err) && attempt < maxRetries - 1) {
const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500;
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
const session = await withBackoff(() =>
client.sendMessage({ agentName: 'busy-agent', requestParts: [textPart('Hello', 'request')] }),
);Handling 429 and 5xx — Python
import asyncio
import random
from blocks_network import RpcError
def is_retryable(err):
return isinstance(err, RpcError) and err.code is not None and (err.code == 429 or err.code >= 500)
async def with_backoff(fn, max_retries=5):
for attempt in range(max_retries):
try:
return await fn()
except RpcError as err:
if is_retryable(err) and attempt < max_retries - 1:
delay = (2 ** attempt) + random.random() * 0.5
await asyncio.sleep(delay)
continue
raise
raise Exception("Max retries exceeded")Authentication recovery
The SDK manages token lifecycle automatically. Here's what happens under the hood and when you need to intervene.
Proactive token refresh
The SDK schedules a token refresh at 80% of the token's TTL. If your token lives for 60 minutes, the SDK refreshes it at the 48-minute mark. This happens silently — you don't need to do anything.
If proactive refresh fails, the SDK retries with exponential backoff (up to 3 attempts, capped at 30 seconds between retries).
Reactive refresh on 401
If a request returns 401 despite the proactive refresh (e.g. the token was revoked server-side), the SDK:
- Deduplicates concurrent refresh attempts (multiple in-flight requests share one refresh)
- Fetches a new token
- Retries the failed request once with the new token
If the retry still returns 401, the error propagates to your code.
Fatal auth errors
Two backend codes trigger distinct behaviors:
| Code | Meaning | SDK behavior |
|---|---|---|
API_KEY_INVALID | Key revoked or account deleted | Throws AgentAuthFatalError — agent must shut down |
REFRESH_TOKEN_INVALID | Refresh token expired | Agent: auto-reconnects with stored credentials. Consumer: re-initializes via API key. |
On the consumer side, a fatal auth error surfaces when creating the TaskClient. For example, if your backend rotated API keys but a deployed service still has the old one:
Detecting fatal auth — TypeScript
import { TaskClient, AgentAuthFatalError } from '@blocks-network/sdk';
try {
const client = await TaskClient.create({
billingMode: 'free',
apiKey: process.env.BLOCKS_API_KEY!,
});
} catch (err) {
if (err instanceof AgentAuthFatalError) {
// Old key was revoked — alert ops, don't retry
alertOpsChannel('Blocks API key is invalid, service degraded');
throw err;
}
}Detecting fatal auth — Python
from blocks_network import TaskClient, AgentAuthFatalError
try:
client = await TaskClient.create(billing_mode="free", api_key=api_key)
except AgentAuthFatalError:
alert_ops_channel("Blocks API key is invalid, service degraded")
raiseHandler exceptions
How handler errors are routed
If your handler function throws an unhandled exception, the SDK catches it and routes it through error callbacks. The exception does not crash your agent or interrupt other active sessions.
The routing order:
- If an
onErrorcallback is registered → invoked with the error and context - If no
onErrorcallback → logged at WARN level by the SDK's internal logger
The onError callback
TaskSession.onError(cb) fires when one of your registered callbacks (onProgress, onArtifact, etc.) throws an exception. It receives (error: Error, context: CallbackErrorContext) — not a StreamError. For stream transport errors, see Stream errors.
This is useful for monitoring: if your onProgress callback throws while parsing a status update, onError lets you log the issue without losing the session.
TypeScript
const session = await client.sendMessage({
agentName: 'data-processor',
requestParts: [textPart(csvData, 'request')],
});
session.onProgress((event) => {
updateProgressBar(event.progress); // might throw if event.progress is unexpected format
});
session.onError((error, context) => {
// Fires if onProgress (or any other callback) throws
console.error(
`Callback ${context.callbackType} failed: ${error.message}`,
);
// Session continues — you won't miss the final result
});Python
session = await client.send_message(
agent_name="data-processor",
request_parts=[{"part_id": "request", "text": csv_data}],
)
def on_error(error, context):
print(f"Callback {context.callback_type} failed: {error}")
session.on_error(on_error)The context object:
| Field | Type | Values |
|---|---|---|
entryPoint | string | 'taskSession' or 'subscribeToTask' |
callbackType | string | 'onProgress', 'onArtifact', 'onTerminal', 'onSystem', 'onEvent', 'onStream', 'streamPredicate' |
event | object | The event that triggered the failing callback |
Best practices for handlers
Your handler runs your code. If it throws, the task ends in a failed state. Build defensively:
TypeScript
export default async function handler(task, ctx) {
try {
const llmResponse = await callExternalLLM(task.requestParts);
const dbResult = await saveToDatabase(llmResponse);
return { artifacts: [{ data: dbResult, mimeType: 'application/json' }] };
} catch (err) {
ctx.reportStatus(`Failed: ${err.message}`);
return { artifacts: [{ data: JSON.stringify({ error: err.message }), mimeType: 'application/json' }] };
}
}Python
async def handler(task, ctx=None):
try:
llm_response = await call_external_llm(task.request_parts)
db_result = await save_to_database(llm_response)
return {"artifacts": [{"data": db_result, "mimeType": "application/json"}]}
except Exception as err:
ctx.report_status(f"Failed: {err}")
return {"artifacts": [{"data": json.dumps({"error": str(err)}), "mimeType": "application/json"}]}Key principles:
- Wrap external calls (LLM APIs, databases, third-party services) in try/catch
- Return a structured error artifact rather than letting exceptions propagate unhandled
- Use
ctx.reportStatus()to communicate failure context to callers - For pipe tasks: check
ctx.cancelSignalbefore expensive operations
Stream errors
Fatal vs non-fatal
Stream errors are delivered to StreamClient.onError() — the callback on the opened stream client, not on the session. Each error includes a fatal flag indicating whether the stream can recover.
The SDK maps the underlying transport's internal categories into neutral TransportCategory labels so your error handling doesn't depend on the transport implementation:
| Category | Fatal | Meaning |
|---|---|---|
'access_denied' | Yes | Access token expired or revoked — stream cannot recover |
'bad_request' | Yes | Malformed request — stream cannot recover |
'network_down' | No | Connection lost — transport retries automatically |
'network_issues' | No | Temporary connectivity problem — transport retries automatically |
'timeout' | No | Subscribe timeout — reconnects automatically |
'malformed_response' | No | Unexpected response format — transport retries |
'other' | No | Unclassified transport event — typically non-fatal |
Non-fatal errors resolve themselves. The transport layer has its own retry machinery and reconnects transparently.
Handling stream disconnects
To receive stream errors, open the stream via StreamRef.open() and register onError on the returned StreamClient. A typical scenario: your consumer is streaming real-time output from a long-running agent, and the stream's access token gets revoked (e.g. the agent's owner rotated credentials).
TypeScript
import { TaskClient, textPart, StreamError } from '@blocks-network/sdk';
const session = await client.sendMessage({
agentName: 'live-monitor',
requestParts: [textPart('Watch CPU metrics', 'request')],
});
// Wait for the agent to open a stream, then subscribe
session.onStream((streamRef) => {
const streamClient = streamRef.open();
// Stream transport errors fire here — NOT on session.onError
streamClient.onError((err: StreamError) => {
if (err.fatal) {
// 'access_denied' or 'bad_request' — stream is dead
console.error(`Stream lost (${err.category}), closing session`);
session.close();
}
// Non-fatal ('network_issues', 'timeout'): transport reconnects, do nothing
});
// Consume inbound messages via async iterator
(async () => {
for await (const msg of streamClient.inbound) {
displayMetric(msg.data);
}
})();
});Two different
onErrorAPIs:streamClient.onError(cb)fires for stream transport errors (theStreamErrorinterface with.fatal).session.onError(cb)fires when your own event callbacks throw (see Handler exceptions). Don't mix them up.
If you receive a fatal stream error and the task is still running, you can re-submit the task or close gracefully. The stream itself cannot be reopened.
See Stream data for the full streaming guide and Network requirements for firewall configuration.
Consumer error patterns
Handling task failures
A task can end in three terminal states: completed, failed, or canceled. Always check the state before reading artifacts. For example, if you're calling a code review agent from your CI pipeline, you need to handle the case where the agent crashes or times out:
TypeScript
import { TaskClient, textPart } from '@blocks-network/sdk';
const client = await TaskClient.create({ billingMode: 'free', apiKey: process.env.BLOCKS_API_KEY! });
const session = await client.sendMessage({
agentName: 'code-reviewer',
requestParts: [textPart(diffContent, 'request')],
});
const terminal = await session.waitForTerminal(60_000);
switch (terminal.state) {
case 'completed':
const artifacts = session.listArtifacts();
const review = await session.downloadArtifact(artifacts[0]);
postReviewComment(new TextDecoder().decode(review.data));
break;
case 'failed':
console.error('Code review agent failed — skipping automated review');
break;
case 'canceled':
console.log('Review was canceled (agent went offline mid-task)');
break;
}
session.close();
client.destroy();Python
from blocks_network import TaskClient
client = await TaskClient.create(billing_mode="free", api_key=api_key)
session = await client.send_message(
agent_name="code-reviewer",
request_parts=[{"part_id": "request", "text": diff_content}],
)
terminal = await session.wait_for_terminal(60_000)
if terminal.state == "completed":
artifacts = session.list_artifacts()
review = await session.download_artifact(artifacts[0])
post_review_comment(review.data.decode())
elif terminal.state == "failed":
print("Code review agent failed — skipping automated review")
elif terminal.state == "canceled":
print("Review was canceled (agent went offline mid-task)")
session.close()
client.destroy()Timeout handling
waitForTerminal rejects if the task doesn't reach a terminal state within the timeout. The task continues running on the agent — you're just giving up waiting. This matters for user-facing apps where you can't block indefinitely:
TypeScript
import { TaskClient, textPart } from '@blocks-network/sdk';
// User clicked "Generate Report" — show a result or a timeout message within 30s
const session = await client.sendMessage({
agentName: 'report-generator',
requestParts: [textPart(reportParams, 'request')],
});
try {
const terminal = await session.waitForTerminal(30_000);
if (terminal.state === 'completed') {
const [artifact] = session.listArtifacts();
return await session.downloadArtifact(artifact);
}
} catch (err) {
// Timeout — report is still generating, but the user can't wait
return { message: 'Report is taking longer than expected. Check back in a minute.' };
} finally {
session.close();
}Choose timeouts carefully
Set your waitForTerminal timeout based on the agent's expected response time. The agent's live stats in the catalog show p50 response times. For orchestrators calling sub-agents, leave a buffer — see Orchestrator resilience.
Billing mode errors
The billingMode passed to TaskClient.create() must match the target agent's billing mode. Passing 'free' when calling a paid agent (or vice versa) throws a BillingModeMismatchError on the first sendMessage call.
Check the agent's billing mode in the Blocks Network catalog before creating your client. If you discover the mismatch at runtime, create a new TaskClient with the correct mode:
import { TaskClient, BillingModeMismatchError, textPart } from '@blocks-network/sdk';
async function callAgent(agentName: string, prompt: string) {
let client = await TaskClient.create({ billingMode: 'free', apiKey: process.env.BLOCKS_API_KEY! });
try {
return await client.sendMessage({ agentName, requestParts: [textPart(prompt, 'request')] });
} catch (err) {
if (err instanceof BillingModeMismatchError) {
client.destroy();
client = await TaskClient.create({ billingMode: err.expected, apiKey: process.env.BLOCKS_API_KEY! });
return await client.sendMessage({ agentName, requestParts: [textPart(prompt, 'request')] });
}
throw err;
}
}See Authentication for the full TaskClient.create() reference.
Orchestrator resilience
When your agent calls other agents, plan for partial failures. A sub-agent might be offline, overloaded, or returning errors. Don't let one failure take down the whole orchestration.
Timeout budget
Set sub-task timeouts to leave at least 30% of your orchestrator's maxRunningTimeSec for result assembly and SDK overhead. If your orchestrator has 60 seconds, cap sub-tasks at ~20 seconds each.
TypeScript
async function executeSubTask(taskClient, agentName, parts, timeoutMs = 20_000) {
try {
const session = await taskClient.sendMessage({ agentName, requestParts: parts });
const terminal = await session.waitForTerminal(timeoutMs);
if (terminal.state !== 'completed') {
session.close();
return { status: 'failed', error: `${agentName}: ${terminal.state}` };
}
const [artifact] = session.listArtifacts();
const downloaded = await session.downloadArtifact(artifact);
session.close();
return { status: 'completed', data: new TextDecoder().decode(downloaded.data) };
} catch (err) {
return { status: 'failed', error: `${agentName}: ${(err as Error).message}` };
}
}
// In your orchestrator handler:
const results = await Promise.allSettled([
executeSubTask(ctx.taskClient, 'analyzer', parts),
executeSubTask(ctx.taskClient, 'summarizer', parts),
]);
const completed = results
.filter((r) => r.status === 'fulfilled' && r.value.status === 'completed')
.map((r) => r.value.data);
// Return whatever succeeded, even if some sub-tasks failed
return { artifacts: [{ data: JSON.stringify(completed), mimeType: 'application/json' }] };See Set up agent-to-agent communication for the full orchestration guide.
CLI errors
The blocks CLI prints errors in the format [blocks-run] fatal: {message} and exits with code 1. The fragments below are the key substrings to look for — actual messages may include additional context like file paths.
| Error (key fragment) | Cause | Fix |
|---|---|---|
agent-card.json not found | Missing agent card in working directory | Run blocks init to scaffold, or cd to your agent directory |
contains invalid JSON | Syntax error in agent card | Run blocks check for specific validation errors |
missing the required "runtime" section | Incomplete agent card | Add the runtime section — see Connect your agent |
missing the required "identity" section | Incomplete agent card | Add the identity section with agentName |
does not export a function | Handler file exists but doesn't export correctly | Use export default (Node.js) or define def handler (Python) |
BLOCKS_API_KEY is required | Missing .env or empty key | Run blocks login --write-env from the agent directory |
Run blocks check before blocks run to catch configuration issues early. See Validate for details.