diff --git a/google/genai/_api_client.py b/google/genai/_api_client.py index 4d90ba86e..5268a0113 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -80,6 +80,12 @@ pass +try: + import httpx2 +except ImportError: + httpx2 = None # type: ignore[assignment] + + if TYPE_CHECKING: from multidict import CIMultiDictProxy @@ -93,6 +99,27 @@ _MULTI_REGIONAL_LOCATIONS = {'us', 'eu'} +# httpx2 (https://github.com/pydantic/httpx2) is a drop-in fork of httpx under a +# separate import namespace, so its classes are not instances of the httpx +# equivalents. Widen the runtime type checks to accept either when httpx2 is +# installed. +_HTTPX_RESPONSE_TYPES = ( + (httpx.Response,) if httpx2 is None else (httpx.Response, httpx2.Response) +) +_HTTPX_HEADERS_TYPES = ( + (httpx.Headers,) if httpx2 is None else (httpx.Headers, httpx2.Headers) +) +_HTTPX_TRANSIENT_EXC = ( + (httpx.TimeoutException, httpx.ConnectError) + if httpx2 is None + else ( + httpx.TimeoutException, + httpx.ConnectError, + httpx2.TimeoutException, + httpx2.ConnectError, + ) +) + class EphemeralTokenAPIKeyError(ValueError): """Error raised when the API key is invalid.""" @@ -256,7 +283,7 @@ def __init__( ): if isinstance(headers, dict): self.headers = headers - elif isinstance(headers, httpx.Headers): + elif isinstance(headers, _HTTPX_HEADERS_TYPES): self.headers = { key: ', '.join(headers.get_list(key)) for key in headers.keys() } @@ -339,7 +366,7 @@ def _copy_to_dict(self, response_payload: dict[str, object]) -> None: def _iter_response_stream(self) -> Iterator[str]: """Iterates over chunks retrieved from the API.""" if not ( - isinstance(self.response_stream, httpx.Response) + isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES) or isinstance(self.response_stream, requests.Response) ): raise TypeError( @@ -350,7 +377,7 @@ def _iter_response_stream(self) -> Iterator[str]: chunk = '' balance = 0 data_buffer: list[str] = [] - if isinstance(self.response_stream, httpx.Response): + if isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES): response_stream = self.response_stream.iter_lines() else: response_stream = self.response_stream.iter_lines(decode_unicode=True) @@ -389,7 +416,9 @@ def _iter_response_stream(self) -> Iterator[str]: async def _aiter_response_stream(self) -> AsyncIterator[str]: """Asynchronously iterates over chunks retrieved from the API.""" - is_valid_response = isinstance(self.response_stream, httpx.Response) or ( + is_valid_response = isinstance( + self.response_stream, _HTTPX_RESPONSE_TYPES + ) or ( has_aiohttp and isinstance(self.response_stream, aiohttp.ClientResponse) ) if not is_valid_response: @@ -403,7 +432,7 @@ async def _aiter_response_stream(self) -> AsyncIterator[str]: balance = 0 data_buffer: list[str] = [] # httpx.Response has a dedicated async line iterator. - if isinstance(self.response_stream, httpx.Response): + if isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES): try: async for line in self.response_stream.aiter_lines(): if not line: @@ -540,7 +569,7 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict: retriable_codes = options.http_status_codes or _RETRY_HTTP_STATUS_CODES retry = tenacity.retry_if_exception( lambda e: (isinstance(e, errors.APIError) and e.code in retriable_codes) - or isinstance(e, (httpx.TimeoutException, httpx.ConnectError)), + or isinstance(e, _HTTPX_TRANSIENT_EXC), ) wait = tenacity.wait_exponential_jitter( initial=options.initial_delay or _RETRY_INITIAL_DELAY, @@ -1416,7 +1445,7 @@ def _request_once( headers=http_request.headers, timeout=http_request.timeout, ) - response = self._httpx_client.send(httpx_request, stream=stream) # type: ignore[union-attr] + response = self._httpx_client.send(httpx_request, stream=stream) # type: ignore[union-attr, arg-type] errors.APIError.raise_for_response(response) return HttpResponse( response.headers, response if stream else [response.text] @@ -1527,7 +1556,7 @@ async def _async_request_once( timeout=http_request.timeout, ) client_response = await self._async_httpx_client.send( # type: ignore[union-attr] - httpx_request, + httpx_request, # type: ignore[arg-type] stream=stream, ) await errors.APIError.raise_for_async_response(client_response) diff --git a/google/genai/errors.py b/google/genai/errors.py index 48bf1b131..09f89a9b4 100644 --- a/google/genai/errors.py +++ b/google/genai/errors.py @@ -21,6 +21,11 @@ import requests from . import _common +try: + import httpx2 +except ImportError: + httpx2 = None # type: ignore[assignment] + if TYPE_CHECKING: from .replay_api_client import ReplayResponse @@ -28,6 +33,15 @@ from google.auth.aio.transport.aiohttp import Response as AsyncAuthorizedSessionResponse +# httpx2 (https://github.com/pydantic/httpx2) is a drop-in fork of httpx under a +# separate import namespace, so its Response is not an instance of +# httpx.Response. Widen the runtime type checks to accept either when httpx2 is +# installed. +_HTTPX_RESPONSE_TYPES = ( + (httpx.Response,) if httpx2 is None else (httpx.Response, httpx2.Response) +) + + class APIError(Exception): """General errors raised by the GenAI API.""" code: int @@ -129,7 +143,7 @@ def raise_for_response( if response.status_code == 200: return - if isinstance(response, httpx.Response): + if isinstance(response, _HTTPX_RESPONSE_TYPES): try: response.read() response_json = response.json() @@ -198,7 +212,7 @@ async def raise_for_async_response( ], ) -> None: """Raises an error with detailed error message if the response has an error status.""" - if isinstance(response, httpx.Response): + if isinstance(response, _HTTPX_RESPONSE_TYPES): if response.status_code == 200: return try: diff --git a/google/genai/tests/client/test_httpx2_client.py b/google/genai/tests/client/test_httpx2_client.py new file mode 100644 index 000000000..1ac80fee0 --- /dev/null +++ b/google/genai/tests/client/test_httpx2_client.py @@ -0,0 +1,141 @@ +# Copyright 2025 Google LLC +# +# 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. +# + +"""Tests for injecting a Pydantic `httpx2` client into the SDK. + +`httpx2` (https://github.com/pydantic/httpx2) is a drop-in fork of `httpx` under +a separate import namespace, so `httpx2` classes fail `isinstance(x, httpx.*)`. +These tests pin the two "walls" that must be widened for the SDK to accept an +injected `httpx2` client (see issue #2680): + +- WALL 1: `HttpOptions.httpx_async_client` / `httpx_client` must accept an + `httpx2` client at construction (aliases in the generated `types.py`). +- WALL 2: the hand-written `isinstance` checks in `errors.py` / `_api_client.py` + must recognize an `httpx2.Response`. +""" + +import pytest + +httpx2 = pytest.importorskip('httpx2') + +from ... import _api_client as api_client +from ... import Client +from ... import errors +from ...types import HttpOptions + + +# WALL 1 — the client must be accepted at construction. +def test_http_options_accepts_httpx2_clients(): + # Previously raised ValidationError because the aliases were typed to the + # httpx clients only, so Pydantic emitted an is_instance_of(httpx.*) check. + http_options = HttpOptions( + httpx_client=httpx2.Client(trust_env=False), + httpx_async_client=httpx2.AsyncClient(trust_env=False), + ) + assert isinstance(http_options.httpx_client, httpx2.Client) + assert isinstance(http_options.httpx_async_client, httpx2.AsyncClient) + + +def test_constructor_with_httpx2_clients(): + mldev_client = Client( + api_key='google_api_key', + http_options={ + 'httpx_client': httpx2.Client(trust_env=False), + 'httpx_async_client': httpx2.AsyncClient(trust_env=False), + }, + ) + assert not mldev_client.models._api_client._httpx_client.trust_env + assert not mldev_client.models._api_client._async_httpx_client.trust_env + + vertexai_client = Client( + vertexai=True, + project='fake_project_id', + location='fake-location', + http_options={ + 'httpx_client': httpx2.Client(trust_env=False), + 'httpx_async_client': httpx2.AsyncClient(trust_env=False), + }, + ) + assert not vertexai_client.models._api_client._httpx_client.trust_env + assert not vertexai_client.models._api_client._async_httpx_client.trust_env + + +# WALL 2 — the response must be processed by the error/raise functions. +def test_raise_for_response_httpx2_success(): + assert ( + errors.APIError.raise_for_response(httpx2.Response(status_code=200)) + is None + ) + + +def test_raise_for_response_httpx2_client_error(): + class FakeResponse(httpx2.Response): + + def read(self) -> bytes: + self._content = ( + b'{"error": {"code": 400, "message": "error message", "status":' + b' "INVALID_ARGUMENT"}}' + ) + return self._content + + with pytest.raises(errors.ClientError) as exc_info: + errors.APIError.raise_for_response(FakeResponse(status_code=400)) + assert exc_info.value.code == 400 + assert exc_info.value.message == 'error message' + assert exc_info.value.status == 'INVALID_ARGUMENT' + + +@pytest.mark.asyncio +async def test_raise_for_async_response_httpx2_success(): + # The async success path early-returns from inside the isinstance branch, so + # without widening it every httpx2 response (success and error) is rejected. + assert ( + await errors.APIError.raise_for_async_response( + httpx2.Response(status_code=200) + ) + is None + ) + + +@pytest.mark.asyncio +async def test_raise_for_async_response_httpx2_client_error(): + class FakeResponse(httpx2.Response): + + async def aread(self) -> bytes: + self._content = ( + b'{"error": {"code": 400, "message": "error message", "status":' + b' "INVALID_ARGUMENT"}}' + ) + return self._content + + with pytest.raises(errors.ClientError) as exc_info: + await errors.APIError.raise_for_async_response(FakeResponse(status_code=400)) + assert exc_info.value.code == 400 + assert exc_info.value.message == 'error message' + assert exc_info.value.status == 'INVALID_ARGUMENT' + + +# WALL 2 — an httpx2.Response must flow through the async streaming iterator. +@pytest.mark.asyncio +async def test_httpx2_response_flows_through_async_stream(): + response = httpx2.Response( + status_code=200, + content=b'data: {"first": 1}\n\ndata: {"second": 2}\n\n', + ) + http_response = api_client.HttpResponse(headers={}, response_stream=response) + + chunks = [chunk async for chunk in http_response._aiter_response_stream()] + + assert chunks == ['{"first": 1}', '{"second": 2}'] diff --git a/google/genai/types.py b/google/genai/types.py index feffb5756..f9a2a5b58 100644 --- a/google/genai/types.py +++ b/google/genai/types.py @@ -109,9 +109,14 @@ def __getattr__(name: str) -> Any: _is_httpx_imported = False if typing.TYPE_CHECKING: import httpx - - HttpxClient = httpx.Client - HttpxAsyncClient = httpx.AsyncClient + import httpx2 + + # httpx2 (https://github.com/pydantic/httpx2) is a drop-in fork of httpx under + # a separate import namespace, so an httpx2 client is not an instance of the + # httpx client. Widen the aliases so HttpOptions accepts either. This file is + # generated ("DO NOT EDIT"); the real fix belongs in the generator template. + HttpxClient = Union[httpx.Client, httpx2.Client] + HttpxAsyncClient = Union[httpx.AsyncClient, httpx2.AsyncClient] _is_httpx_imported = True else: HttpxClient: typing.Type = Any @@ -127,6 +132,19 @@ def __getattr__(name: str) -> Any: HttpxClient = None HttpxAsyncClient = None + try: + import httpx2 + + if _is_httpx_imported: + HttpxClient = Union[httpx.Client, httpx2.Client] + HttpxAsyncClient = Union[httpx.AsyncClient, httpx2.AsyncClient] + else: + HttpxClient = httpx2.Client + HttpxAsyncClient = httpx2.AsyncClient + _is_httpx_imported = True + except ImportError: + pass + _is_aiohttp_imported = False if typing.TYPE_CHECKING: from aiohttp import ClientSession diff --git a/requirements.txt b/requirements.txt index 322d9fe27..6d368991b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ absl-py==2.1.0 annotated-types==0.7.0 -anyio==4.8.0 +anyio==4.14.2 cachetools==5.5.0 certifi==2024.8.30 charset-normalizer==3.4.0 coverage==7.6.9 distro==1.9.0 httpx==0.28.1 +httpx2==2.7.0; python_version >= '3.10' google-auth==2.47.0 -idna==3.10 +idna==3.18 iniconfig==2.0.0 packaging==24.2 pillow==11.0.0