Skip to content
Open
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
63 changes: 63 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# HTTPXodus Migration: reflex-dev/reflex β†’ httpx2

**Branch:** `httpxodus/httpx2-migration`
**Issue:** <https://github.com/reflex-dev/reflex/issues/7034>
**Date:** 2026-09-03
**Mode:** Dual import (httpx2 preferred, httpx fallback)

## What changed

| File | Change |
|------|--------|
| `pyproject.toml` | Added `httpx2 >=2.0; python_version >= '3.10'` next to existing `httpx >=0.26,<1.0` runtime dep |
| `reflex/utils/net.py` | 4 import sites β†’ dual import. The private `get_environment_proxies` import is also dual-bound (httpx2 ships the same helper) |
| `reflex/utils/registry.py` | 1 import site β†’ dual import |
| `reflex/utils/js_runtimes.py` | 1 import site β†’ dual import |
| `reflex/utils/frontend_skeleton.py` | 1 import site β†’ dual import |
| `reflex/utils/templates.py` | 1 import site β†’ dual import |
| `reflex/utils/telemetry.py` | 1 import site β†’ dual import |
| `reflex/custom_components/custom_components.py` | 1 import site β†’ dual import |
| `tests/units/test_telemetry.py` | `httpx_post` fixture: same dual-import pattern so it mocks whichever module wins |
| `tests/units/utils/test_utils.py` | 1 test: same dual-import so the side-effect `httpx.ConnectError` comes from the same module the production code catches |
| `uv.lock` | regenerated by `uv sync` (httpx2 2.12.0 added, httpcore2 transitively pulled in) |

