From 3d9ad92543d4902e42408dcf2943efc981678115 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 27 Oct 2026 17:39:47 -0700 Subject: [PATCH] feat(python): unify GAOS HTTP errors onto native APIError and export GAOS error classes Have 4xx/5xx GAOS compatibility error classes (`APIStatusError`, `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `UnprocessableEntityError`, `RateLimitError`, `InternalServerError`, and unmapped 4xx status errors) also inherit from native `google.genai.errors.APIError` (`ClientError` for 4xx, `ServerError` for 5xx) via multiple inheritance, and populate the native `code`, `status`, and `details` attributes alongside existing `status_code` and `body` attributes. Non-HTTP and schema validation errors (`APIConnectionError`, `APITimeoutError`, `APIResponseValidationError`) remain compat-only. Also export the GAOS error classes on `google.genai.interactions`, `google.genai.private.interactions`, `google.genai.errors` (with `InteractionsAPIError` aliasing compat `APIError`), and `google.genai.private.errors`, and deduplicate `_gaos_private.lib.compat_errors` against `_gaos.lib.compat_errors`. FUTURE_COPYBARA_INTEGRATE_REVIEW=https://github.com/googleapis/python-genai/pull/3209 from googleapis:release-please--branches--main 39c95f2f004970ff5d9fd941aef3a09a98d3a79e PiperOrigin-RevId: 1008500737 --- google/genai/_gaos/lib/compat_errors.py | 85 +++++++--- google/genai/_interactions/__init__.py | 2 +- google/genai/errors.py | 62 ++++++- google/genai/interactions.py | 30 ++++ .../tests/interactions/test_httpx_compat.py | 153 +++++++++++++++++- 5 files changed, 307 insertions(+), 25 deletions(-) diff --git a/google/genai/_gaos/lib/compat_errors.py b/google/genai/_gaos/lib/compat_errors.py index 7bd524927e..6646b276a5 100644 --- a/google/genai/_gaos/lib/compat_errors.py +++ b/google/genai/_gaos/lib/compat_errors.py @@ -47,6 +47,7 @@ else (httpx.HTTPError, httpx2.HTTPError) ) +from ... import errors as _native_errors from ..errors.genaierror import GenAiError from ..errors.no_response_error import NoResponseError from ..errors.responsevalidationerror import ResponseValidationError @@ -79,7 +80,9 @@ def __init__( *, body: object, ) -> None: - super().__init__(message) + # Call Exception.__init__ directly so subclasses with multiple bases + # only initialize Exception with `message`. + Exception.__init__(self, message) self.request = request self.message = message self.body = body @@ -87,6 +90,10 @@ def __init__( def __str__(self) -> str: return self.message + def __reduce__(self) -> tuple[Callable[..., "APIError"], tuple[Any, ...]]: + # Preserve the concrete error subclass across copy and pickle. + return (_rebuild_error, (type(self), self.args, self.__dict__.copy())) + @classmethod def generate( cls, @@ -105,10 +112,8 @@ def generate( ) status_cls = cast(type[APIStatusError], target_cls) return status_cls(msg, response=response, body=body) - # Plain APIError fallback (status_code None or unexpected success - # status like 201). Attach status_code to the instance for callers - # that need to inspect it; not part of the Stainless contract for - # APIError but covers the unexpected-status path. + # Plain APIError fallback (status_code None or unexpected non-4xx/5xx + # status). request = response.request if response is not None else None err = target_cls(msg, request, body=body) # type: ignore[arg-type] if status_code is not None: @@ -116,6 +121,16 @@ def generate( return err +def _rebuild_error( + cls: type[APIError], args: tuple[Any, ...], state: dict[str, Any] +) -> APIError: + """Copy/unpickle helper for `APIError.__reduce__`.""" + error = cls.__new__(cls) + Exception.__init__(error, *args) + error.__dict__.update(state) + return error + + class APIResponseValidationError(APIError): response: httpx.Response @@ -135,7 +150,7 @@ def __init__( self.status_code = response.status_code -class APIStatusError(APIError): +class APIStatusError(APIError, _native_errors.APIError): # type: ignore[misc] """Raised when an API response has a 4xx or 5xx status code.""" response: httpx.Response @@ -150,6 +165,21 @@ def __init__( super().__init__(message, response.request, body=body) self.response = response self.status_code = response.status_code + self._set_native_fields(response) + + def _set_native_fields(self, response: httpx.Response) -> None: + """Populate `code`, `status`, and `details` from the HTTP response.""" + details = self.body + if isinstance(details, list) and len(details) == 1: + details = details[0] + if not isinstance(details, (dict, list)): + details = { + "message": details if isinstance(details, str) else "", + "status": response.reason_phrase, + } + self.code = response.status_code + self.status = _error_status(details) + self.details = details class APIConnectionError(APIError): @@ -174,38 +204,42 @@ def __init__(self, request: httpx.Request) -> None: ) -class BadRequestError(APIStatusError): +class BadRequestError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 400 # type: ignore[assignment] -class AuthenticationError(APIStatusError): +class AuthenticationError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 401 # type: ignore[assignment] -class PermissionDeniedError(APIStatusError): +class PermissionDeniedError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 403 # type: ignore[assignment] -class NotFoundError(APIStatusError): +class NotFoundError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 404 # type: ignore[assignment] -class ConflictError(APIStatusError): +class ConflictError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 409 # type: ignore[assignment] -class UnprocessableEntityError(APIStatusError): +class UnprocessableEntityError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 422 # type: ignore[assignment] -class RateLimitError(APIStatusError): +class RateLimitError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] status_code: int = 429 # type: ignore[assignment] -class InternalServerError(APIStatusError): +class InternalServerError(APIStatusError, _native_errors.ServerError): # type: ignore[misc] pass +class _ClientStatusError(APIStatusError, _native_errors.ClientError): # type: ignore[misc] + """Fallback status error for 4xx responses without a dedicated subclass.""" + + _STATUS_MAP = { 400: BadRequestError, 401: AuthenticationError, @@ -226,7 +260,7 @@ def _status_class(status_code: Optional[int]) -> type[APIError]: if 500 <= status_code < 600: return InternalServerError if 400 <= status_code < 500: - return APIStatusError + return _ClientStatusError return APIError @@ -245,6 +279,18 @@ def _parse_body(body: Any) -> Any: return text +def _error_status(body: Any) -> Optional[str]: + """Extract the API status string from a parsed error body.""" + if not isinstance(body, dict): + return None + status = body.get("status") + if isinstance(status, str): + return status + error = body.get("error") + nested = error.get("status") if isinstance(error, dict) else None + return nested if isinstance(nested, str) else None + + def _compose_message(status_code: Optional[int], body: Any) -> str: """Build an error message in `Error code: {status} - {body}` form. @@ -279,13 +325,10 @@ def _wrap_no_response_error(error: NoResponseError) -> APIConnectionError: def _wrap_httpx_error(error: BaseException) -> APIConnectionError: - """Translate an `httpx.HTTPError` into the compat connection-error class. + """Translate an `httpx.HTTPError` into `APIConnectionError` or `APITimeoutError`. - `httpx.HTTPError.request` is a descriptor that raises `RuntimeError` when - the request was never attached. Reads the underlying `_request` attribute - directly. May pass `None` if the error was raised before request - construction — type hint stays strict (mirrors Stainless) but runtime - tolerates the edge case rather than fabricating a misleading stand-in. + Reads `_request` directly since `httpx.HTTPError.request` raises + `RuntimeError` when no request is attached. """ request = getattr(error, "_request", None) if isinstance(error, _HTTPX_TIMEOUT_ERRORS): diff --git a/google/genai/_interactions/__init__.py b/google/genai/_interactions/__init__.py index 75b79b0615..2a2fe61e02 100644 --- a/google/genai/_interactions/__init__.py +++ b/google/genai/_interactions/__init__.py @@ -251,4 +251,4 @@ def file_from_path( "RateLimitError", "UnprocessableEntityError", ): - globals()[_name].__module__ = "google.genai._interactions" + globals()[_name].__module__ = __name__ # pylint: disable=bad-indentation diff --git a/google/genai/errors.py b/google/genai/errors.py index d96c3637db..14a02482bf 100644 --- a/google/genai/errors.py +++ b/google/genai/errors.py @@ -54,7 +54,13 @@ class APIError(Exception): - """General errors raised by the GenAI API.""" + """General errors raised by the GenAI API. + + Interactions API status errors (`google.genai.interactions.APIStatusError` + and its subclasses) also inherit from this class. On those, `message` and + `str()` keep the interactions format (e.g. `Error code: 400 - {...}`); the + server-provided error message is available in `details`. + """ code: int response: Union[ 'requests.Response', @@ -380,3 +386,57 @@ def __init__( ExperimentalWarning = _common.ExperimentalWarning + +if TYPE_CHECKING: + from ._gaos.lib.compat_errors import ( # pylint: disable=g-import-not-at-top,g-multiple-import + APIConnectionError, + APIError as InteractionsAPIError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + ConflictError, + GeminiNextGenAPIClientError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + UnprocessableEntityError, + ) + +_GAOS_COMPAT_ERROR_NAMES = ( + 'APIConnectionError', + 'APIResponseValidationError', + 'APIStatusError', + 'APITimeoutError', + 'AuthenticationError', + 'BadRequestError', + 'ConflictError', + 'GeminiNextGenAPIClientError', + 'InternalServerError', + 'NotFoundError', + 'PermissionDeniedError', + 'RateLimitError', + 'UnprocessableEntityError', +) +_GAOS_COMPAT_ERROR_MAP = { + **{name: name for name in _GAOS_COMPAT_ERROR_NAMES}, + 'InteractionsAPIError': 'APIError', +} + + +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: # pylint: disable=invalid-name + target_name = _GAOS_COMPAT_ERROR_MAP.get(name) + if target_name is not None: + from ._gaos.lib import compat_errors as _compat_errors # pylint: disable=g-import-not-at-top + + value = getattr(_compat_errors, target_name) + globals()[name] = value + return value + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') + + def __dir__() -> list[str]: # pylint: disable=invalid-name + return sorted(set(globals()).union(_GAOS_COMPAT_ERROR_MAP)) diff --git a/google/genai/interactions.py b/google/genai/interactions.py index ca7afa2045..5a0592718c 100644 --- a/google/genai/interactions.py +++ b/google/genai/interactions.py @@ -30,6 +30,22 @@ from ._gaos.types.environments import __all__ as _environments_all from ._gaos.types.interactions import * # noqa: F401,F403 from ._gaos.types.interactions import __all__ as _interactions_all +from ._gaos.lib.compat_errors import ( # pylint: disable=g-multiple-import + APIConnectionError, + APIError, + APIResponseValidationError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + ConflictError, + GeminiNextGenAPIClientError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + UnprocessableEntityError, +) from ._gaos.models.listagents import ListAgentsRequestParam as AgentListParams from ._gaos.models.listwebhooks import ListWebhooksRequestParam as WebhookListParams from ._gaos.resources.interactions import * # noqa: F401,F403 @@ -111,22 +127,36 @@ class InteractionGetParamsStreaming(InteractionGetParamsBase): __all__ = [ + "APIConnectionError", + "APIError", + "APIResponseValidationError", + "APIStatusError", + "APITimeoutError", "Agent", "AgentCreateParams", "AgentDeleteResponse", "AgentListParams", "AgentListResponse", + "AuthenticationError", + "BadRequestError", + "ConflictError", "CreateAgentInteractionParamsNonStreaming", "CreateAgentInteractionParamsStreaming", "CreateModelInteractionParamsNonStreaming", "CreateModelInteractionParamsStreaming", + "GeminiNextGenAPIClientError", "InteractionCreateParams", "InteractionGetParams", "InteractionGetParamsBase", "InteractionGetParamsNonStreaming", "InteractionGetParamsStreaming", + "InternalServerError", "ModelParam", + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", "SigningSecret", + "UnprocessableEntityError", "Webhook", "WebhookCreateParams", "WebhookDeleteResponse", diff --git a/google/genai/tests/interactions/test_httpx_compat.py b/google/genai/tests/interactions/test_httpx_compat.py index ea867f6a04..9718bd8f4e 100644 --- a/google/genai/tests/interactions/test_httpx_compat.py +++ b/google/genai/tests/interactions/test_httpx_compat.py @@ -14,6 +14,9 @@ """Tests for httpx and httpx2 compatibility across the GAOS client.""" +import copy +import pickle + import httpx import pytest @@ -22,8 +25,12 @@ except ImportError: httpx2 = None -from ... import client as client_lib -from ..._gaos.basesdk import AsyncBaseSDK, BaseSDK +from ... import _interactions # pylint: disable=g-import-not-at-top +from ... import client as client_lib # pylint: disable=g-import-not-at-top +from ... import errors # pylint: disable=g-import-not-at-top +from ... import interactions # pylint: disable=g-import-not-at-top +from ..._gaos.basesdk import AsyncBaseSDK, BaseSDK # pylint: disable=g-import-not-at-top,g-multiple-import +from ..._gaos.errors.genaierror import GenAiError # pylint: disable=g-import-not-at-top from ..._gaos.httpclient import AsyncHttpClient, HttpClient from ..._gaos.lib import compat_errors from ..._gaos.utils import eventstreaming, retries @@ -409,3 +416,145 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert len(recorded_requests) == 1 assert isinstance(recorded_requests[0], httpx2.Request) assert result.status == "completed" + + +# --- Error hierarchy unification tests (b/577987345) --- + + +@pytest.mark.parametrize( + "compat_cls, expected_native", + [ + (compat_errors.APIStatusError, errors.APIError), + (compat_errors.BadRequestError, errors.ClientError), + (compat_errors.AuthenticationError, errors.ClientError), + (compat_errors.PermissionDeniedError, errors.ClientError), + (compat_errors.NotFoundError, errors.ClientError), + (compat_errors.ConflictError, errors.ClientError), + (compat_errors.UnprocessableEntityError, errors.ClientError), + (compat_errors.RateLimitError, errors.ClientError), + (compat_errors.InternalServerError, errors.ServerError), + ], +) +def test_compat_status_errors_subclass_native_errors( + compat_cls, expected_native +): + """Tests that status-bearing compat errors subclass native errors.""" + assert issubclass(compat_cls, expected_native) + assert issubclass(compat_cls, errors.APIError) + assert issubclass(compat_cls, compat_errors.APIError) + assert issubclass(compat_cls, compat_errors.GeminiNextGenAPIClientError) + if expected_native is errors.ClientError: + assert not issubclass(compat_cls, errors.ServerError) + elif expected_native is errors.ServerError: + assert not issubclass(compat_cls, errors.ClientError) + + +@pytest.mark.parametrize( + "compat_cls", + [ + compat_errors.GeminiNextGenAPIClientError, + compat_errors.APIError, + compat_errors.APIConnectionError, + compat_errors.APITimeoutError, + compat_errors.APIResponseValidationError, + ], +) +def test_non_status_compat_errors_do_not_subclass_native_api_error(compat_cls): + """Tests that non-status compat errors do not subclass native APIError.""" + assert not issubclass(compat_cls, errors.APIError) + + +def test_compat_errors_exported_on_interactions_and_errors_entrypoints(): + """Tests that compat errors are exported on interactions and errors.""" + for name in ( + "APIConnectionError", + "APIResponseValidationError", + "APIStatusError", + "APITimeoutError", + "AuthenticationError", + "BadRequestError", + "ConflictError", + "GeminiNextGenAPIClientError", + "InternalServerError", + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", + "UnprocessableEntityError", + ): + assert getattr(interactions, name) is getattr(compat_errors, name) + assert getattr(_interactions, name) is getattr(compat_errors, name) + assert getattr(errors, name) is getattr(compat_errors, name) + assert interactions.APIError is compat_errors.APIError + assert _interactions.APIError is compat_errors.APIError + assert errors.InteractionsAPIError is compat_errors.APIError + + +def test_wrap_sdk_error_populates_native_api_error_fields_and_roundtrips(): + """Tests native field population and copy/pickle roundtripping.""" + req = httpx.Request("POST", "https://example.com/v1beta/interactions") + raw_body = ( + '{"error": {"code": 400, "message": "bad model",' + ' "status": "INVALID_ARGUMENT"}}' + ) + resp = httpx.Response( + 400, + request=req, + headers={"content-type": "application/json"}, + text=raw_body, + ) + sdk_err = GenAiError("API error occurred", resp, raw_body) + + with pytest.raises(errors.ClientError) as exc_info: + raise compat_errors.wrap_sdk_error(sdk_err) + + err = exc_info.value + assert isinstance(err, compat_errors.BadRequestError) + assert isinstance(err, errors.APIError) + assert err.status_code == 400 + assert err.code == 400 + assert err.status == "INVALID_ARGUMENT" + assert err.details == { + "error": { + "code": 400, + "message": "bad model", + "status": "INVALID_ARGUMENT", + } + } + assert err.body == err.details + assert err.response is resp + assert err.message.startswith("Error code: 400 - ") + assert str(err) == err.message + + unpickled = pickle.loads(pickle.dumps(err)) # pylint: disable=g-unsafe-pickle-load + for clone in (copy.copy(err), copy.deepcopy(err), unpickled): + assert clone.__class__ is compat_errors.BadRequestError + assert clone.status_code == 400 + assert clone.code == 400 + assert clone.status == "INVALID_ARGUMENT" + assert clone.details == err.details + assert clone.message == err.message + assert str(clone) == str(err) + + +def test_unmapped_4xx_status_caught_by_native_client_error(): + """Tests that unmapped 4xx status codes are caught by native ClientError.""" + req = httpx.Request("POST", "https://example.com/v1beta/interactions") + resp = httpx.Response(413, request=req, text="Payload too large") + sdk_err = GenAiError("API error occurred", resp, "Payload too large") + + with pytest.raises(errors.ClientError) as exc_info: + raise compat_errors.wrap_sdk_error(sdk_err) + + err = exc_info.value + assert isinstance(err, compat_errors.APIStatusError) + assert not isinstance(err, errors.ServerError) + assert err.status_code == 413 + assert err.code == 413 + assert err.details == { + "message": "Payload too large", + "status": "Request Entity Too Large", + } + unpickled = pickle.loads(pickle.dumps(err)) # pylint: disable=g-unsafe-pickle-load + assert isinstance(unpickled, errors.ClientError) + assert isinstance(unpickled, compat_errors.APIStatusError) + assert unpickled.code == 413