DEV Community

Cover image for One extra field turned an A2A error into a TypeError. Fixed in a2a-sdk 1.3.0
Shreya Boyane
Shreya Boyane

Posted on

One extra field turned an A2A error into a TypeError. Fixed in a2a-sdk 1.3.0

On October 27, 2026, SAP announced general availability of inbound Agent2Agent (A2A) interoperability, so third-party agents can now invoke SAP Agents through its Agent Gateway. Every launch like this means more A2A clients calling agents their authors do not control. Two days before SAP's announcement, I had opened a pull request for that exact situation in the A2A Python SDK. An agent could send a valid A2A error, and the calling code would receive a TypeError. My fix shipped the same day as SAP's news, in a2a-sdk 1.3.0.

A2A is an open protocol that lets AI agents discover each other and exchange messages and tasks over JSON-RPC, REST or gRPC. It joined the Agentic AI Foundation in August 2026, and a2a-sdk is its official Python SDK.

The short version

When an agent sent back an error, the SDK handed the reply to a third-party library before looking at it itself. That library is strict about which fields an error may carry, so one extra field, the kind the A2A spec says to ignore, made it throw a raw TypeError. The SDK's own translation of error codes into named exceptions never got a turn. Version 1.3.0 reads the reply itself first, at one place, and the library only builds requests now.

The concept, animated

Animated figure. The reply's path through JsonRpcTransport as labelled blocks: agent reply, the json-rpc package's JSONRPC20Response(**body), the SDK's _create_jsonrpc_error(), your code with its except TaskNotFoundError branch, and three bins for TaskNotFoundError, A2AClientError and result. A token labelled {"code": -32001, "reason": "x"} drops into a raw TypeError tray at the json-rpc block. In 1.3.0 that block lifts out, _check_jsonrpc_response() takes its place, and the token reaches the TaskNotFoundError bin; a body that is a list reaches A2AClientError.

Figure 1. The blocks are the real call chain in client/transports/jsonrpc.py, in order. The token is the reply body. The three states: an error object with one extra field before 1.3.0, the same reply in 1.3.0, and a body that is not an object in 1.3.0.

A client talks to agents it does not control, so odd replies are normal. The SDK's promise about them is clear: a failure reaches your code either as A2AClientError or as a typed A2A error such as TaskNotFoundError. The A2A spec (section 5.7) adds that implementations should ignore fields they do not recognise.

Now follow the token in the first state. It carries the error code -32001, which means TaskNotFoundError, plus one extra field, reason, that the spec says to ignore. The first block it reaches is JSONRPC20Response(**body) from the json-rpc package. It validates the envelope before the SDK sees anything, and it builds the error with a constructor that accepts only code, message and data. Any other member is, to that library, a programming mistake. So the token drops into the tray labelled raw TypeError: JSONRPCError.__init__() got an unexpected keyword argument 'reason'. The SDK's _create_jsonrpc_error() block never receives it, and your except TaskNotFoundError: branch never runs.

The library was correct by its own rules. It simply ran first, so its rules won and the SDK's contract lost.

How I found seven of these

I was exploring the client with one question: what reaches my code when an agent answers wrong? No issue existed, so I played the misbehaving agent myself, wrote seven bad response bodies by hand, and fed them to the client through a mock transport. All seven came back as raw Python exceptions.

What the agent sent Before After
a list, or a string (say, from a proxy) TypeError A2AClientError
neither result nor error ValueError A2AClientError
"error": "boom" TypeError A2AClientError
error object with an extra member TypeError TaskNotFoundError
error object without message ValueError TaskNotFoundError
SSE data: that is not JSON JSONDecodeError A2AClientError
event: error with an extra member TypeError TaskNotFoundError

Following one reply through the code explained the whole table. Nine request methods handed the body straight to the json-rpc validator, and streaming used the same validator on each event. The SDK's own mapping from error codes to exceptions even had a fallback for a missing message, but it could never execute, because the library failed first. Streaming had a second twist: the JSON parse for each event ran in a place where its exception escaped the handler that was supposed to catch it, so a broken data: line leaked a JSONDecodeError.

