Skip to content
Open
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
85 changes: 64 additions & 21 deletions google/genai/_gaos/lib/compat_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,14 +80,20 @@ 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

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,
Expand All @@ -105,17 +112,25 @@ 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:
err.status_code = status_code
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

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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,
Expand All @@ -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


Expand All @@ -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.

Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion google/genai/_interactions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,4 @@ def file_from_path(
"RateLimitError",
"UnprocessableEntityError",
):
globals()[_name].__module__ = "google.genai._interactions"
globals()[_name].__module__ = __name__ # pylint: disable=bad-indentation
62 changes: 61 additions & 1 deletion google/genai/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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))
30 changes: 30 additions & 0 deletions google/genai/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading