Error Handling

Every error the protocol defines, from both sides: raising each one in a server handler, what it looks like on the wire, and rescuing each error class in the client.

The Error Boundary

Your handler block runs inside an error boundary (A2A::Agent):

  • Unmatched operation — a case ... in with no matching branch raises NoMatchingPatternError, which becomes A2A::UnsupportedOperationError (-32004). You never need an else branch.
  • A2A::Error raised (or returned) — formatted by the binding layer into the spec-compliant error response. Raising and returning are equivalent; raise from deep in helper code, return when it reads better.
  • Anything else raised — logged and returned as a JSON-RPC internal error (-32603, HTTP 500). The exception details never leak to the client.

Raising Every Protocol Error

Each spec error is an A2A::Error subclass carrying its JSON-RPC code, HTTP status (for the REST binding), and structured google.rpc.ErrorInfo detail data. In handler context:

TaskNotFoundError — -32001 / HTTP 404

The task ID doesn’t correspond to an existing or accessible task:

in "GetTask"
  id   = env["a2a.request"].id
  task = TASKS[id] or raise A2A::TaskNotFoundError.new(id)

TaskNotCancelableError — -32002 / HTTP 400

The task is already in a terminal state:

in "CancelTask"
  id   = env["a2a.request"].id
  task = TASKS[id] or raise A2A::TaskNotFoundError.new(id)

  if TERMINAL_STATES.include?(task[:state])
    raise A2A::TaskNotCancelableError.new(id, state: task[:state])
  end

PushNotificationNotSupportedError — -32003 / HTTP 400

The client used push notification operations but your agent card declares capabilities.pushNotifications: false:

in "CreateTaskPushNotificationConfig"
  unless env["a2a.agent_card"].dig("capabilities", "pushNotifications")
    raise A2A::PushNotificationNotSupportedError.new
  end

UnsupportedOperationError — -32004 / HTTP 400

Returned automatically for any operation your case doesn’t match. Raise it explicitly to refuse a variant of an operation you otherwise support:

in "SendMessage"
  msg = env["a2a.request"].message
  if msg.task_id && TERMINAL_STATES.include?(TASKS[msg.task_id]&.dig(:state))
    raise A2A::UnsupportedOperationError.new(message: "Task is in a terminal state")
  end

ContentTypeNotSupportedError — -32005 / HTTP 400

A media type in the request’s message parts isn’t supported by the agent:

in "SendMessage"
  env["a2a.request"].message.parts.each do |part|
    media_type = part.media_type
    next if media_type.to_s.empty? || media_type == "text/plain"

    raise A2A::ContentTypeNotSupportedError.new(media_type)
  end

InvalidAgentResponseError — -32006 / HTTP 500

A downstream agent returned something that doesn’t conform to the spec — typically raised by orchestrators that delegate over A2A::Client:

in "SendMessage"
  result = A2A::Client.new(REMOTE_AGENT_URL).send_message(message: forwarded)
  task = result.task or raise A2A::InvalidAgentResponseError.new(
    message: "Remote agent returned no task"
  )

ExtendedAgentCardNotConfiguredError — -32007 / HTTP 400

GetExtendedAgentCard was called but you don’t provide one:

in "GetExtendedAgentCard"
  raise A2A::ExtendedAgentCardNotConfiguredError.new unless EXTENDED_CARD
  A2A::Protocol::JsonSchema["Agent Card"].new(EXTENDED_CARD)

ExtensionSupportRequiredError — -32008 / HTTP 400

Your agent card marks an extension required: true but the client didn’t declare support for it:

in "SendMessage"
  declared = env["a2a.request"].message&.extensions || []
  unless declared.include?("https://example.com/ext/geo-coordinates/v1")
    raise A2A::ExtensionSupportRequiredError.new("https://example.com/ext/geo-coordinates/v1")
  end

VersionNotSupportedError — -32009 / HTTP 400

The client requested a protocol version (via the A2A-Version header) you don’t support:

version = env["HTTP_A2A_VERSION"]
if version && version != "1.0"
  raise A2A::VersionNotSupportedError.new(version)
end

InvalidParamsError — -32602 / HTTP 400

Structurally valid params that fail your semantic validation. (Schema validation is already done by the middleware before your block runs — this is for rules the schema can’t express.)

in "SendMessage"
  text = env["a2a.message"]
  if text.to_s.strip.empty?
    raise A2A::InvalidParamsError.new("message must contain non-empty text", fields: ["message.parts"])
  end

Internal::Errors::PushNotificationConfigNotFoundError — -32001 / HTTP 404

The task exists but the requested push config ID doesn’t. Not a spec error — an implementation detail this library provides (it reports on the wire as TASK_NOT_FOUND):

