Skip to content

feat: add httpx2 support - #1439

Open
Iwaniukooo11 wants to merge 7 commits into
a2aproject:mainfrom
Iwaniukooo11:mateusziwaniuk/refactor/httpx2-compat-layer
Open

Iwaniukooo11 wants to merge 7 commits into
a2aproject:mainfrom
Iwaniukooo11:mateusziwaniuk/refactor/httpx2-compat-layer

Conversation

@Iwaniukooo11

@Iwaniukooo11 Iwaniukooo11 commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Description

This PR introduces support to client-passed httpx2 connection via httpx_compat

It is backward compatible, which means that passing httpx is also valid and exceptions/typing are properly handled

The compat layer exports catch-tuples for except/isinstance, type aliases for annotations, and httpx_lib

The only use of httpx is left in 2 places:

  • When client didn't provide their own connection (so we are leaving it as is)
  • When checking the status codes (httpx and httpx2 behave the same way here)

Tests

  • Restructured tests, so that they use httpx_lib from httpx_compat
  • Marked the 35 files whose behavior depends on the installed lib with @pytest.mark.httpx2. This includes files that never mention httpx themselves: starlette.testclient picks httpx2 when it is installed, so anything using TestClient is affected too.
  • Added a CI job that installs the new httpx2 dependency group and reruns those tests. The default matrix installs only dev, so it stays an httpx-only environment.

Release-As: 1.4.0

refers to #1288

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/utils/_httpx_compat.py (new) — 64.29% —
Total 93.56% 93.49% 🔴 -0.08%

Generated by coverage-comment.yml

@Iwaniukooo11
Iwaniukooo11 marked this pull request as ready for review November 10, 2026 11:00
@Iwaniukooo11
Iwaniukooo11 requested a review from a team as a code owner November 10, 2026 11:00
@ishymko
ishymko requested review from a team and removed request for a team November 12, 2026 10:27
mykytanetipa added a commit that referenced this pull request Nov 13, 2026
…onfig (#1445)

# Description

`RestTransport.delete_task_push_notification_config` goes through
`_execute_request`, which always calls `response.json()`. The spec
leaves the result to the server
([3.1.10](https://a2a-protocol.org/latest/specification/#3110-delete-push-notification-config):
"Confirmation of deletion (implementation-specific)"), and the SDKs
answer differently:

- this SDK's server answers 200 with `{}`;
- a2a-js answers 204 No Content
([`rest_handler.ts`](https://github.com/a2aproject/a2a-js/blob/3d3ca8291e1f6b1f53293f61c12ba46c36670000/src/server/express/rest_handler.ts#L548-L559));
- a2a-go answers 200 with no body
([`handleDeleteTaskPushConfig`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/a2asrv/rest.go#L536-L557)).

Against a2a-js or a2a-go servers, the config is deleted, but the Python
client then raises `A2AClientError: JSON Decode Error`. A caller that
retries or reports the failure is acting on a delete that succeeded.

The delete now sends its request, checks the status, and does not read
the body. Error responses still go through `_handle_http_error`, so a
404 with `TASK_NOT_FOUND` still raises `TaskNotFoundError`.
`send_http_request` and the JSON-RPC transport are unchanged.

The repro at the end of this description answers the delete the way each
server does. On main (2cc1dd5):

```
a2a-python 200 b'{}' -> ok
a2a-js     204 b''   -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0)
a2a-go     200 b''   -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0)
```

On this branch, all three print `ok`.

```
$ pytest tests/client/transports/test_rest_client.py   # src change reverted
FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[no_content]
FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[empty_200]
2 failed, 44 passed
$ pytest --ignore=tests/integration/cross_version   # this branch, Python 3.10, locked deps
2560 passed, 207 skipped, 4 xfailed, 1 xpassed
$ pytest --cov=src
src/a2a/client/transports/rest.py   87%   (the new lines are covered)
TOTAL   94%
```

The new tests also cover a mapped error and a closed client on this
path. I left the cross-version tests out because their servers don't
start within the 3 s timeout on my machine; the same happens on main.
`ruff check`, `ruff format` and `ty check` pass. `ty` prints only the
three existing unused `type: ignore` warnings.

#1439 touches `send_http_request` and the transport type hints. This
change stays inside `delete_task_push_notification_config` to keep out
of its way, apart from one added import. #1436 percent-encodes the ids
in this method's path, so whichever of the two lands second has a
one-line conflict there; I'll rebase this one if #1436 goes first.

- [x] Follow the [`CONTRIBUTING`
Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
- [x] Make your Pull Request title in the
<https://www.conventionalcommits.org/> specification.
- [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
from the repository root to format)
- [x] Appropriate docs were updated (if necessary)

No issue exists, since the new tests reproduce it.

<details><summary>Repro script</summary>

```python
import asyncio

import httpx

from a2a.client.transports.rest import RestTransport
from a2a.types.a2a_pb2 import AgentCard, DeleteTaskPushNotificationConfigRequest

# How each SDK's server confirms DELETE /tasks/{id}/pushNotificationConfigs/{configId}.
SERVERS = {'a2a-python': (200, b'{}'), 'a2a-js': (204, b''), 'a2a-go': (200, b'')}


async def main() -> None:
    for name, (status, body) in SERVERS.items():
        transport = httpx.MockTransport(lambda r, s=status, b=body: httpx.Response(s, content=b))
        async with httpx.AsyncClient(transport=transport) as http:
            client = RestTransport(http, AgentCard(), 'http://agent.example')
            try:
                await client.delete_task_push_notification_config(
                    DeleteTaskPushNotificationConfigRequest(task_id='t1', id='cfg1')
                )
                print(f'{name:10} {status} {body!r:5} -> ok')
            except Exception as e:
                print(f'{name:10} {status} {body!r:5} -> {type(e).__name__}: {e}')


asyncio.run(main())
```

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: mykytanetipa <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants