Skip to content
Draft
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
45 changes: 37 additions & 8 deletions google/genai/_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
pass


try:
import httpx2
except ImportError:
httpx2 = None # type: ignore[assignment]


if TYPE_CHECKING:
from multidict import CIMultiDictProxy

Expand All @@ -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."""
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions google/genai/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,27 @@
import requests
from . import _common

try:
import httpx2
except ImportError:
httpx2 = None # type: ignore[assignment]


if TYPE_CHECKING:
from .replay_api_client import ReplayResponse
import aiohttp
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
141 changes: 141 additions & 0 deletions google/genai/tests/client/test_httpx2_client.py
Original file line number Diff line number Diff line change
@@ -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}']
24 changes: 21 additions & 3 deletions google/genai/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down