The fix, in one idea

Still from the third state: the json-rpc block is lifted out and faded, the SDK's _check_jsonrpc_response() block stands in its place, and a token labelled "a list, not an object" sits in the A2AClientError bin.

Figure 2. In 1.3.0 the json-rpc validator is out of the chain. The SDK reads the envelope itself at one choke point, so an error object becomes the typed A2A error whatever extra fields it carries, and a body that is not even an object becomes A2AClientError.

Check the wire envelope yourself, once, and hand libraries only what they can handle. Every reply already passes through one sending function or the streaming loop, so I put one check there. It asks three plain questions in order: is the body an object; if it has an error, is that an object, and if so, map it to the A2A exception by its code; otherwise, is there a result. Anything that fails those questions becomes A2AClientError with a message that says what was wrong. The streaming loop turns a JSONDecodeError into A2AClientError the same way. A falsy error next to a result still counts as success, as before. The client no longer builds JSONRPC20Response objects at all; requests still use JSONRPC20Request.

Wrapping each of the nine calls in its own try/except would have turned TaskNotFoundError into a generic error and copied the same code nine times. One choke point keeps the typed exceptions.

The tests live in a new class, TestMalformedJsonRpcResponses, with 11 cases. Ten fail on main with the exceptions in the table and pass with the change. The eleventh guards that delete_task_push_notification_config still raises on an error response. The existing tests pass unchanged. I ran everything on Windows 11 with Python 3.10.19: lint.sh passed, the transport tests gave 51 passed, and the wider run gave 2213 passed at 93% total coverage. Three failures and four setup errors fail the same way on main, and two integration suites do not run on Windows, so I left those to Linux CI. All 17 checks passed there, and the coverage bot showed client/transports/jsonrpc.py going from 92.66% to 95.51%.

What changed for people using a2a-sdk

If your client calls agents you do not control, version 1.3.0 behaves differently in three ways:

  • An agent that adds its own field to an error object, or leaves out message, now raises the mapped A2A error, so your except TaskNotFoundError: branch runs.
  • A malformed body, such as a list, a string, a missing result or a non-object error, raises A2AClientError, so one except clause covers all of them.
  • A stream with a broken data: line raises A2AClientError where it used to leak a JSONDecodeError.

One behaviour change is worth knowing. A body with "error": null and no result used to parse as an empty object and now raises A2AClientError. That body breaks JSON-RPC 2.0 anyway. Upgrading is pip install -U a2a-sdk.

The v0.3 compatibility transport still uses the same validator-first pattern on main. I kept this PR to the 1.0 client and offered a fix(compat) follow-up in the PR body.

A thank-you to Iwaniukooo11 and the A2A maintainers

I opened PR #1375 on October 25 with no linked issue, just the table and the tests. On October 26, Mateusz Iwaniuk (@Iwaniukooo11) brought the branch up to date with main, approved it and merged it. Thank you. Getting a first PR to a repo reviewed and merged the day after it opens is a generous welcome.

Thanks also to the A2A maintainers for keeping the door open to outside contributors. Of the 40 merges before mine, 29 came from non-members, with a median of 3.4 days from open to merge. Release 1.3.0 carries fixes from at least three outside contributors, mine among them.

What I took away

Where a third-party validator sits in your call chain decides which exceptions your callers see. The stack trace ended inside json-rpc, but the decision that mattered was where the SDK called it.

When you own an error contract, check the wire envelope yourself at one choke point and hand libraries only what they can handle. As more vendors open their agents to outside callers, the exception a client can catch by name is part of the interface, and that boundary is the kind of problem I like to work on.

I'm Shreya Boyane, an ML engineer who builds agent systems, and I'm open to ML and AI engineering roles. You can find my work on GitHub, more about me at shreyaboyane.dev, and I'm happy to connect on LinkedIn.

Top comments (0)