Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,7 @@ what gets sent over the wire.*
- receive_json
- ping
- close

## `alias_httpx`

::: httpx2.alias_httpx
230 changes: 230 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# 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
```

### 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. 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.**

```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())
```

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. 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

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 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

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/<version>`. 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t promise every deprecation is visible

This overstates the current runtime behavior: several active deprecations still emit the built-in DeprecationWarning (for example verify=<str>/cert=... in src/httpx2/httpx2/_config.py, per-request cookies in _client.py, and raw data= bytes in _content.py), which is ignored by default when raised from library code. Users migrating those call sites therefore will not see the warnings without configuring filters, contrary to this guide.

Useful? React with 👍 / 👎.


### 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,
Comment thread
Kludex marked this conversation as resolved.
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.
* 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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading