From 0564d2375c83e8014232866a6c56ca7496fda89b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 14:46:41 +0200 Subject: [PATCH 1/6] Document migration from httpx --- docs/migration.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 180 insertions(+) create mode 100644 docs/migration.md diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..cb4b0e16 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,179 @@ +# Migrating from HTTPX + +So, you have an application (or a library) using `httpx`, and you want to move to `httpx2`. + +Good news: this is probably the easiest migration you will do this year. + +`httpx2` is a fork of `httpx 0.28.1`. It has the same public API. The same `Client`, the same `AsyncClient`, the same `Response`, the same everything. The main thing that changed is the name. + +Let's see how to do it, step by step. + +## In a Hurry? + +If your application depends on `httpx` directly, the whole migration is: + +* Replace `httpx` with `httpx2` in your dependencies. +* Replace `import httpx` with `import httpx2` in your code. + +```python +import httpx2 + +r = httpx2.get("https://www.example.org/") +``` + +That's it. Everything else works the same. + +If you want an even smaller diff, you can alias the import and keep the rest of your code untouched: + +```python +import httpx2 as httpx + +r = httpx.get("https://www.example.org/") +``` + +Both styles are fine. The alias style is great for a first migration pass in a big codebase, and you can rename properly later (or never, we won't judge). + +## You Can Have Both Installed + +This is the part that matters if you have a **huge codebase**, or dependencies you don't control. + +`httpx` and `httpx2` are different packages, with different import names. They don't conflict. They can be installed in the same environment, at the same time, and each one works normally. + +This means: + +* Your transitive dependencies that still require `httpx` keep working, unchanged. +* You don't have to wait for every library in your dependency tree to migrate before you do. +* You can migrate your own code **incrementally**, one module at a time, one pull request at a time. There is no flag day. + +!!! tip + Don't try to migrate a large application in one giant pull request. Add `httpx2` next to `httpx`, migrate your own modules gradually, and remove the `httpx` pin once nothing of yours (and none of your dependencies) imports it anymore. + +To find what still uses `httpx` in your environment: + +```shell +grep -rn "import httpx\b" src/ +``` + +And to see which installed packages still depend on it: + +```shell +pip install pipdeptree +pipdeptree --reverse --packages httpx +``` + +## What Changed + +The public API did not change. But a rename touches a few visible things, and there are a couple of behavior differences worth knowing about. + +### Renames + +* **Package and import**: `httpx` is now `httpx2`. +* **The CLI**: the command line client is now `httpx2` (installed with `httpx2[cli]`). +* **The User-Agent header**: the default is now `python-httpx2/`. If you have server-side logic or tests matching on `python-httpx/...`, update them. +* **The loggers**: `httpx` is now `httpx2`, and `httpcore.*` is now `httpcore2.*`. If you configure logging by logger name, update those names. +* **The transport package**: `httpcore` is now `httpcore2`. If you import `httpcore` directly (for example, to catch low-level exceptions in a transport), switch those imports to `httpcore2`. + +### Behavior differences + +* **SSL verification uses the operating system's trust store**, via [`truststore`](https://truststore.readthedocs.io/), instead of `certifi`'s bundled certificates. For most users this just means fewer corporate-proxy headaches. The `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables are still honored first, and you can still pass `verify=` explicitly. See [SSL](advanced/ssl.md) for details. +* **Python 3.10 or newer is required**. Support for Python 3.9 was dropped. +* **Deprecation warnings are visible by default**. `httpx2` uses `HTTPXDeprecationWarning`, a `UserWarning` subclass, so you will actually see deprecations without configuring warning filters. + +### New things you get + +Not required for the migration, but nice to have: + +* **Server-Sent Events** support is built in, via `client.sse()`. If you use `httpx-sse`, you can drop it. See [Server-Sent Events](sse.md). +* **WebSockets** support is built in, with the `httpx2[ws]` extra. If you use `httpx-ws`, you can drop it too. See [WebSockets](websockets.md). + +## For Package Maintainers + +If you maintain a library that depends on `httpx`, you have two good options, depending on whether you can release a new major version or not. Both have been battle-tested by real projects. + +!!! warning + Whatever you choose, don't mix the two packages in one code path. `httpx.Response` and `httpx2.Response` are distinct classes: an `isinstance()` check against one will not match objects from the other, and a `httpx2.Client` cannot be passed where your code truly requires a `httpx.Client`. Pick one module per code path, as the patterns below do. + +### If you can release a major version: replace it + +The simple option. Swap the dependency, rename the imports, document it as a breaking change. + +This is what the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) did in [#2972](https://github.com/modelcontextprotocol/python-sdk/pull/2972) for its v2 release: + +* `httpx` (and `httpx-sse`) replaced with `httpx2` in `pyproject.toml`. +* Imports renamed across the codebase. +* The change documented in the migration guide, including the visible differences (trust store verification, logger names, User-Agent). + +For your users, the impact is small: if your API lets them pass their own client (an `http_client=...` parameter, for example), they only need to change which module they build it from. Everything else is internal to your library. + +### If you can't release a major version: support both, prefer `httpx2` + +Maybe your library is stable, widely deployed, and a major version is not on the table. You can still migrate, without breaking anyone. + +The pattern: **try `httpx2` first, fall back to `httpx` with a deprecation warning, and point your install extra at `httpx2`** so that new installs get the new package. + +This is what [Starlette](https://github.com/encode/starlette) did for its `TestClient` in [#3291](https://github.com/encode/starlette/pull/3291): + +```python +import warnings +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import httpx2 as httpx +else: + try: + import httpx2 as httpx + except ModuleNotFoundError: + try: + import httpx + except ModuleNotFoundError: + raise RuntimeError( + "This module requires the httpx2 package to be installed.\n" + "You can install it with:\n" + " $ pip install httpx2\n" + ) from None + else: + warnings.warn( + "Using `httpx` with this package is deprecated; install `httpx2` instead.", + DeprecationWarning, + stacklevel=2, + ) +``` + +Then the rest of the module just uses `httpx.Client`, `httpx.Response`, etc., and it works with whichever package is installed. + +How this behaves: + +* Users with `httpx2` installed get `httpx2`. Silently, no changes needed. +* Users with only `httpx` installed keep working, with a deprecation warning telling them where things are going. +* Users with neither get a clear error pointing at `httpx2`. + +And in your `pyproject.toml`, update the extra (or dependency) that installs the HTTP client so that new installs bring in `httpx2`: + +```toml +[project.optional-dependencies] +full = [ + "httpx2>=2.0.0", +] +``` + +!!! tip + Keep the `TYPE_CHECKING` branch importing a single module (`httpx2`, aliased as `httpx`). That way your type checker always sees one consistent set of types, while the runtime fallback stays flexible. + +!!! note + Use a warning class that is visible by default (a `UserWarning` subclass) if you have one. Starlette added `StarletteDeprecationWarning` for exactly this reason: a deprecation nobody sees is a deprecation that doesn't work. + +Later, when you do get to release a major version, you delete the fallback branch and the pattern collapses into a plain `import httpx2`. + +## Checklist + +A quick summary to review before you ship: + +* Dependencies: `httpx` replaced by (or accompanied by) `httpx2`. +* Imports: `import httpx` replaced by `import httpx2` (or aliased). +* Direct `httpcore` imports switched to `httpcore2`. +* Logging configuration updated for the `httpx2` and `httpcore2.*` logger names. +* Anything matching on the `python-httpx/...` User-Agent updated. +* Custom CA setups verified against the trust store behavior (or `verify=` passed explicitly). +* `httpx-sse` and `httpx-ws` dependencies dropped if you migrate to the built-in [SSE](sse.md) and [WebSockets](websockets.md) support. + +If you get stuck on something not covered here, open a [discussion](https://github.com/pydantic/httpx2/discussions) and we will help you out - and probably update this page too. diff --git a/mkdocs.yml b/mkdocs.yml index 7157734c..cb90ed7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ edit_uri: "" nav: - Introduction: 'index.md' - QuickStart: 'quickstart.md' + - Migrating from HTTPX: 'migration.md' - Advanced: - Clients: 'advanced/clients.md' - Authentication: 'advanced/authentication.md' From dc08d69b0e2ad398bd56ec0fd664ad8f6872cf7a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 14:59:30 +0200 Subject: [PATCH 2/6] Document the type boundary between httpx and httpx2 objects --- docs/migration.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/migration.md b/docs/migration.md index cb4b0e16..2d269df6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -61,6 +61,33 @@ pip install pipdeptree pipdeptree --reverse --packages httpx ``` +### But objects don't cross the boundary + +There is one rule to keep in mind: the two packages can live side by side, but their objects can't stand in for each other. + +`httpx2.Client` and `httpx.Client` are **distinct classes**. Same for `Response`, `Request`, `Timeout`, all of it. If a library that is still on `httpx` accepts a client and checks `isinstance(client, httpx.Client)` internally - or is simply written against `httpx` types - passing it an `httpx2.Client` will fail. + +So, the rule of thumb: **the package that receives the object decides which module you create it from.** + +```python +import httpx +import httpx2 +import some_library_still_on_httpx + +# Your own code: httpx2. +client = httpx2.AsyncClient() + +# A dependency that expects httpx: give it httpx. +some_library_still_on_httpx.configure(http_client=httpx.AsyncClient()) +``` + +The same applies to exceptions: an `httpx.HTTPError` raised inside a still-on-`httpx` dependency will not be caught by `except httpx2.HTTPError`. Catch exceptions from the module used by the code that raises them. + +This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. What you can't do is fully drop `httpx` while a dependency still needs to be handed `httpx` objects. + +!!! note + There is intentionally no automatic aliasing that makes `import httpx` silently resolve to `httpx2`. It has been discussed in [#963](https://github.com/pydantic/httpx2/issues/963), but it would change the behavior of every installed library that imports `httpx`, without those libraries opting in. + ## What Changed The public API did not change. But a rename touches a few visible things, and there are a couple of behavior differences worth knowing about. From 7e30fcc1dd0aa68935fb23c7862b3ea6adb594f3 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 15:39:50 +0200 Subject: [PATCH 3/6] Document `alias_httpx()` in the migration guide and API reference --- docs/api.md | 4 ++++ docs/migration.md | 32 +++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 8aafc873..3a48d790 100644 --- a/docs/api.md +++ b/docs/api.md @@ -257,3 +257,7 @@ what gets sent over the wire.* - receive_json - ping - close + +## `alias_httpx` + +::: httpx2.alias_httpx diff --git a/docs/migration.md b/docs/migration.md index 2d269df6..a1440c4c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -83,10 +83,35 @@ some_library_still_on_httpx.configure(http_client=httpx.AsyncClient()) The same applies to exceptions: an `httpx.HTTPError` raised inside a still-on-`httpx` dependency will not be caught by `except httpx2.HTTPError`. Catch exceptions from the module used by the code that raises them. -This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. What you can't do is fully drop `httpx` while a dependency still needs to be handed `httpx` objects. +This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. What you can't do is fully drop `httpx` while a dependency still needs to be handed `httpx` objects... unless you use the escape hatch below. -!!! note - There is intentionally no automatic aliasing that makes `import httpx` silently resolve to `httpx2`. It has been discussed in [#963](https://github.com/pydantic/httpx2/issues/963), but it would change the behavior of every installed library that imports `httpx`, without those libraries opting in. +### The escape hatch: `alias_httpx()` + +If you want to cross that boundary today - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes - there is `alias_httpx()`: + +```python +import httpx2 + +httpx2.alias_httpx() +``` + +After this call, `import httpx` resolves to `httpx2`, process-wide. Submodule imports included. Dependencies importing `httpx` get the `httpx2` classes, so `isinstance()` checks pass and `except` clauses catch across the boundary. You can pass your `httpx2.Client` anywhere and drop the `httpx` install entirely. + +There are exactly two rules: + +* **Call it first.** It must run at the very top of your entrypoint, before anything imports `httpx`. Modules that already imported `httpx` hold references the alias can't rewrite, so `alias_httpx()` raises a `RuntimeError` instead of half-working. +* **Applications only, never libraries.** It changes the meaning of `import httpx` for the whole process. That is an application's decision to make, not something a library should impose on its users. + +!!! warning + Don't do `sys.modules["httpx"] = httpx2` by hand instead. It looks equivalent, but any `from httpx.something import ...` in a dependency loads the module a second time under the `httpx` name, quietly recreating the two-class-hierarchies problem. `alias_httpx()` handles submodules correctly. + +And a few things no aliasing can fix, so you know where the edges are: + +* `importlib.metadata.version("httpx")` reads installed package metadata, not imports - it will raise or report the old `httpx` version. +* A library configuring `logging.getLogger("httpx")` won't see `httpx2`'s logs (the loggers are `httpx2` and `httpcore2.*`). +* Subprocesses and spawn-based workers re-import from scratch: each new process needs to call `alias_httpx()` too, before importing `httpx`. + +Think of it as a bridge, not a destination: it buys you time while your dependencies migrate, and you remove the call once they have. ## What Changed @@ -202,5 +227,6 @@ A quick summary to review before you ship: * Anything matching on the `python-httpx/...` User-Agent updated. * Custom CA setups verified against the trust store behavior (or `verify=` passed explicitly). * `httpx-sse` and `httpx-ws` dependencies dropped if you migrate to the built-in [SSE](sse.md) and [WebSockets](websockets.md) support. +* Dependencies still importing `httpx`: either keep `httpx` installed for them, or call `alias_httpx()` at the top of your entrypoint. If you get stuck on something not covered here, open a [discussion](https://github.com/pydantic/httpx2/discussions) and we will help you out - and probably update this page too. From 8734942802d1d1dc0d1cc6c592f01fac90a58fcc Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 15:45:36 +0200 Subject: [PATCH 4/6] Fold the aliasing escape hatch into the type boundary section --- docs/migration.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index a1440c4c..7f9b3b1a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -65,7 +65,7 @@ pipdeptree --reverse --packages httpx There is one rule to keep in mind: the two packages can live side by side, but their objects can't stand in for each other. -`httpx2.Client` and `httpx.Client` are **distinct classes**. Same for `Response`, `Request`, `Timeout`, all of it. If a library that is still on `httpx` accepts a client and checks `isinstance(client, httpx.Client)` internally - or is simply written against `httpx` types - passing it an `httpx2.Client` will fail. +`httpx2.Client` and `httpx.Client` are **distinct classes**. Same for `Response`, `Request`, `Timeout`, all of it. If a library that is still on `httpx` accepts a client and checks `isinstance(client, httpx.Client)` internally - or is simply written against `httpx` types - passing it an `httpx2.Client` will fail. The same goes for exceptions: an `httpx.HTTPError` raised inside a still-on-`httpx` dependency will not be caught by `except httpx2.HTTPError`. So, the rule of thumb: **the package that receives the object decides which module you create it from.** @@ -81,13 +81,9 @@ client = httpx2.AsyncClient() some_library_still_on_httpx.configure(http_client=httpx.AsyncClient()) ``` -The same applies to exceptions: an `httpx.HTTPError` raised inside a still-on-`httpx` dependency will not be caught by `except httpx2.HTTPError`. Catch exceptions from the module used by the code that raises them. +This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. -This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. What you can't do is fully drop `httpx` while a dependency still needs to be handed `httpx` objects... unless you use the escape hatch below. - -### The escape hatch: `alias_httpx()` - -If you want to cross that boundary today - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes - there is `alias_httpx()`: +And if you want to remove the boundary entirely - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes - there is an escape hatch, `alias_httpx()`: ```python import httpx2 From 5db6f6d04e1e9573bb1f5a30624fcfa0aa9da780 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 15:47:29 +0200 Subject: [PATCH 5/6] Frame the type boundary as temporary with a clear end state --- docs/migration.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 7f9b3b1a..6ade8d84 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -83,7 +83,9 @@ some_library_still_on_httpx.configure(http_client=httpx.AsyncClient()) This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. -And if you want to remove the boundary entirely - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes - there is an escape hatch, `alias_httpx()`: +And to be clear: this boundary is not a permanent feature of your life. It only exists while both packages are in your environment. Every dependency that moves to `httpx2` removes a piece of it - the [For Package Maintainers](#for-package-maintainers) section below gives maintainers two proven, low-effort paths to do it, so point them there (or send them the pull request yourself, it is usually a small one). The day your last dependency migrates, you uninstall `httpx`, and this whole section stops applying to you. + +If you don't want to wait for that day - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes now - there is an escape hatch, `alias_httpx()`: ```python import httpx2 @@ -107,7 +109,7 @@ And a few things no aliasing can fix, so you know where the edges are: * A library configuring `logging.getLogger("httpx")` won't see `httpx2`'s logs (the loggers are `httpx2` and `httpcore2.*`). * Subprocesses and spawn-based workers re-import from scratch: each new process needs to call `alias_httpx()` too, before importing `httpx`. -Think of it as a bridge, not a destination: it buys you time while your dependencies migrate, and you remove the call once they have. +Think of it as a bridge, not a destination: it gives you the end state today - one set of classes, no `httpx` install - while your dependencies catch up. Once the last one migrates, you delete the call and the bridge was never there. ## What Changed From 7248191e3c0428dd8991c8c8c918fe3c063a2635 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 23 Jul 2026 15:51:47 +0200 Subject: [PATCH 6/6] Apply suggestion from @Kludex --- docs/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index 6ade8d84..990c7fd0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -83,7 +83,7 @@ some_library_still_on_httpx.configure(http_client=httpx.AsyncClient()) This is exactly why incremental migration works: your code moves to `httpx2`, while each dependency keeps receiving the types it expects, until it migrates too. -And to be clear: this boundary is not a permanent feature of your life. It only exists while both packages are in your environment. Every dependency that moves to `httpx2` removes a piece of it - the [For Package Maintainers](#for-package-maintainers) section below gives maintainers two proven, low-effort paths to do it, so point them there (or send them the pull request yourself, it is usually a small one). The day your last dependency migrates, you uninstall `httpx`, and this whole section stops applying to you. +And to be clear: this boundary is not a permanent feature of your life. It only exists while both packages are in your environment. Every dependency that moves to `httpx2` removes a piece of it - the [For Package Maintainers](#for-package-maintainers) section below gives maintainers two proven, low-effort paths to do it, so point them there. The day your last dependency migrates, you uninstall `httpx`, and this whole section stops applying to you. If you don't want to wait for that day - your application is on `httpx2`, but some dependencies still `import httpx`, and you want everyone to share the same classes now - there is an escape hatch, `alias_httpx()`: