From b6b6b0565b1372c05ef89a9fde2dee49f36fd0f6 Mon Sep 17 00:00:00 2001 From: Fredrik Fornwall Date: Fri, 25 Sep 2026 07:58:33 +0200 Subject: [PATCH] feat(deps)!: Migrate from httpx to httpx2 and drop Python 3.9 Replace httpx with httpx2, update imports and documentation, and alias httpx in tests so RESPX mocks intercept requests. Disable the RESPX pytest plugin to ensure aliasing happens before import. Update packaging, CI workflows, and documentation to require Python 3.10 or newer. BREAKING CHANGE: Python 3.9 is no longer supported. Signed-off-by: Fredrik Fornwall --- .github/workflows/ci.yml | 6 +- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 2 +- README.md | 6 +- firebase_admin/_http_client.py | 32 ++++++----- firebase_admin/_retry.py | 33 +++++------ firebase_admin/_utils.py | 23 ++++---- firebase_admin/messaging.py | 14 ++--- requirements.txt | 2 +- setup.cfg | 2 + setup.py | 9 ++- tests/conftest.py | 21 +++++++ tests/test_http_client.py | 30 +++++----- tests/test_messaging.py | 4 +- tests/test_retry.py | 102 ++++++++++++++++----------------- 16 files changed, 157 insertions(+), 133 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4f02efe..66680486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9'] + python: ['3.10', '3.11', '3.12', '3.13', 'pypy3.10'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -60,10 +60,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index adfc47fd..6da167d7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34f1d848..b04791d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 139e7f96..68010fcc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ information on using pull requests. ### Initial Setup -You need Python 3.9+ to build and test the code in this repo. +You need Python 3.10+ to build and test the code in this repo. We recommend using [pip](https://pypi.python.org/pypi/pip) for installing the necessary tools and project dependencies. Most recent versions of Python ship with pip. If your development environment diff --git a/README.md b/README.md index 29303fd4..8601aa83 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,8 @@ requests, code review feedback, and also pull requests. ## Supported Python Versions -We currently support Python 3.9+. However, Python 3.9 support is deprecated, -and developers are strongly advised to use Python 3.10 or higher. Firebase -Admin Python SDK is also tested on PyPy and -[Google App Engine](https://cloud.google.com/appengine/) environments. +We currently support Python 3.10+. Firebase Admin Python SDK is also tested on +PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments. ## Documentation diff --git a/firebase_admin/_http_client.py b/firebase_admin/_http_client.py index 6d258229..565e8a8a 100644 --- a/firebase_admin/_http_client.py +++ b/firebase_admin/_http_client.py @@ -20,7 +20,7 @@ from __future__ import annotations import logging from typing import Any, Dict, Generator, Optional, Tuple, Union -import httpx +import httpx2 import requests.adapters from requests.packages.urllib3.util import retry # pylint: disable=import-error from google.auth import credentials @@ -165,7 +165,7 @@ def __init__(self, **kwargs): def parse_body(self, resp): return resp.json() -class GoogleAuthCredentialFlow(httpx.Auth): +class GoogleAuthCredentialFlow(httpx2.Auth): """Google Auth Credential Auth Flow""" def __init__(self, credential: credentials.Credentials): self._credential = credential @@ -174,7 +174,7 @@ def __init__(self, credential: credentials.Credentials): def apply_auth_headers( self, - request: httpx.Request, + request: httpx2.Request, auth_request: google_auth_requests.Request ) -> None: """A helper function that refreshes credentials if needed and mutates the request headers @@ -189,7 +189,9 @@ def apply_auth_headers( ) logger.debug('Auth headers applied. Credential validity after: %s', self._credential.valid) - def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + def auth_flow( + self, request: httpx2.Request + ) -> Generator[httpx2.Request, httpx2.Response, None]: _original_headers = request.headers.copy() _credential_refresh_attempt = 0 @@ -207,7 +209,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re 'Dispatching request, attempt %d of %d', _credential_refresh_attempt, self._max_refresh_attempts ) - response: httpx.Response = yield request + response: httpx2.Response = yield request if response.status_code in self._refresh_status_codes: if _credential_refresh_attempt < self._max_refresh_attempts: @@ -235,7 +237,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re response.status_code ) break - # The last yielded response is automatically returned by httpx's auth flow. + # The last yielded response is automatically returned by httpx2's auth flow. class HttpxAsyncClient(): """Async HTTP client used to make HTTP/2 calls using HTTPX. @@ -247,7 +249,7 @@ def __init__( self, credential: Optional[credentials.Credentials] = None, base_url: str = '', - headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None, + headers: Optional[Union[httpx2.Headers, Dict[str, str]]] = None, retry_config: HttpxRetry = DEFAULT_HTTPX_RETRY_CONFIG, timeout: int = DEFAULT_TIMEOUT_SECONDS, http2: bool = True @@ -279,7 +281,7 @@ def __init__( } if credential: - self._async_client = httpx.AsyncClient( + self._async_client = httpx2.AsyncClient( http2=http2, timeout=self._timeout, headers=self._headers, @@ -287,7 +289,7 @@ def __init__( mounts=self._mounts ) else: - self._async_client = httpx.AsyncClient( + self._async_client = httpx2.AsyncClient( http2=http2, timeout=self._timeout, headers=self._headers, @@ -306,12 +308,12 @@ def timeout(self): def async_client(self): return self._async_client - async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + async def request(self, method: str, url: str, **kwargs: Any) -> httpx2.Response: """Makes an HTTP call using the HTTPX library. This is the sole entry point to the HTTPX library. All other helper methods in this class call this method to send HTTP requests out. Refer to - https://www.python-httpx.org/api/ for more information on supported options + https://pydantic.dev/docs/httpx2/api/api/ for more information on supported options and features. Args: @@ -332,12 +334,12 @@ class call this method to send HTTP requests out. Refer to resp = await self._async_client.request(method, self.base_url + url, **kwargs) return resp.raise_for_status() - async def headers(self, method: str, url: str, **kwargs: Any) -> httpx.Headers: + async def headers(self, method: str, url: str, **kwargs: Any) -> httpx2.Headers: resp = await self.request(method, url, **kwargs) return resp.headers async def body_and_response( - self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx.Response]: + self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx2.Response]: resp = await self.request(method, url, **kwargs) return self.parse_body(resp), resp @@ -346,11 +348,11 @@ async def body(self, method: str, url: str, **kwargs: Any) -> Any: return self.parse_body(resp) async def headers_and_body( - self, method: str, url: str, **kwargs: Any) -> Tuple[httpx.Headers, Any]: + self, method: str, url: str, **kwargs: Any) -> Tuple[httpx2.Headers, Any]: resp = await self.request(method, url, **kwargs) return resp.headers, self.parse_body(resp) - def parse_body(self, resp: httpx.Response) -> Any: + def parse_body(self, resp: httpx2.Response) -> Any: return resp.json() async def aclose(self) -> None: diff --git a/firebase_admin/_retry.py b/firebase_admin/_retry.py index efd90a74..1f1e0895 100644 --- a/firebase_admin/_retry.py +++ b/firebase_admin/_retry.py @@ -26,7 +26,7 @@ from typing import Any, Callable, List, Optional, Tuple, Coroutine import logging import asyncio -import httpx +import httpx2 logger = logging.getLogger(__name__) @@ -47,8 +47,8 @@ def __init__( backoff_max: float = DEFAULT_BACKOFF_MAX, backoff_jitter: float = 0, history: Optional[List[Tuple[ - httpx.Request, - Optional[httpx.Response], + httpx2.Request, + Optional[httpx2.Response], Optional[Exception] ]]] = None, respect_retry_after_header: bool = False, @@ -68,7 +68,7 @@ def copy(self) -> HttpxRetry: """Creates a deep copy of this instance.""" return copy.deepcopy(self) - def is_retryable_response(self, response: httpx.Response) -> bool: + def is_retryable_response(self, response: httpx2.Response) -> bool: """Determine if a response implies that the request should be retried if possible.""" if self.status_forcelist and response.status_code in self.status_forcelist: return True @@ -98,7 +98,8 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None: else: retry_date_tuple = email.utils.parsedate_tz(retry_after_header) if retry_date_tuple is None: - raise httpx.RemoteProtocolError(f"Invalid Retry-After header: {retry_after_header}") + raise httpx2.RemoteProtocolError( + f"Invalid Retry-After header: {retry_after_header}") retry_date = email.utils.mktime_tz(retry_date_tuple) seconds = retry_date - time.time() @@ -107,7 +108,7 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None: return seconds - def get_retry_after(self, response: httpx.Response) -> float | None: + def get_retry_after(self, response: httpx2.Response) -> float | None: """Determine the Retry-After time needed before sending the next request.""" retry_after_header = response.headers.get('Retry-After', None) if retry_after_header: @@ -133,7 +134,7 @@ async def sleep_for_backoff(self) -> None: logger.debug('Sleeping for backoff of %f seconds following failed request', backoff) await asyncio.sleep(backoff) - async def sleep(self, response: httpx.Response) -> None: + async def sleep(self, response: httpx2.Response) -> None: """Determine and wait the time needed before sending the next request.""" if self.respect_retry_after_header: retry_after = self.get_retry_after(response) @@ -148,8 +149,8 @@ async def sleep(self, response: httpx.Response) -> None: def increment( self, - request: httpx.Request, - response: Optional[httpx.Response] = None, + request: httpx2.Request, + response: Optional[httpx2.Response] = None, error: Optional[Exception] = None ) -> None: """Update the retry state based on request attempt.""" @@ -157,7 +158,7 @@ def increment( self.history.append((request, response, error)) -class HttpxRetryTransport(httpx.AsyncBaseTransport): +class HttpxRetryTransport(httpx2.AsyncBaseTransport): """HTTPX transport with retry logic.""" DEFAULT_RETRY = HttpxRetry(max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5) @@ -170,17 +171,17 @@ def __init__(self, retry: HttpxRetry = DEFAULT_RETRY, **kwargs: Any) -> None: # We use a full AsyncHTTPTransport under the hood that is already # set up to handle requests. We also insure that that transport's internal # retries are not allowed. - self._wrapped_transport = httpx.AsyncHTTPTransport(**transport_kwargs) + self._wrapped_transport = httpx2.AsyncHTTPTransport(**transport_kwargs) - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: return await self._dispatch_with_retry( request, self._wrapped_transport.handle_async_request) async def _dispatch_with_retry( self, - request: httpx.Request, - dispatch_method: Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]] - ) -> httpx.Response: + request: httpx2.Request, + dispatch_method: Callable[[httpx2.Request], Coroutine[Any, Any, httpx2.Response]] + ) -> httpx2.Response: """Sends a request with retry logic using a provided dispatch method.""" # This request config is used across all requests that use this transport and therefore # needs to be copied to be used for just this request and it's retries. @@ -201,7 +202,7 @@ async def _dispatch_with_retry( logger.debug('Sending request in _dispatch_with_retry(): %r', request) response = await dispatch_method(request) logger.debug('Received response: %r', response) - except httpx.HTTPError as err: + except httpx2.HTTPError as err: logger.debug('Received error: %r', err) error = err diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index 0277b9e5..1ebd8ff4 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -20,7 +20,7 @@ import google.auth import requests -import httpx +import httpx2 import firebase_admin from firebase_admin import exceptions @@ -132,7 +132,7 @@ def handle_platform_error_from_requests(error, handle_func=None): return exc if exc else _handle_func_requests(error, message, error_dict) def handle_platform_error_from_httpx( - error: httpx.HTTPError, + error: httpx2.HTTPError, handle_func: Optional[Callable[..., Optional[exceptions.FirebaseError]]] = None ) -> exceptions.FirebaseError: """Constructs a ``FirebaseError`` from the given httpx error. @@ -140,7 +140,7 @@ def handle_platform_error_from_httpx( This can be used to handle errors returned by Google Cloud Platform (GCP) APIs. Args: - error: An error raised by the httpx module while making an HTTP call to a GCP API. + error: An error raised by the httpx2 module while making an HTTP call to a GCP API. handle_func: A function that can be used to handle platform errors in a custom way. When specified, this function will be called with three arguments. It has the same signature as ```_handle_func_httpx``, but may return ``None``. @@ -149,7 +149,7 @@ def handle_platform_error_from_httpx( FirebaseError: A ``FirebaseError`` that can be raised to the user code. """ - if isinstance(error, httpx.HTTPStatusError): + if isinstance(error, httpx2.HTTPStatusError): response = error.response content = response.content.decode() status_code = response.status_code @@ -237,11 +237,11 @@ def handle_requests_error(error, message=None, code=None): err_type = _error_code_to_exception_type(code) return err_type(message=message, cause=error, http_response=error.response) -def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exceptions.FirebaseError: +def _handle_func_httpx(error: httpx2.HTTPError, message, error_dict) -> exceptions.FirebaseError: """Constructs a ``FirebaseError`` from the given GCP error. Args: - error: An error raised by the httpx module while making an HTTP call. + error: An error raised by the httpx2 module while making an HTTP call. message: A message to be included in the resulting ``FirebaseError``. error_dict: Parsed GCP error response. @@ -252,7 +252,8 @@ def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exception return handle_httpx_error(error, message, code) -def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> exceptions.FirebaseError: +def handle_httpx_error( + error: httpx2.HTTPError, message=None, code=None) -> exceptions.FirebaseError: """Constructs a ``FirebaseError`` from the given httpx error. This method is agnostic of the remote service that produced the error, whether it is a GCP @@ -260,7 +261,7 @@ def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> excep any way. Args: - error: An error raised by the httpx module while making an HTTP call. + error: An error raised by the httpx2 module while making an HTTP call. message: A message to be included in the resulting ``FirebaseError`` (optional). If not specified the string representation of the ``error`` argument is used as the message. code: A GCP error code that will be used to determine the resulting error type (optional). @@ -270,15 +271,15 @@ def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> excep Returns: FirebaseError: A ``FirebaseError`` that can be raised to the user code. """ - if isinstance(error, httpx.TimeoutException): + if isinstance(error, httpx2.TimeoutException): return exceptions.DeadlineExceededError( message=f'Timed out while making an API call: {error}', cause=error) - if isinstance(error, httpx.ConnectError): + if isinstance(error, httpx2.ConnectError): return exceptions.UnavailableError( message=f'Failed to establish a connection: {error}', cause=error) - if isinstance(error, httpx.HTTPStatusError): + if isinstance(error, httpx2.HTTPStatusError): if not code: code = _http_status_to_error_code(error.response.status_code) if not message: diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index a108444a..76f8737e 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -24,7 +24,7 @@ import urllib.parse import warnings -import httpx +import httpx2 import requests import firebase_admin @@ -595,7 +595,7 @@ async def send_data(data): url=self._fcm_url, headers=dict(self._fcm_headers), json=data) - except httpx.HTTPError as exception: + except httpx2.HTTPError as exception: return SendResponse(resp=None, exception=self._handle_fcm_httpx_error(exception)) # Catch errors caused by the requests library during authorization except requests.exceptions.RequestException as exception: @@ -732,7 +732,7 @@ async def send_request_async(token: str): json=json_data, ) return {'success': True} - except httpx.HTTPError as error: + except httpx2.HTTPError as error: return self._build_topic_subscription_result_from_httpx_error( error, is_subscribe) except requests.exceptions.RequestException as error: @@ -817,7 +817,7 @@ def _build_topic_subscription_result_from_requests_error(self, error, is_subscri def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe): """Constructs a result dict from an httpx error.""" - response = error.response if isinstance(error, httpx.HTTPStatusError) else None + response = error.response if isinstance(error, httpx2.HTTPStatusError) else None return self._build_topic_subscription_result(response, is_subscribe) def _parse_topic_management_results(self, results) -> TopicManagementResponse: @@ -876,7 +876,7 @@ def _handle_fcm_error(self, error): return _utils.handle_platform_error_from_requests( error, _MessagingService._build_fcm_error_requests) - def _handle_fcm_httpx_error(self, error: httpx.HTTPError) -> exceptions.FirebaseError: + def _handle_fcm_httpx_error(self, error: httpx2.HTTPError) -> exceptions.FirebaseError: """Handles errors received from the FCM API.""" return _utils.handle_platform_error_from_httpx( error, _MessagingService._build_fcm_error_httpx) @@ -921,14 +921,14 @@ def _build_fcm_error_requests(cls, error, message, error_dict): @classmethod def _build_fcm_error_httpx( cls, - error: httpx.HTTPError, + error: httpx2.HTTPError, message: str, error_dict: Optional[Dict[str, Any]] ) -> Optional[exceptions.FirebaseError]: """Parses a httpx error response from the FCM API and creates a FCM-specific exception if appropriate.""" exc_type = cls._build_fcm_error(error_dict) - if isinstance(error, httpx.HTTPStatusError): + if isinstance(error, httpx2.HTTPStatusError): # pylint: disable=not-callable return exc_type( message, cause=error, http_response=error.response) if exc_type else None diff --git a/requirements.txt b/requirements.txt index 7790c0c5..934242b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,4 @@ google-cloud-firestore >= 2.27.0; platform.python_implementation != 'PyPy' google-cloud-storage >= 3.1.1 pyjwt[crypto] >= 2.12.1 cryptography < 44.0.0; platform.python_implementation == 'PyPy' and python_version < '3.11' -httpx[http2] == 0.28.1 \ No newline at end of file +httpx2[http2] == 2.13.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 4c6cf8d8..da08ffc0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,5 @@ [tool:pytest] testpaths = tests asyncio_default_test_loop_scope = class +# The respx pytest plugin imports httpx before tests/conftest.py can alias it to httpx2. +addopts = -p no:respx diff --git a/setup.py b/setup.py index 8950a263..e3ac88c6 100644 --- a/setup.py +++ b/setup.py @@ -22,8 +22,8 @@ (major, minor) = (sys.version_info.major, sys.version_info.minor) -if major != 3 or minor < 9: - print('firebase_admin requires python >= 3.9', file=sys.stderr) +if major != 3 or minor < 10: + print('firebase_admin requires python >= 3.10', file=sys.stderr) sys.exit(1) # Read in the package metadata per recommendations from: @@ -43,7 +43,7 @@ 'google-cloud-storage>=3.1.1', 'pyjwt[crypto] >= 2.10.1', 'cryptography < 44.0.0; platform.python_implementation == "PyPy" and python_version < "3.11"', - 'httpx[http2] == 0.28.1', + 'httpx2[http2] >= 2.13.1, < 3.0.0', ] setup( @@ -61,13 +61,12 @@ keywords='firebase cloud development', install_requires=install_requires, packages=['firebase_admin'], - python_requires='>=3.9', + python_requires='>=3.10', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Topic :: Software Development :: Build Tools', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4ae00f35 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +# Copyright 2026 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Shared pytest configuration.""" + +import httpx2 + +# Alias before importing respx so its mocks intercept httpx2 requests. +httpx2.alias_httpx() diff --git a/tests/test_http_client.py b/tests/test_http_client.py index f1e7f6a6..2747700e 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -15,7 +15,7 @@ """Tests for firebase_admin._http_client.""" from typing import Dict, Optional, Union import pytest -import httpx +import httpx2 import respx from pytest_localserver import http from pytest_mock import MockerFixture @@ -203,8 +203,8 @@ class TestHttpxAsyncClient: def test_init_default(self, mocker: MockerFixture, default_retry_config: HttpxRetry): """Test client initialization with default settings (no credentials).""" - # Mock httpx.AsyncClient and HttpxRetryTransport init to check args passed to them - mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + # Mock httpx2.AsyncClient and HttpxRetryTransport init to check args passed to them + mock_async_client_init = mocker.patch('httpx2.AsyncClient.__init__', return_value=None) mock_transport_init = mocker.patch( 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None ) @@ -216,7 +216,7 @@ def test_init_default(self, mocker: MockerFixture, default_retry_config: HttpxRe assert client._headers == _http_client.METRICS_HEADERS assert client._retry_config == default_retry_config - # Check httpx.AsyncClient call args + # Check httpx2.AsyncClient call args _, init_kwargs = mock_async_client_init.call_args assert init_kwargs.get('http2') is True assert init_kwargs.get('timeout') == DEFAULT_TIMEOUT_SECONDS @@ -237,12 +237,12 @@ def test_init_default(self, mocker: MockerFixture, default_retry_config: HttpxRe def test_init_with_credentials(self, mocker: MockerFixture, default_retry_config: HttpxRetry): """Test client initialization with credentials.""" - # Mock GoogleAuthCredentialFlow, httpx.AsyncClient and HttpxRetryTransport init to + # Mock GoogleAuthCredentialFlow, httpx2.AsyncClient and HttpxRetryTransport init to # check args passed to them mock_auth_flow_init = mocker.patch( 'firebase_admin._http_client.GoogleAuthCredentialFlow.__init__', return_value=None ) - mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + mock_async_client_init = mocker.patch('httpx2.AsyncClient.__init__', return_value=None) mock_transport_init = mocker.patch( 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None ) @@ -258,7 +258,7 @@ def test_init_with_credentials(self, mocker: MockerFixture, default_retry_config # Verify GoogleAuthCredentialFlow was initialized with the credential mock_auth_flow_init.assert_called_once_with(mock_credential) - # Check httpx.AsyncClient call args + # Check httpx2.AsyncClient call args _, init_kwargs = mock_async_client_init.call_args assert init_kwargs.get('http2') is True assert init_kwargs.get('timeout') == DEFAULT_TIMEOUT_SECONDS @@ -279,11 +279,11 @@ def test_init_with_credentials(self, mocker: MockerFixture, default_retry_config def test_init_with_custom_settings(self, mocker: MockerFixture): """Test client initialization with custom settings.""" - # Mock httpx.AsyncClient and HttpxRetryTransport init to check args passed to them + # Mock httpx2.AsyncClient and HttpxRetryTransport init to check args passed to them mock_auth_flow_init = mocker.patch( 'firebase_admin._http_client.GoogleAuthCredentialFlow.__init__', return_value=None ) - mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + mock_async_client_init = mocker.patch('httpx2.AsyncClient.__init__', return_value=None) mock_transport_init = mocker.patch( 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None ) @@ -310,7 +310,7 @@ def test_init_with_custom_settings(self, mocker: MockerFixture): # Verify original headers are not mutated assert headers == {'X-Custom': 'Test'} - # Check httpx.AsyncClient call args + # Check httpx2.AsyncClient call args _, init_kwargs = mock_async_client_init.call_args assert init_kwargs.get('http2') is False assert init_kwargs.get('timeout') == timeout @@ -363,7 +363,7 @@ async def test_request_raise_for_status(self): ] route = respx.request('POST', _TEST_URL).mock(side_effect=responses) - with pytest.raises(httpx.HTTPStatusError) as exc_info: + with pytest.raises(httpx2.HTTPStatusError) as exc_info: resp = await client.request('post', _TEST_URL) resp = exc_info.value.response assert resp.status_code == 404 @@ -451,7 +451,7 @@ async def test_request_with_headers(self): """Test client request with credentials.""" mock_credential = testutils.MockGoogleCredential() - headers = httpx.Headers({'X-Custom': 'Test'}) + headers = httpx2.Headers({'X-Custom': 'Test'}) client = HttpxAsyncClient(credential=mock_credential, headers=headers) responses = [ @@ -583,8 +583,8 @@ async def test_aclose(self): def check_headers( self, - headers: Union[httpx.Headers, Dict[str, str]], - expected_headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None, + headers: Union[httpx2.Headers, Dict[str, str]], + expected_headers: Optional[Union[httpx2.Headers, Dict[str, str]]] = None, has_auth: bool = True, has_metrics: bool = True ): @@ -653,7 +653,7 @@ async def test_auth_headers_retry_exhausted(self, mocker: MockerFixture): ] route = respx.request('POST', _TEST_URL).mock(side_effect=responses) - with pytest.raises(httpx.HTTPStatusError) as exc_info: + with pytest.raises(httpx2.HTTPStatusError) as exc_info: resp = await client.request('post', _TEST_URL) resp = exc_info.value.response assert resp.status_code == 401 diff --git a/tests/test_messaging.py b/tests/test_messaging.py index ae2e4a4d..157c4036 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -18,7 +18,7 @@ from itertools import chain, repeat import json import numbers -import httpx +import httpx2 import requests import respx @@ -2164,7 +2164,7 @@ async def test_send_each_async_error_500_pass_on_retry_config(self): @pytest.mark.asyncio @respx.mock async def test_send_each_async_request_error(self): - responses = httpx.ConnectError("Test request error", request=httpx.Request( + responses = httpx2.ConnectError("Test request error", request=httpx2.Request( 'POST', 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send')) diff --git a/tests/test_retry.py b/tests/test_retry.py index 751fdea7..324276ba 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -19,7 +19,7 @@ from itertools import repeat from unittest.mock import call import pytest -import httpx +import httpx2 from pytest_mock import MockerFixture import respx @@ -39,9 +39,9 @@ async def test_no_retry_on_success(self, base_url: str, mocker: MockerFixture): """Test that a successful response doesn't trigger retries.""" retry_config = HttpxRetry(max_retries=3, status_forcelist=[500]) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) - route = respx.post(base_url).mock(return_value=httpx.Response(200, text="Success")) + route = respx.post(base_url).mock(return_value=httpx2.Response(200, text="Success")) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) response = await client.post(base_url) @@ -57,9 +57,9 @@ async def test_no_retry_on_non_retryable_status(self, base_url: str, mocker: Moc """Test that a non-retryable error status doesn't trigger retries.""" retry_config = HttpxRetry(max_retries=3, status_forcelist=[500, 503]) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) - route = respx.post(base_url).mock(return_value=httpx.Response(404, text="Not Found")) + route = respx.post(base_url).mock(return_value=httpx2.Response(404, text="Not Found")) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) response = await client.post(base_url) @@ -77,12 +77,12 @@ async def test_retry_on_status_code_success_on_last_retry( """Test retry on status code from status_forcelist, succeeding on the last attempt.""" retry_config = HttpxRetry(max_retries=2, status_forcelist=[503, 500], backoff_factor=0.5) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(503, text="Attempt 1 Failed"), - httpx.Response(500, text="Attempt 2 Failed"), - httpx.Response(200, text="Attempt 3 Success"), + httpx2.Response(503, text="Attempt 1 Failed"), + httpx2.Response(500, text="Attempt 2 Failed"), + httpx2.Response(200, text="Attempt 3 Success"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -103,13 +103,13 @@ async def test_retry_exhausted_returns_last_response( """Test that the last response is returned when retries are exhausted.""" retry_config = HttpxRetry(max_retries=1, status_forcelist=[500], backoff_factor=0) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(500, text="Attempt 1 Failed"), - httpx.Response(500, text="Attempt 2 Failed (Final)"), + httpx2.Response(500, text="Attempt 1 Failed"), + httpx2.Response(500, text="Attempt 2 Failed (Final)"), # Should stop after previous response - httpx.Response(200, text="This should not be reached"), + httpx2.Response(200, text="This should not be reached"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -127,11 +127,11 @@ async def test_retry_after_header_seconds(self, base_url: str, mocker: MockerFix retry_config = HttpxRetry( max_retries=1, respect_retry_after_header=True, backoff_factor=100) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '10'}), - httpx.Response(200, text="OK"), + httpx2.Response(429, text="Too Many Requests", headers={'Retry-After': '10'}), + httpx2.Response(200, text="OK"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -150,7 +150,7 @@ async def test_retry_after_header_http_date(self, base_url: str, mocker: MockerF retry_config = HttpxRetry( max_retries=1, respect_retry_after_header=True, backoff_factor=100) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) # Calculate a future time and format as HTTP-date retry_delay_seconds = 60 @@ -159,8 +159,8 @@ async def test_retry_after_header_http_date(self, base_url: str, mocker: MockerF http_date = email.utils.formatdate(retry_time) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(503, text="Maintenance", headers={'Retry-After': http_date}), - httpx.Response(200, text="OK"), + httpx2.Response(503, text="Maintenance", headers={'Retry-After': http_date}), + httpx2.Response(200, text="OK"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -186,13 +186,13 @@ async def test_retry_after_ignored_when_disabled(self, base_url: str, mocker: Mo max_retries=3, respect_retry_after_header=False, status_forcelist=[429], backoff_factor=0.5, backoff_max=10) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), - httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), - httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), - httpx.Response(200, text="OK"), + httpx2.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx2.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx2.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx2.Response(200, text="OK"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -220,13 +220,13 @@ async def test_retry_after_header_missing_backoff_fallback( max_retries=3, respect_retry_after_header=True, status_forcelist=[429], backoff_factor=0.5, backoff_max=10) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(429, text="Too Many Requests"), - httpx.Response(429, text="Too Many Requests"), - httpx.Response(429, text="Too Many Requests"), - httpx.Response(200, text="OK"), + httpx2.Response(429, text="Too Many Requests"), + httpx2.Response(429, text="Too Many Requests"), + httpx2.Response(429, text="Too Many Requests"), + httpx2.Response(200, text="OK"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -251,13 +251,13 @@ async def test_exponential_backoff(self, base_url: str, mocker: MockerFixture): retry_config = HttpxRetry( max_retries=3, status_forcelist=[500], backoff_factor=0.1, backoff_max=10.0) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(500, text="Fail 1"), - httpx.Response(500, text="Fail 2"), - httpx.Response(500, text="Fail 3"), - httpx.Response(200, text="Success"), + httpx2.Response(500, text="Fail 1"), + httpx2.Response(500, text="Fail 2"), + httpx2.Response(500, text="Fail 3"), + httpx2.Response(200, text="Success"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -282,14 +282,14 @@ async def test_backoff_max(self, base_url: str, mocker: MockerFixture): retry_config = HttpxRetry( max_retries=4, status_forcelist=[500], backoff_factor=1, backoff_max=3.0) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(500, text="Fail 1"), - httpx.Response(500, text="Fail 2"), - httpx.Response(500, text="Fail 2"), - httpx.Response(500, text="Fail 4"), - httpx.Response(200, text="Success"), + httpx2.Response(500, text="Fail 1"), + httpx2.Response(500, text="Fail 2"), + httpx2.Response(500, text="Fail 2"), + httpx2.Response(500, text="Fail 4"), + httpx2.Response(200, text="Success"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -314,13 +314,13 @@ async def test_backoff_jitter(self, base_url: str, mocker: MockerFixture): retry_config = HttpxRetry( max_retries=3, status_forcelist=[500], backoff_factor=0.2, backoff_jitter=0.1) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) route = respx.post(base_url).mock(side_effect=[ - httpx.Response(500, text="Fail 1"), - httpx.Response(500, text="Fail 2"), - httpx.Response(500, text="Fail 3"), - httpx.Response(200, text="Success"), + httpx2.Response(500, text="Fail 1"), + httpx2.Response(500, text="Fail 2"), + httpx2.Response(500, text="Fail 3"), + httpx2.Response(200, text="Success"), ]) mock_sleep = mocker.patch('asyncio.sleep', return_value=None) @@ -347,20 +347,20 @@ async def test_error_not_retryable(self, base_url): """Test that non-HTTP errors are raised immediately if not retryable.""" retry_config = HttpxRetry(max_retries=3) transport = HttpxRetryTransport(retry=retry_config) - client = httpx.AsyncClient(transport=transport) + client = httpx2.AsyncClient(transport=transport) # Mock a connection error route = respx.post(base_url).mock( - side_effect=repeat(httpx.ConnectError("Connection failed"))) + side_effect=repeat(httpx2.ConnectError("Connection failed"))) - with pytest.raises(httpx.ConnectError, match="Connection failed"): + with pytest.raises(httpx2.ConnectError, match="Connection failed"): await client.post(base_url) assert route.call_count == 1 class TestHttpxRetry(): - _TEST_REQUEST = httpx.Request('POST', _TEST_URL) + _TEST_REQUEST = httpx2.Request('POST', _TEST_URL) def test_httpx_retry_copy(self, base_url): """Test that `HttpxRetry.copy()` creates a deep copy.""" @@ -411,13 +411,13 @@ def test_parse_retry_after_past_http_date(self, mocker: MockerFixture): def test_parse_retry_after_invalid_date(self): retry = HttpxRetry() - with pytest.raises(httpx.RemoteProtocolError, match='Invalid Retry-After header'): + with pytest.raises(httpx2.RemoteProtocolError, match='Invalid Retry-After header'): retry._parse_retry_after('Invalid Date Format') def test_get_backoff_time_calculation(self): retry = HttpxRetry( max_retries=6, status_forcelist=[503], backoff_factor=0.5, backoff_max=10.0) - response = httpx.Response(503) + response = httpx2.Response(503) # No history -> attempt 1 -> no backoff before first request # Note: get_backoff_time() is typically called *before* the *next* request, # so history length reflects completed attempts.