7 production files migrated (matches issue #7034 count exactly). No `AsyncClient` anywhere β€” every site is sync, every call is CLI/framework tooling, nothing leaks into the public API.

## Why dual import, not hard switch

- The repo still ships as a library β€” keeping `httpx >=0.26,<1.0` in runtime deps avoids breaking downstream apps that pin to `httpx` resolvers.
- The issue already framed both options; the dual-import variant is the recommended one in the HTTPXodus charter and matches what `starlette`, `anthropic-sdk`, and `mcp-sdk` chose.
- `httpx2` ships the same private helper (`httpx2._utils.get_environment_proxies`), so the `_httpx_client()` factory in `net.py` works against either package without runtime branching.

## Out of scope (deliberately)

- `reflex-hosting-cli` β€” independent package, separate `pyproject.toml`. Issue notes that covering it would be part of a complete migration story, but it's not in this PR's scope.
- `docs/` β€” the docs site has its own workspace and its own `httpx` usage; it's a separate release.
- `tests/test_node_version.py`, `tests/integration/...` β€” top-level integration tests, also not in `tests/units/`. Most are dev-environment/network-dependent and would not run in this validation pass.

## Validation

`uv sync` succeeded (exit 0). `pytest reflex/utils tests/units/` results:

```
8137 passed, 18 skipped, 447 warnings in 83.01s (0:01:23)
```

`ruff check .` and `ruff format --check` are clean on the touched files.

### Test fixes required by the migration

Two test files had to be aligned with the new import pattern (otherwise `httpx_post` mocks the wrong module and `httpx.ConnectError` side-effects aren't caught by `httpx2.HTTPError`):

1. `tests/units/test_telemetry.py` β€” the `httpx_post` fixture now uses the same dual-import so it mocks whichever module is actually installed.
2. `tests/units/utils/test_utils.py` β€” `test_initialize_agents_md_warns_on_fetch_failure` uses the dual-import to get `httpx.ConnectError` from the same module the production code catches.

Both are minimal, mechanical, and only touch the modules that exercise the migrated code paths.

### Pre-existing test infra issue (not caused by migration)

`tests/units/reflex_base/utils/pyi_generator/test_build_hashes.py::test_build_entrypoint_does_not_touch_pyi_hashes` fails when `ruff` is not on `PATH`. The test invokes the pyi_generator as a subprocess, and the generator itself shells out to `ruff format`. With `PATH=/path/to/.venv/bin:$PATH` (so the venv's `ruff` is visible to the subprocess) the test passes. This is independent of the migration.

## Behavior caveat worth flagging in the changelog

`httpx2` verifies TLS against the **OS trust store** instead of `certifi`. Reflex already has first-class handling for custom `verify=` and proxy mounts (`net._httpx_client()`), but the OS trust-store change can shift behavior in containers and corporate-proxy environments. The CLAUDE.md / AGENTS.md already explains this; worth a one-line mention in the user-facing changelog when this lands.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"click >=8.2",
"granian[reload] >=2.7.4",
"httpx >=0.26,<1.0",
"httpx2 >=2.0; python_version >= '3.10'",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: httpx2 is added to the required dependencies, but the dual-import code across net.py, telemetry.py, registry.py, and the download utilities relies on httpx2 being optional (except ModuleNotFoundError: import httpx). Because httpx2 is now always installed, that fallback is dead code, and every reflex install is forced to pull httpx2 β€” contradicting the PR's stated Option A of keeping it optional for apps that pin httpx. Move it to [project.optional-dependencies] (or drop the fallback if httpx2 is meant to be mandatory).

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At pyproject.toml, line 26:

<comment>httpx2 is added to the required `dependencies`, but the dual-import code across net.py, telemetry.py, registry.py, and the download utilities relies on httpx2 being optional (`except ModuleNotFoundError: import httpx`). Because httpx2 is now always installed, that fallback is dead code, and every reflex install is forced to pull httpx2 β€” contradicting the PR's stated Option A of keeping it optional for apps that pin httpx. Move it to `[project.optional-dependencies]` (or drop the fallback if httpx2 is meant to be mandatory).</comment>

<file context>
@@ -23,6 +23,7 @@ dependencies = [
   "click >=8.2",
   "granian[reload] >=2.7.4",
   "httpx >=0.26,<1.0",
+  "httpx2 >=2.0; python_version >= '3.10'",
   "packaging >=24.2,<27",
   "psutil >=7.0.0,<8.0; sys_platform == 'win32'",
</file context>

"packaging >=24.2,<27",
"psutil >=7.0.0,<8.0; sys_platform == 'win32'",
"python-multipart >=0.0.32,<1.0",
Expand Down
5 changes: 4 additions & 1 deletion reflex/custom_components/custom_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,10 @@ def _collect_details_for_gallery():
Raises:
SystemExit: If pyproject.toml file is ill-formed or the request to the backend services fails.
"""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx
from reflex_cli.utils import hosting

console.rule("[bold]Authentication with Reflex Services")
Expand Down
5 changes: 4 additions & 1 deletion reflex/utils/frontend_skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ def initialize_agents_md(
"""
plan = _plan_agents_md(agents_file, claude_file)

import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

logger.debug(f"Fetching {url}")
try:
Expand Down
5 changes: 4 additions & 1 deletion reflex/utils/js_runtimes.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,10 @@ def download_and_run(url: str, *args, show_status: bool = False, **env):
Raises:
SystemExit: If the script fails to download.
"""
import httpx
try:
import httpx2 as httpx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This try/except dual-import block is duplicated verbatim in 7+ files (net.py has 4 copies). It is not a circular-import case, so it belongs in a shared helper, e.g. def _import_httpx() in reflex/utils/net.py, imported where needed. Extract it so the fallback logic lives in one place.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At reflex/utils/js_runtimes.py, line 238:

<comment>This try/except dual-import block is duplicated verbatim in 7+ files (net.py has 4 copies). It is not a circular-import case, so it belongs in a shared helper, e.g. `def _import_httpx()` in reflex/utils/net.py, imported where needed. Extract it so the fallback logic lives in one place.</comment>

<file context>
@@ -234,7 +234,10 @@ def download_and_run(url: str, *args, show_status: bool = False, **env):
     """
-    import httpx
+    try:
+        import httpx2 as httpx
+    except ModuleNotFoundError:
+        import httpx
</file context>

except ModuleNotFoundError:
import httpx

# Download the script
logger.debug(f"Downloading {url}")
Expand Down
23 changes: 18 additions & 5 deletions reflex/utils/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ def _wrap_https_func(

@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

url = args[0]
logger.debug(f"Sending HTTPS request to {args[0]}")
Expand Down Expand Up @@ -95,7 +98,10 @@ def _is_ipv4_supported() -> bool:
Returns:
True if the system supports IPv4, False otherwise.
"""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

try:
httpx.head("http://1.1.1.1", timeout=3)
Expand All @@ -111,7 +117,10 @@ def _is_ipv6_supported() -> bool:
Returns:
True if the system supports IPv6, False otherwise.
"""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

try:
httpx.head("http://[2606:4700:4700::1111]", timeout=3)
Expand Down Expand Up @@ -150,8 +159,12 @@ def _httpx_client():
Returns:
An HTTPX client.
"""
import httpx
from httpx._utils import get_environment_proxies
try:
import httpx2 as httpx
from httpx2._utils import get_environment_proxies
except ModuleNotFoundError:
import httpx
from httpx._utils import get_environment_proxies

verify_setting = _httpx_verify_kwarg()
return httpx.Client(
Expand Down
5 changes: 4 additions & 1 deletion reflex/utils/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ def latency(registry: str) -> int:
Returns:
int: The latency of the registry in microseconds.
"""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

try:
time_to_respond = net.get(registry, timeout=2).elapsed.microseconds
Expand Down
5 changes: 4 additions & 1 deletion reflex/utils/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,10 @@ def _prepare_event(


def _send_event(event_data: _Event) -> bool:
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

try:
httpx.post(POSTHOG_API_URL, json=event_data)
Expand Down
5 changes: 4 additions & 1 deletion reflex/utils/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ def create_config_init_app_from_remote_template(app_name: str, template_url: str
SystemExit: If any download, file operations fail or unexpected zip file format.

"""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

# Create a temp directory for the zip download.
try:
Expand Down
6 changes: 5 additions & 1 deletion tests/units/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ def httpx_post(mocker: MockerFixture):
Returns:
The mock for ``httpx.post`` so tests can assert on the posted payload.
"""
return mocker.patch("httpx.post")
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx
return mocker.patch.object(httpx, "post")


def test_telemetry():
Expand Down
5 changes: 4 additions & 1 deletion tests/units/utils/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,10 @@ def test_initialize_agents_md_refreshes_managed_section(tmp_path, mocker):

def test_initialize_agents_md_warns_on_fetch_failure(tmp_path, mocker, caplog):
"""Test that a failed fetch warns without writing AGENTS.md or the bridge."""
import httpx
try:
import httpx2 as httpx
except ModuleNotFoundError:
import httpx

agents_file = tmp_path / "AGENTS.md"
claude_file = tmp_path / "CLAUDE.md"
Expand Down
Loading
Loading