When an A2A call goes wrong, the caller sees one of three things: a transport failure such as a refused connection or a 401, a protocol error object saying the request could not be served, or a successful response carrying a Task that failed or was rejected. Code that mixes these up retries things that can never succeed and gives up on things that would have worked a second later.
This page is a reference for the A2A protocol error layer. It covers the nine A2A-specific errors and how each binding represents them, the reason strings that make them unambiguous, and code for both sides of the wire: a server mapper that emits all three bindings and a client classifier that decides what to do. It was checked against the A2A specification source on 2026-10-02, which lists 1.0.0 as the latest released version. For the wider picture of failures between agents, see the A2A error handling guide; for what to do after a crash or a dropped stream, see A2A error recovery.
Three kinds of failure, and why the distinction matters
Transport and authentication failures happen before A2A semantics apply. Examples are DNS failures, TLS errors, timeouts, and HTTP 401 or 403 from the authentication layer. The specification gives 401 Unauthorized and UNAUTHENTICATED as example codes for authentication problems, and 403 Forbidden and PERMISSION_DENIED for authorization. Neither has an A2A-specific code. Fix credentials or scopes. Do not retry them in a loop.
Protocol errors mean the agent understood that a request arrived but would not or could not serve it. These carry a code, a human-readable message and an optional array of typed details. There are two families: the standard JSON-RPC codes and the nine A2A-specific errors.
Task outcomes are not errors on the wire. If the agent accepts a message, works on it and fails, the call succeeds and returns a Task in TASK_STATE_FAILED. If the agent declines the work, the Task is in TASK_STATE_REJECTED. TASK_STATE_INPUT_REQUIRED and TASK_STATE_AUTH_REQUIRED are interrupted states that wait for the client. Treating a FAILED task as a transport error is a classic bug: resending the message creates a second task rather than repairing the first.
The code table
Section 5.4 of the specification gives the canonical mapping. Every binding must represent all nine errors, and custom bindings must define an equivalent mapping that keeps the meaning. The reason column is the google.rpc.ErrorInfo reason. It is the error name in UPPER_SNAKE_CASE with the Error suffix removed, under the domain a2a-protocol.org.
| Error | JSON-RPC | gRPC | HTTP | ErrorInfo reason | Meaning |
|---|---|---|---|---|---|
TaskNotFoundError | -32001 | NOT_FOUND | 404 | TASK_NOT_FOUND | The task ID is unknown, expired, purged, or not visible to this caller. |
TaskNotCancelableError | -32002 | FAILED_PRECONDITION | 400 | TASK_NOT_CANCELABLE | Cancel was requested on a task already in a terminal state. |
PushNotificationNotSupportedError | -32003 | FAILED_PRECONDITION | 400 | PUSH_NOTIFICATION_NOT_SUPPORTED | Push config was used but the Agent Card says pushNotifications is not supported. |
UnsupportedOperationError | -32004 | FAILED_PRECONDITION | 400 | UNSUPPORTED_OPERATION | The operation, or one aspect of it, is not supported, for example streaming when capabilities.streaming is false. |
ContentTypeNotSupportedError | -32005 | INVALID_ARGUMENT | 400 | CONTENT_TYPE_NOT_SUPPORTED | A media type in the message parts is not accepted by the agent or skill. |
InvalidAgentResponseError | -32006 | INTERNAL | 500 | INVALID_AGENT_RESPONSE | An agent returned a response that does not conform to the spec for the method. |
ExtendedAgentCardNotConfiguredError | -32007 | FAILED_PRECONDITION | 400 | EXTENDED_AGENT_CARD_NOT_CONFIGURED | The extended Agent Card was requested but none is configured. |
ExtensionSupportRequiredError | -32008 | FAILED_PRECONDITION | 400 | EXTENSION_SUPPORT_REQUIRED | The card marks an extension required and the client did not declare it. |
VersionNotSupportedError | -32009 | FAILED_PRECONDITION | 400 | VERSION_NOT_SUPPORTED | The requested A2A-Version is not served by this interface. |
Look at the HTTP column. Seven of the nine errors return 400, and six of the gRPC statuses are FAILED_PRECONDITION. The status code alone cannot tell a cancel race from a version mismatch. That is why the spec requires an ErrorInfo object in the details for A2A-specific errors on the gRPC and HTTP bindings, and recommends one on JSON-RPC. Clients should branch on the reason string and treat the status code only as a fallback.
The standard JSON-RPC 2.0 codes still apply under the JSON-RPC binding: -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params and -32603 internal error. JSON-RPC 2.0 reserves -32000 to -32099 for implementation-defined server errors, and A2A uses -32001 to -32099 for its own codes. Codes -32010 and up are unassigned today, and a later revision may assign them, so do not put your own errors there. Return a standard code and attach an ErrorInfo with your own domain instead.
One error, three wire shapes
Here is the same TaskNotFoundError in each binding. The JSON-RPC binding puts the details array in error.data:
{
"jsonrpc": "2.0",
"id": 2,
"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"}
}]
}
}gRPC returns a google.rpc.Status with code NOT_FOUND and the same ErrorInfo inside status.details. The HTTP+JSON binding returns HTTP 404 with a google.rpc.Status JSON body:
HTTP/1.1 404 Not Found
Content-Type: application/a2a+json
{"error": {"code": 404, "status": "NOT_FOUND",
"message": "The specified task ID does not exist or is not accessible",
"details": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "TASK_NOT_FOUND", "domain": "a2a-protocol.org",
"metadata": {"taskId": "task-123"}}]}}One inconsistency is worth knowing about. Section 11.6 defines HTTP errors as google.rpc.Status JSON. Some of the spec's own worked examples (the version-negotiation and task-listing scenarios) show application/problem+json bodies with fields such as type, title and detail, and one includes a supportedVersions list. Treat 11.6 as the rule when you build a server. When you build a client, accept both shapes, because some peers will copy the examples.
When to raise each error
TaskNotFoundError covers both "does not exist" and "not accessible to you". Use it for another tenant's task too. If you return 403 for a task that exists but is not yours, you have told the caller that the ID is real. TaskNotCancelableError is the normal loser of a race: a cancel arrives while the executor is committing a final state, and whichever transition commits first under the task lock wins. It is not an alarm. The client should read the task and accept its final state.
UnsupportedOperationError has two jobs. The spec requires it when a client calls SendStreamingMessage or SubscribeToTask against a card whose capabilities.streaming is false. The spec also requires it for subscribing to a task that is already terminal; in that case the client falls back to GetTask. PushNotificationNotSupportedError and ExtendedAgentCardNotConfiguredError are capability gaps that the client could have found in the Agent Card before calling.
ContentTypeNotSupportedError is about media types in message parts, such as sending a PDF to a text-only skill. Attach a google.rpc.BadRequest field violation that names the part. ExtensionSupportRequiredError fires when the card marks an extension as required and the request did not list it in the A2A-Extensions service parameter, which is a comma-separated list of extension URIs. VersionNotSupportedError is driven by A2A-Version, sent as a header or query parameter in Major.Minor form. An empty value is treated as 0.3 for backward compatibility, so a 1.0-only server will reject clients that send no header.
InvalidAgentResponseError is different from the others. It describes a response from an agent that breaks the spec, so it is usually raised by whoever received the bad response, such as a client SDK, a gateway or an orchestrator. If a peer returns a malformed Task, surface -32006 to your own caller instead of -32603, so they know the fault is downstream.
Server side: one exception type, three encoders
Keep the error model in one place and encode it at the edge. The sketch below is framework-neutral Python. Handlers raise A2AError, and each binding adapter turns it into its own wire form.
from dataclasses import dataclass, field
DOMAIN = "a2a-protocol.org"
# name -> (json-rpc code, grpc status, http status)
TABLE = {
"TASK_NOT_FOUND": (-32001, "NOT_FOUND", 404),
"TASK_NOT_CANCELABLE": (-32002, "FAILED_PRECONDITION", 400),
"PUSH_NOTIFICATION_NOT_SUPPORTED": (-32003, "FAILED_PRECONDITION", 400),
"UNSUPPORTED_OPERATION": (-32004, "FAILED_PRECONDITION", 400),
"CONTENT_TYPE_NOT_SUPPORTED": (-32005, "INVALID_ARGUMENT", 400),
"INVALID_AGENT_RESPONSE": (-32006, "INTERNAL", 500),
"EXTENDED_AGENT_CARD_NOT_CONFIGURED": (-32007, "FAILED_PRECONDITION", 400),
"EXTENSION_SUPPORT_REQUIRED": (-32008, "FAILED_PRECONDITION", 400),
"VERSION_NOT_SUPPORTED": (-32009, "FAILED_PRECONDITION", 400),
}
@dataclass
class A2AError(Exception):
reason: str # key of TABLE
message: str # safe for the caller to read
metadata: dict = field(default_factory=dict)
extra: list = field(default_factory=list) # e.g. a BadRequest detail
def details(self):
info = {"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": self.reason, "domain": DOMAIN,
"metadata": {k: str(v) for k, v in self.metadata.items()}}
return [info, *self.extra]
def to_jsonrpc(err, req_id):
code, _, _ = TABLE[err.reason]
return {"jsonrpc": "2.0", "id": req_id,
"error": {"code": code, "message": err.message, "data": err.details()}}
def to_http(err):
_, grpc_status, http = TABLE[err.reason]
body = {"error": {"code": http, "status": grpc_status,
"message": err.message, "details": err.details()}}
return http, {"Content-Type": "application/a2a+json"}, body
def cancel_task(store, task_id, principal):
task = store.get(task_id, principal) # None if absent OR not visible
if task is None:
raise A2AError("TASK_NOT_FOUND", "Task not found", {"taskId": task_id})
if task.state in {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED",
"TASK_STATE_CANCELED", "TASK_STATE_REJECTED"}:
raise A2AError("TASK_NOT_CANCELABLE",
f"Task is already {task.state}", {"taskId": task_id})
return store.request_cancel(task)Unexpected exceptions should become -32603 or HTTP 500 with a generic message and a correlation ID in the metadata, never a stack trace. Metadata values are strings, following ErrorInfo's convention.
Client side: normalise, then decide
The client's job is to reduce any of the wire shapes to one decision. This classifier accepts JSON-RPC errors, google.rpc.Status JSON and problem+json bodies, prefers the ErrorInfo reason, and falls back to the numeric code and then the HTTP status.
CODE_TO_REASON = {code: name for name, (code, _, _) in TABLE.items()}
ACTION = {
"TASK_NOT_FOUND": "reconcile", # forget local handle; re-create only if safe
"TASK_NOT_CANCELABLE": "reconcile", # GetTask and accept the terminal state
"UNSUPPORTED_OPERATION": "degrade", # e.g. fall back from streaming to polling
"PUSH_NOTIFICATION_NOT_SUPPORTED": "degrade",
"EXTENDED_AGENT_CARD_NOT_CONFIGURED": "degrade",
"CONTENT_TYPE_NOT_SUPPORTED": "fix_request",
"EXTENSION_SUPPORT_REQUIRED": "fix_request", # add the URI to A2A-Extensions
"VERSION_NOT_SUPPORTED": "fix_request", # re-read the card, pick a version
"INVALID_AGENT_RESPONSE": "report", # downstream agent is broken
}
def classify(http_status, body):
if http_status in (401, 403):
return "auth", None
err = body.get("error") if isinstance(body, dict) else None
if err is None and isinstance(body, dict) and "status" in body and "title" in body:
err = {"code": body["status"], "details": []} # problem+json
if err is None:
return "ok", None # inspect Task state next
details = err.get("data") or err.get("details") or []
reason = next((d.get("reason") for d in details
if d.get("@type", "").endswith("google.rpc.ErrorInfo")
and d.get("domain") == DOMAIN), None)
reason = reason or CODE_TO_REASON.get(err.get("code"))
if reason:
return ACTION.get(reason, "report"), reason
if err.get("code") in (-32603,) or http_status in (500, 502, 503, 504):
return "retry", "INTERNAL"
return "fix_request", err.get("code") # -32700/-32600/-32601/-32602"Retry" here means resend with the same messageId so the server can deduplicate it. That is covered in the A2A idempotency guide. Use capped exponential backoff with jitter, and give up after a fixed budget.
Worked example: a cancel that loses the race
An orchestrator delegates a translation task. The user aborts, and the orchestrator sends CancelTask at the same moment the remote executor writes TASK_STATE_COMPLETED. The executor commits first. The cancel handler then finds a terminal task and raises TASK_NOT_CANCELABLE, so the client receives -32002 with reason TASK_NOT_CANCELABLE. The classifier returns "reconcile". The orchestrator calls GetTask, sees COMPLETED with an artifact, and has to decide whether to discard the result or keep it. The protocol has done its job. What remains is a product decision, and the code should make it visible instead of logging an error.
Now suppose a proxy strips the details array and returns a bare HTTP 400. With no reason and no code, the classifier treats it as a bad request. Proxies must pass error bodies through unchanged, so test error paths through every hop.
Operating it: metrics, alerts and failure modes
- Count by reason, not by status. Emit a counter labelled with method, ErrorInfo reason and peer agent. A rise in VERSION_NOT_SUPPORTED after a deploy means a client population was left behind. A rise in TASK_NOT_FOUND often means task retention is shorter than clients expect.
- Alert on INVALID_AGENT_RESPONSE. It means a contract break between agents, and it usually follows a peer upgrade.
- Do not page on TASK_NOT_CANCELABLE. It is expected under concurrency.
- Messages are for humans and details are for machines. Never parse
message, and never put secrets or stack traces in it. - Keep FAILED tasks out of protocol error metrics. Track task failure rate separately, by skill. Mixing the two hides a broken agent behind a healthy protocol layer, or the other way round.
Trade-offs
| Choice | Benefit | Cost |
|---|---|---|
| Branch on ErrorInfo reason | Unambiguous across bindings | Breaks if a proxy strips details; keep a code fallback |
| Return NOT_FOUND for others' tasks | No existence leak between tenants | Harder support debugging; log the real cause server-side |
| Fail a task (FAILED) vs raise an error | FAILED keeps history and artifacts on the task | Clients must inspect results, not only error paths |
For the request and response mechanics that these errors travel over, read the JSON-RPC request/response flow walkthrough.
What to do next
- Grep your server for every error it can return and check each against the table above: code, gRPC status, HTTP status and reason.
- Make sure every A2A-specific error carries an ErrorInfo with domain
a2a-protocol.org, including on JSON-RPC, where it is recommended rather than required. - Replace any client branching on HTTP status alone with a reason-first classifier like the one above, and make it accept problem+json.
- Separate task failure metrics from protocol error metrics, and label both by peer agent.
- Add one end-to-end test per error that passes through every proxy and gateway in production.
- Decide what happens when a cancel loses the race, and write that behaviour into the orchestrator.