in "GetTaskPushNotificationConfig"
  request = env["a2a.request"]
  task    = TASKS[request.task_id] or raise A2A::TaskNotFoundError.new(request.task_id)

  config = task[:push_configs].find { |c| c["id"] == request.id }
  unless config
    raise A2A::Internal::Errors::PushNotificationConfigNotFoundError.new(request.task_id, request.id)
  end

Custom Errors — base A2A::Error

For conditions the spec doesn’t name, construct the base class with your own code and HTTP status:

raise A2A::Error.new("Agent is overloaded, retry later", code: -32000, http_status: 503)

Summary

Error class JSON-RPC HTTP Raised when
A2A::TaskNotFoundError.new(task_id) -32001 404 Unknown/expired task ID
A2A::TaskNotCancelableError.new(task_id, state:) -32002 400 Cancel on a terminal-state task
A2A::PushNotificationNotSupportedError.new -32003 400 Push ops without the capability
A2A::UnsupportedOperationError.new(message: "...") -32004 400 Unmatched operation (automatic) or refused variant
A2A::ContentTypeNotSupportedError.new(content_type) -32005 400 Unsupported media type in parts
A2A::InvalidAgentResponseError.new -32006 500 Downstream agent returned junk
A2A::ExtendedAgentCardNotConfiguredError.new -32007 400 No extended card configured
A2A::ExtensionSupportRequiredError.new(extension) -32008 400 Required extension not declared
A2A::VersionNotSupportedError.new(version) -32009 400 Unsupported A2A-Version
A2A::InvalidParamsError.new(message, fields:) -32602 400 Semantic validation failure
A2A::Internal::Errors::PushNotificationConfigNotFoundError.new(task_id, config_id) -32001 404 Unknown push config ID
A2A::Error.new(message, code:, http_status:) custom custom Anything else

On the Wire

The JSON-RPC binding delivers errors inside the envelope, always over HTTP 200:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Task not found",
    "data": [{
      "@type": "type.googleapis.com/google.rpc.ErrorInfo",
      "reason": "TASK_NOT_FOUND",
      "domain": "a2a-protocol.org",
      "metadata": {"taskId": "task-123"}
    }]
  }
}

The REST binding uses the error’s HTTP status with an application/problem+json body:

{
  "type": "error",
  "title": "Task not found",
  "status": 404,
  "detail": [{
    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
    "reason": "TASK_NOT_FOUND",
    "domain": "a2a-protocol.org",
    "metadata": {"taskId": "task-123"}
  }]
}

Handling Errors Client-Side

A2A::Client raises one error class per binding, plus a local validation error. All three:

A2A::JsonRpcError (JSON-RPC binding)

Raised when the server returns a JSON-RPC error envelope. code is the protocol code; http_status is always 200 (the error lives in the envelope, not the HTTP status):

client = A2A::Client.new(url) # binding: :json_rpc is the default

begin
  client.get_task(id: "no-such-task")
rescue A2A::JsonRpcError => e
  e.code                              # => -32001
  e.message                           # => "Task not found"
  e.error_data&.first&.dig("reason")  # => "TASK_NOT_FOUND"
  e.error_data&.first&.dig("metadata", "taskId") # => "no-such-task"
end

A2A::RestError (REST binding)

Raised on any HTTP 4xx/5xx with a problem+json body. http_status (and code) carry the HTTP status:

client = A2A::Client.new(url, binding: :rest)

begin
  client.get_task(id: "no-such-task")
rescue A2A::RestError => e
  e.http_status                       # => 404
  e.message                           # => "Task not found"
  e.error_data&.first&.dig("reason")  # => "TASK_NOT_FOUND"
end

Binding-Agnostic Handling

When the binding is configurable (as in the Rails integration test), rescue both:

begin
  client.cancel_task(id: "task-abc")
rescue A2A::JsonRpcError, A2A::RestError => e
  e.message # => "Operation not supported: CancelTask"
end

Both are A2A::Error subclasses, so rescue A2A::Error also works — but that additionally catches A2A::Protocol::JsonSchema::ValidationError below, which usually indicates a bug in your code rather than a server-reported condition.

A2A::Protocol::JsonSchema::ValidationError (local)

Raised before anything is sent when the request params don’t validate against the operation’s request schema:

begin
  client.send_message(message: "not a hash")
rescue A2A::Protocol::JsonSchema::ValidationError => e
  e.message # describes which schema property failed
end

Transport Errors

Connection refused, DNS failure, and timeouts surface as ordinary Faraday exceptions (e.g. Faraday::ConnectionFailed) — rescue them separately if the remote agent may be unreachable:

begin
  client.agent_card
rescue Faraday::Error => e
  # remote agent down / unreachable
end

This site uses Just the Docs, a documentation theme for Jekyll.