From d3443767c2c1dc0ea3cc6e12d086fe390e31e266 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 13:30:03 +1000 Subject: [PATCH 1/4] chore: replace httpx usage with httpx2 --- auth/management.py | 4 ++-- auth/validator.py | 6 +++--- auth0/client.py | 20 ++++++++++---------- auth0/user_info.py | 4 ++-- db/models.py | 2 +- galaxy/client.py | 2 +- register/tokens.py | 4 ++-- routers/admin.py | 2 +- routers/biocommons_register.py | 2 +- routers/user.py | 2 +- scheduled_tasks/tasks.py | 2 +- services/institutions.py | 4 ++-- tests/admin_api/test_admin.py | 6 +++--- tests/auth/test_auth_management.py | 2 +- tests/auth/test_auth_validator.py | 4 ++-- tests/db/test_models.py | 2 +- tests/galaxy/test_client.py | 4 ++-- tests/scheduled_tasks/test_tasks.py | 2 +- tests/test_auth0_client.py | 2 +- tests/test_biocommons_admin.py | 2 +- tests/test_biocommons_register.py | 8 ++++---- tests/test_user.py | 2 +- tests/test_utils.py | 6 +++--- 23 files changed, 47 insertions(+), 47 deletions(-) diff --git a/auth/management.py b/auth/management.py index 747321b9..cd4c9eb3 100644 --- a/auth/management.py +++ b/auth/management.py @@ -1,6 +1,6 @@ from typing import Annotated -import httpx +import httpx2 from cachetools import TTLCache from fastapi import Depends @@ -24,7 +24,7 @@ def get_management_token(settings: Annotated[Settings, Depends(get_settings)]): "client_secret": settings.auth0_management_secret, "audience": f"https://{settings.auth0_domain}/api/v2/", } - response = httpx.post(url, json=payload) + response = httpx2.post(url, json=payload) response.raise_for_status() data = response.json() token = data["access_token"] diff --git a/auth/validator.py b/auth/validator.py index 8bb80025..32b7dfaf 100644 --- a/auth/validator.py +++ b/auth/validator.py @@ -4,7 +4,7 @@ import weakref from datetime import UTC, datetime, timedelta -import httpx +import httpx2 import jwt from cachetools import TTLCache from fastapi import HTTPException @@ -106,7 +106,7 @@ async def _fetch_rsa_keys(auth0_domain: str) -> dict: try: metadata_url = f"https://{auth0_domain}/.well-known/openid-configuration" - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: metadata_response = await client.get(metadata_url) metadata_response.raise_for_status() metadata = metadata_response.json() @@ -118,7 +118,7 @@ async def _fetch_rsa_keys(auth0_domain: str) -> dict: except KeyError as exc: logger.error(f"OIDC metadata from {metadata_url} did not include jwks_uri") raise InvalidTokenError("Failed to fetch JWKS") from exc - except (httpx.HTTPError, ValueError) as exc: + except (httpx2.HTTPError, ValueError) as exc: logger.error( f"Failed to fetch OIDC metadata or JWKS for domain {auth0_domain}: {exc}" ) diff --git a/auth0/client.py b/auth0/client.py index 9363c7ad..5b8bce80 100644 --- a/auth0/client.py +++ b/auth0/client.py @@ -4,9 +4,9 @@ import time from typing import Iterator, Optional, Type, TypeVar -import httpx +import httpx2 from fastapi import Depends -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from pydantic import BaseModel, EmailStr, Field, HttpUrl, model_validator from auth.management import get_management_token @@ -192,7 +192,7 @@ def __init__(self, domain: str, management_token: str): self.domain = domain self.api_base = f"https://{domain}/api/v2" self.management_token = management_token - self._client = httpx.Client(headers={"Authorization": f"Bearer {management_token}"}) + self._client = httpx2.Client(headers={"Authorization": f"Bearer {management_token}"}) def close(self) -> None: self._client.close() @@ -206,16 +206,16 @@ def __exit__(self, exc_type, exc, tb) -> None: T = TypeVar('T', bound=BaseModel) @staticmethod - def _convert_list(resp: httpx.Response, model: Type[T]) -> list[T]: + def _convert_list(resp: httpx2.Response, model: Type[T]) -> list[T]: """Convert a list of data to the given pydantic model.""" return [model(**item) for item in resp.json()] @staticmethod - def _convert_users(resp: httpx.Response): + def _convert_users(resp: httpx2.Response): return Auth0Client._convert_list(resp, Auth0UserData) @staticmethod - def _convert_roles(resp: httpx.Response): + def _convert_roles(resp: httpx2.Response): return Auth0Client._convert_list(resp, RoleData) def get_users(self, page: Optional[int] = None, per_page: Optional[int] = None, include_totals: Optional[bool] = None, q: Optional[str] = None) -> list[Auth0UserData] | UsersWithTotals: @@ -332,7 +332,7 @@ def export_and_download_users( logger.info(f"User export job {job_id} completed successfully. Downloading from {location}...") # Don't use client for this, we don't want the auth header here - download = httpx.get(location) + download = httpx2.get(location) download.raise_for_status() content = download.content @@ -365,7 +365,7 @@ def check_user_password(self, email: str, password: str, settings: Settings) -> "scope": "openid", } # We don't want the management token here so not using self._client - resp = httpx.post(url, data=data) + resp = httpx2.post(url, data=data) if resp.status_code in {400, 403}: error = resp.json().get("error") if error == "invalid_grant": @@ -408,7 +408,7 @@ def remove_roles_from_user(self, user_id: str, role_id: str | list[str]): url = f"{self.api_base}/users/{user_id}/roles" if isinstance(role_id, str): role_id = [role_id] - # httpx.Client.delete() no longer accepts json payloads (0.28+), so use request() + # httpx2.Client.delete() no longer accepts json payloads (0.28+), so use request() resp = self._client.request("DELETE", url, json={"roles": role_id}) resp.raise_for_status() return True @@ -611,7 +611,7 @@ def trigger_password_change(self, user_email: str, client_id: str, settings: Set # NOTE: Authentication API, not management API url = f"https://{self.domain}/dbconnections/change_password" # Don't use _client here, since it's not a management API endpoint - resp = httpx.post( + resp = httpx2.post( url, json={"email": user_email, "client_id": client_id, diff --git a/auth0/user_info.py b/auth0/user_info.py index 915a4465..5cdb345b 100644 --- a/auth0/user_info.py +++ b/auth0/user_info.py @@ -1,6 +1,6 @@ from typing import Annotated -import httpx +import httpx2 from fastapi.params import Depends from pydantic import BaseModel, ConfigDict, Field @@ -47,7 +47,7 @@ async def get_auth0_user_info( Doesn't require management API access so may be more efficient when only the current user is required """ - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: resp = await client.get( f"https://{settings.auth0_domain}/userinfo", headers={ "Authorization": f"Bearer {auth0_token}" diff --git a/db/models.py b/db/models.py index cc8488e7..5ce14dc5 100644 --- a/db/models.py +++ b/db/models.py @@ -4,7 +4,7 @@ from logging import getLogger from typing import Optional, Self, Type -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from pydantic import AwareDatetime from sqlalchemy import Column, Index, String, Text, UniqueConstraint, delete, desc, func from sqlmodel import DateTime, Field, Relationship, Session, select diff --git a/galaxy/client.py b/galaxy/client.py index b2fc29ed..9cad493c 100644 --- a/galaxy/client.py +++ b/galaxy/client.py @@ -1,7 +1,7 @@ from typing import Annotated from fastapi import Depends -from httpx import Client +from httpx2 import Client from galaxy.config import GalaxySettings, get_galaxy_settings from galaxy.schemas import GalaxyUserModel diff --git a/register/tokens.py b/register/tokens.py index 1eeaa671..8da24e79 100644 --- a/register/tokens.py +++ b/register/tokens.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime, timedelta -import httpx +import httpx2 import jwt from fastapi import HTTPException from jwt.exceptions import InvalidTokenError @@ -35,7 +35,7 @@ def verify_registration_token(token: str, settings: Settings): def validate_recaptcha(token: str, settings: Settings): - response = httpx.post( + response = httpx2.post( url="https://www.google.com/recaptcha/api/siteverify", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={"secret": settings.recaptcha_secret, "response": token}, diff --git a/routers/admin.py b/routers/admin.py index 0e59a5ab..9dd6042c 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response from fastapi.params import Query -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from pydantic import ( BaseModel, ConfigDict, diff --git a/routers/biocommons_register.py b/routers/biocommons_register.py index abae4d59..7eb89a37 100644 --- a/routers/biocommons_register.py +++ b/routers/biocommons_register.py @@ -2,7 +2,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Response -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from sqlmodel import Session from auth0.client import Auth0Client, get_auth0_client diff --git a/routers/user.py b/routers/user.py index 50f5f3c8..c01e37ff 100644 --- a/routers/user.py +++ b/routers/user.py @@ -8,7 +8,7 @@ from botocore.exceptions import ClientError from fastapi import APIRouter, Body, Depends, HTTPException, Response, status -from httpx import AsyncClient, HTTPStatusError +from httpx2 import AsyncClient, HTTPStatusError from loguru import logger from pydantic import AliasPath, AwareDatetime, BaseModel, Field from pydantic import BaseModel as PydanticBaseModel diff --git a/scheduled_tasks/tasks.py b/scheduled_tasks/tasks.py index fd868861..bc053861 100644 --- a/scheduled_tasks/tasks.py +++ b/scheduled_tasks/tasks.py @@ -9,7 +9,7 @@ from typing import Any from uuid import UUID -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from loguru import logger from pydantic import BaseModel, field_validator from sqlalchemy.exc import IntegrityError diff --git a/services/institutions.py b/services/institutions.py index dbb6b144..4ef0103a 100644 --- a/services/institutions.py +++ b/services/institutions.py @@ -1,6 +1,6 @@ from typing import Optional -import httpx +import httpx2 GALAXY_AU_VALIDATE_URL = "https://site.usegalaxy.org.au/institution/validate" @@ -13,7 +13,7 @@ async def check_australian_research_institution_email(email: str) -> Optional[bo that must not act on an outage should treat None distinctly from False. """ try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx2.AsyncClient(timeout=5.0) as client: response = await client.get(GALAXY_AU_VALIDATE_URL, params={"email": email}) response.raise_for_status() return bool(response.json().get("valid", False)) diff --git a/tests/admin_api/test_admin.py b/tests/admin_api/test_admin.py index be6561a5..8c966db3 100644 --- a/tests/admin_api/test_admin.py +++ b/tests/admin_api/test_admin.py @@ -2123,7 +2123,7 @@ def test_admin_update_user_username_duplicate_in_auth0( persistent_factories, ): """Test username update fails when Auth0 returns 409 conflict.""" - from httpx import HTTPStatusError, Request, Response + from httpx2 import HTTPStatusError, Request, Response user = _create_user_with_platform_membership( db_session=test_db_session, @@ -2170,7 +2170,7 @@ def test_admin_update_user_username_auth0_400_error( persistent_factories, ): """Test username update handles Auth0 400 error.""" - from httpx import HTTPStatusError, Request, Response + from httpx2 import HTTPStatusError, Request, Response user = _create_user_with_platform_membership( db_session=test_db_session, @@ -2244,7 +2244,7 @@ def test_get_unverified_users(test_client, test_db_session, as_admin_user, galax def test_auth0client_get_users_forwards_filter_to_httpx(mocker): client = Auth0Client(domain="tenant.example.auth0.com", management_token="tok") - # Mock the underlying httpx client and its response + # Mock the underlying httpx2 client and its response fake_resp = mocker.Mock() fake_resp.json.return_value = [] # get_users() reads .json() only client._client = mocker.Mock() diff --git a/tests/auth/test_auth_management.py b/tests/auth/test_auth_management.py index ca78b704..1306a2e2 100644 --- a/tests/auth/test_auth_management.py +++ b/tests/auth/test_auth_management.py @@ -4,7 +4,7 @@ def test_get_management_token_success(mock_settings): - with patch("auth.management.httpx.post") as mock_post: + with patch("auth.management.httpx2.post") as mock_post: mock_post.return_value.json.return_value = {"access_token": "abc123"} mock_post.return_value.raise_for_status = lambda: None diff --git a/tests/auth/test_auth_validator.py b/tests/auth/test_auth_validator.py index 4e8f2d45..74dd8807 100644 --- a/tests/auth/test_auth_validator.py +++ b/tests/auth/test_auth_validator.py @@ -20,7 +20,7 @@ from fastapi.security import HTTPAuthorizationCredentials from fastapi.testclient import TestClient from freezegun import freeze_time -from httpx import Request, Response +from httpx2 import Request, Response from jwt import InvalidSignatureError from jwt.algorithms import RSAAlgorithm @@ -150,7 +150,7 @@ async def test_get_rsa_key_returns_key(mock_settings: Settings): jwks_url = f"https://{mock_settings.auth0_domain}/.well-known/jwks.json" with patch("auth.validator.jwt.get_unverified_header", return_value=unverified_header), \ - patch("auth.validator.httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: + patch("auth.validator.httpx2.AsyncClient.get", new_callable=AsyncMock) as mock_get: metadata_response = Response( 200, json={"jwks_uri": jwks_url}, diff --git a/tests/db/test_models.py b/tests/db/test_models.py index fdc09894..fcffdfba 100644 --- a/tests/db/test_models.py +++ b/tests/db/test_models.py @@ -4,7 +4,7 @@ import pytest import respx from freezegun import freeze_time -from httpx import Response +from httpx2 import Response from mimesis import Person from mimesis.locales import Locale from sqlalchemy.exc import IntegrityError diff --git a/tests/galaxy/test_client.py b/tests/galaxy/test_client.py index 9439c4d9..0069f440 100644 --- a/tests/galaxy/test_client.py +++ b/tests/galaxy/test_client.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from galaxy.client import GalaxyClient @@ -15,7 +15,7 @@ def test_username_exists(galaxy_client, respx_mock): user1 = GalaxyUserFactory.build(username="user1") user2 = GalaxyUserFactory.build(username="user2") respx_mock.get("https://galaxy.example.com/api/users").mock( - return_value=httpx.Response( + return_value=httpx2.Response( 200, json=[user1.model_dump(mode="json"), user2.model_dump(mode="json")] diff --git a/tests/scheduled_tasks/test_tasks.py b/tests/scheduled_tasks/test_tasks.py index 22fe2d0c..f440d719 100644 --- a/tests/scheduled_tasks/test_tasks.py +++ b/tests/scheduled_tasks/test_tasks.py @@ -6,7 +6,7 @@ import pytest from botocore.exceptions import ClientError, EndpointConnectionError -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from sqlmodel import Session, select from db.models import ( diff --git a/tests/test_auth0_client.py b/tests/test_auth0_client.py index cb262c80..cd9fbbb0 100644 --- a/tests/test_auth0_client.py +++ b/tests/test_auth0_client.py @@ -4,7 +4,7 @@ import pytest import respx -from httpx import Response +from httpx2 import Response from pydantic import ValidationError from auth0.client import ( diff --git a/tests/test_biocommons_admin.py b/tests/test_biocommons_admin.py index dbe6a64a..94c02eda 100644 --- a/tests/test_biocommons_admin.py +++ b/tests/test_biocommons_admin.py @@ -2,7 +2,7 @@ import pytest import respx -from httpx import Response +from httpx2 import Response from sqlmodel import select from db.models import Auth0Role, BiocommonsGroup, Platform diff --git a/tests/test_biocommons_register.py b/tests/test_biocommons_register.py index 66f54434..186fef86 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -2,7 +2,7 @@ import pytest import respx -from httpx import Response +from httpx2 import Response from sqlmodel import select from starlette.exceptions import HTTPException @@ -632,7 +632,7 @@ def test_biocommons_registration_auth0_conflict_error( mock_recaptcha_verify, ): """Test handling of Auth0 conflict error (user already exists)""" - from httpx import HTTPStatusError, Request, Response + from httpx2 import HTTPStatusError, Request, Response response = Response(409, json={"error": "user_exists"}) request = Request("POST", "https://example.com") @@ -700,7 +700,7 @@ def test_biocommons_registration_email_conflict_error( test_client, tsi_group, mock_auth0_client, test_db_session, mock_recaptcha_verify, ): """Test handling of Auth0 conflict error when email exists""" - from httpx import HTTPStatusError, Request, Response + from httpx2 import HTTPStatusError, Request, Response response = Response(409, json={"error": "user_exists"}) request = Request("POST", "https://example.com") @@ -732,7 +732,7 @@ def test_biocommons_registration_both_conflict_error( mock_recaptcha_verify, ): """Test handling of Auth0 conflict error when both username and email exist""" - from httpx import HTTPStatusError, Request, Response + from httpx2 import HTTPStatusError, Request, Response response = Response(409, json={"error": "user_exists"}) request = Request("POST", "https://example.com") diff --git a/tests/test_user.py b/tests/test_user.py index 636a160b..230291a1 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -6,7 +6,7 @@ import pytest import respx from fastapi import HTTPException -from httpx import HTTPStatusError, Request, Response +from httpx2 import HTTPStatusError, Request, Response from sqlmodel import Session, select from db.models import ( diff --git a/tests/test_utils.py b/tests/test_utils.py index 5fdff0d7..ade16a40 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ -import httpx +import httpx2 import pytest import respx -from httpx import Response +from httpx2 import Response from sqlmodel import select from auth0.client import get_auth0_client @@ -216,7 +216,7 @@ def test_check_australian_research_institution_upstream_error_returns_false(test @respx.mock def test_check_australian_research_institution_upstream_timeout_returns_false(test_client): - respx.get(GALAXY_AU_VALIDATE_URL).mock(side_effect=httpx.TimeoutException("timeout")) + respx.get(GALAXY_AU_VALIDATE_URL).mock(side_effect=httpx2.TimeoutException("timeout")) resp = test_client.get( "/utils/register/check-australian-research-institution", params={"email": "researcher@sydney.edu.au"}, From 65909704bb755ed8c5c22f8f2f1fe72159e32d50 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 13:30:43 +1000 Subject: [PATCH 2/4] feat: replace httpx dependency with httpx2 --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18c82b2f..62bb5508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi[standard]>=0.139.0", # Security floor for CVE-2026-26007 (subgroup validation for SECT curves) "cryptography>=49.0", - "httpx>=0.28.1", + "httpx2>=2.9.1", "psycopg[binary]>=3.3.4", "email-validator~=2.3", "pydantic-settings>=2.14.2", @@ -31,7 +31,6 @@ dependencies = [ [project.optional-dependencies] dev = [ "boto3-stubs>=1.43.45", - "httpx2>=2.9.1", "pytest>=9.0.3", "pytest-mock>=3.14.0", "pytest-sugar>=1.1.1", From c1eb990cce53745e951de67b62946a3b9fcd9cd0 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 13:31:04 +1000 Subject: [PATCH 3/4] fix: workarounds to make respx work with httpx2 --- pyproject.toml | 3 ++- tests/conftest.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 62bb5508..9a5c9150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ exclude-newer = "7 days" [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] -addopts = "--cov=auth --cov=routers --cov=schemas --cov=db --cov=biocommons --cov=scheduled_tasks --cov-report=term --cov-report=xml" +addopts = "-p no:respx --cov=auth --cov=routers --cov=schemas --cov=db --cov=biocommons --cov=scheduled_tasks --cov-report=term --cov-report=xml" [build-system] requires = ["setuptools>=61.0"] @@ -70,4 +70,5 @@ line-length = 88 target-version = "py313" lint.select = ["E", "F", "I"] lint.ignore = ["E501"] +lint.per-file-ignores = { "tests/conftest.py" = ["E402"] } exclude = ["tests/data", ".venv", "venv", "migrations"] diff --git a/tests/conftest.py b/tests/conftest.py index 4fc12449..ecdf9948 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,13 @@ +import httpx2 + +# respx only patches the real `httpx`/`httpcore`; alias them to httpx2 so it +# intercepts requests made through our httpx2-based clients. Must run before +# anything else imports httpx or httpcore, so its pytest11 entry point plugin +# is disabled (see `-p no:respx` in pyproject.toml) and loaded manually here. +httpx2.alias_httpx() + +pytest_plugins = ["respx.plugin"] + import os import warnings from datetime import datetime From 7da47f97f25c127d5a9f584aca984afb289ea345 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 13:31:26 +1000 Subject: [PATCH 4/4] chore: update lock file --- uv.lock | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 64b0482a..c4911941 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ dependencies = [ { name = "cryptography" }, { name = "email-validator" }, { name = "fastapi", extra = ["standard"] }, - { name = "httpx" }, + { name = "httpx2" }, { name = "itsdangerous" }, { name = "loguru" }, { name = "prometheus-fastapi-instrumentator" }, @@ -41,7 +41,6 @@ dev = [ { name = "boto3-stubs" }, { name = "faker" }, { name = "freezegun" }, - { name = "httpx2" }, { name = "mimesis" }, { name = "moto" }, { name = "polyfactory" }, @@ -68,8 +67,7 @@ requires-dist = [ { name = "faker", marker = "extra == 'dev'", specifier = ">=40.4.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.139.0" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5.2" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.9.1" }, + { name = "httpx2", specifier = ">=2.9.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "mimesis", marker = "extra == 'dev'", specifier = "~=18.0" },