AssistantMessage includes an error field for API-level errors:
ClaudeAgentSDK.query(prompt: "Hello") do |message|
if message.is_a?(ClaudeAgentSDK::AssistantMessage) && message.error
case message.error
when 'rate_limit' then puts "Rate limited - retry after delay"
when 'authentication_failed' then puts "Check your API key"
when 'billing_error' then puts "Check your billing status"
when 'invalid_request' then puts "Invalid request format"
when 'server_error' then puts "Server error - retry later"
end
end
endSee examples/error_handling_example.rb.
require 'claude_agent_sdk'
begin
ClaudeAgentSDK.query(prompt: "Hello") { |message| puts message }
rescue ClaudeAgentSDK::ControlRequestTimeoutError
puts "Control protocol timed out — consider increasing the timeout"
rescue ClaudeAgentSDK::CLINotFoundError
puts "Please install Claude Code"
rescue ClaudeAgentSDK::ResultError => e
# More specific than ProcessError — must be rescued first.
case e.terminal_reason
when 'api_error' then puts "API failed (HTTP #{e.api_error_status}): #{e.result}"
else puts "Run failed (#{e.subtype}): #{e.errors.join('; ')}"
end
rescue ClaudeAgentSDK::ProcessError => e
puts "Process failed with exit code: #{e.exit_code}"
rescue ClaudeAgentSDK::CLIJSONDecodeError => e
puts "Failed to parse response: #{e}"
endAn ordinary exception raised inside your own callbacks (hooks, can_use_tool,
SDK MCP tool handlers, resource readers and prompt generators) is not raised
to the code above. It is reported to the CLI as a failed callback, and the
session keeps running. exit, Interrupt and other signal exceptions are the
exception: the CLI gets the error response first, and then they propagate as
Ruby normally would. See
Hooks & Permission Callbacks
and MCP Servers.
When a run fails, the CLI emits a result message with is_error: true (which
you still receive as a ResultMessage) and then exits non-zero on purpose,
for shell-script consumers. That trailing process failure carries nothing
beyond "exit code 1", so the SDK replaces it with a ResultError carrying the
payload the CLI already reported — you can branch on why the run failed
without matching on strings:
begin
ClaudeAgentSDK.query(prompt: "...") { |m| handle(m) }
rescue ClaudeAgentSDK::ResultError => e
retry_later if e.terminal_reason == 'api_error' # overloaded / timeout
widen_budget if e.subtype == 'error_max_turns'
raise
endResultError subclasses ProcessError, so existing rescue ProcessError
handlers keep working unchanged — but rescue ResultError first if you
want the structured fields.
The exception message prefers, in order: the CLI's errors[], then result,
then a non-success subtype, then the HTTP status. A run that ends on an API
failure arrives as subtype: "success" with is_error: true and the prose in
result, which is why subtype alone is not used as the message.
A refused resume (a nonexistent session, or a resume_drops_turn guard
failure) reaches you the same way — including on a control request such as the
initial handshake that was still in flight when the CLI exited. Match on
Resume rejected by --resume-drops-turn: in the message and treat it as
deterministic: clear the fork target and resume plainly rather than retrying.
The SDK holds no credentials; the CLI authenticates itself (see the README's Authentication). Neither of the two ways that can fail is reported when the session starts.
No credentials at all (a fresh container, a CI runner). The first prompt
comes back as an AssistantMessage whose error is 'authentication_failed'
(its text is "Not logged in · Please run /login", a command an SDK host cannot
run), followed by a ResultMessage with is_error set. query() and ask
then raise ResultError with terminal_reason == 'api_error'. A Client
session stays open, so check the result's is_error there.
A key or token the API rejects. It ends the same way, with
api_error_status 401, but only after the CLI has retried: ten
APIRetryMessages over about three minutes when tested. Watch for them rather
than waiting for the error:
ClaudeAgentSDK.query(prompt: "Hello") do |message|
next unless message.is_a?(ClaudeAgentSDK::APIRetryMessage)
warn "API retry #{message.attempt}/#{message.max_retries}: HTTP #{message.error_status} (#{message.error})"
endClient#interrupt, #set_model, #set_permission_mode, #rewind_files, #reconnect_mcp_server, #toggle_mcp_server, #stop_task and the other control methods (the Ruby-style model= and permission_mode= included) send a request to the CLI and wait for its answer. They can fail in three ways:
| What happened | Raised |
|---|---|
| The client is not connected, or the connection ended while the request was waiting | CLIConnectionError |
| The CLI did not answer in time | ControlRequestTimeoutError |
The CLI answered with an error: an MCP server name it does not know, a rewind without enable_file_checkpointing, a permission mode it does not accept |
a plain StandardError whose message is the CLI's text |
The third is not a ClaudeSDKError, so rescue ClaudeAgentSDK::ClaudeSDKError does not catch it:
begin
client.reconnect_mcp_server('my-server')
rescue ClaudeAgentSDK::ClaudeSDKError => e
puts "Connection lost or timed out: #{e.message}"
rescue StandardError => e
puts "The CLI refused: #{e.message}" # e.g. "Server not found: my-server"
endRescue StandardError for it, and do not test for the exact class.
The control request timeout defaults to 1200 seconds (20 minutes) to accommodate long-running agent sessions. Override it via environment variable:
export CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS=300 # 5 minutes# Base class of the SDK's error classes (all the ones below)
class ClaudeSDKError < StandardError; end
# Raised when connection to Claude Code fails
class CLIConnectionError < ClaudeSDKError; end
# Raised when the control protocol does not respond in time
class ControlRequestTimeoutError < CLIConnectionError; end
# Raised when Claude Code CLI is not found
class CLINotFoundError < CLIConnectionError
# @param message [String] Error message (default: "Claude Code not found")
# @param cli_path [String, nil] Optional path to the CLI that was not found
end
# Raised by CLIInstaller.install / .install_pinned (and the
# claude_agent_sdk:install_cli rake task) when the CLI cannot be installed.
# Never raised by query, ask or Client
class CLIInstallError < ClaudeSDKError; end
# Raised by the local-disk session APIs when CLAUDE_CONFIG_DIR is unset and
# no usable home directory exists for the default ~/.claude
class ConfigDirError < ClaudeSDKError; end
# Raised when resuming from a SessionStore fails: a store call raised or
# exceeded load_timeout_ms during resume materialization. #cause holds the
# adapter's exception (or the timeout)
class SessionStoreError < ClaudeSDKError; end
# Raised when the Claude Code process fails
class ProcessError < ClaudeSDKError
attr_reader :exit_code, # Integer | nil
:stderr # String | nil
end
# Raised when the CLI exits after reporting a terminal error result.
# Subclasses ProcessError, so existing `rescue ProcessError` keeps working.
class ResultError < ProcessError
attr_reader :subtype, # String | nil ('error_max_turns', 'error_during_execution', ...)
:errors, # Array<String> - error strings from the CLI (may be empty)
:result, # String | nil - result text; holds the "API Error: ..." prose
:api_error_status, # Integer | nil - HTTP status of the failing API call
:terminal_reason, # String | nil - why the run ended ('api_error', 'max_turns', ...)
:session_id, # String | nil
:data, # Hash - raw `result` payload as emitted by the CLI
:original_error # ProcessError | nil - the bare exit error this replaced
end
# Raised when JSON parsing fails
class CLIJSONDecodeError < ClaudeSDKError
attr_reader :line, # String - The line that failed to parse
:original_error # Exception - The original JSON decode exception
end
# Raised when message parsing fails
class MessageParseError < ClaudeSDKError
attr_reader :data # Hash | nil
end| Error | Description |
|---|---|
ClaudeSDKError |
Base class of every error class in this table. One failure is not a ClaudeSDKError: a control request the CLI rejects, see Errors from Client Control Methods |
CLIConnectionError |
Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and ClaudeAgentSDK.ask when the stream ends without a ResultMessage |
ControlRequestTimeoutError |
Control protocol timeout (configurable via env var) |
CLINotFoundError |
Claude Code not installed |
CLIInstallError |
CLIInstaller.install / install_pinned (or the claude_agent_sdk:install_cli rake task) could not install the CLI: unsupported platform, invalid version, HTTP error, a response or download over its size limit, checksum mismatch, or a filesystem error. A wrapped failure keeps the original exception in #cause. This is the error a deploy or bin/setup script rescues; a failed install leaves a previously installed binary in place. See CLI installer |
ConfigDirError |
A local-disk session API (list_sessions, get_session_*, rename_session, ...) could not locate the Claude config directory: CLAUDE_CONFIG_DIR is unset and there is no usable home directory (HOME unset with no passwd entry, as under docker --user in a minimal image, or an empty/relative HOME). Set CLAUDE_CONFIG_DIR |
SessionStoreError |
Resuming from session_store: failed: a store call (#load, #list_sessions, #list_subkeys) raised or exceeded load_timeout_ms while the SDK materialized the transcript, before the CLI started. The message names the call; #cause is the adapter's exception. Before 1.0 this was a bare RuntimeError — see Sessions |
ProcessError |
Process failed (includes exit_code and stderr) — also raised when the CLI is still running 5s after closing stdout and the SDK had to terminate it |
ResultError |
Run ended on a terminal error result (subclasses ProcessError; adds subtype, errors, api_error_status, terminal_reason, ...) — rescue it first |
CLIJSONDecodeError |
JSON parsing issues — including stdout ending mid-frame (a truncated final message; line holds the partial frame) |
MessageParseError |
Message parsing issues |
See lib/claude_agent_sdk/errors.rb for all error types.