Skip to content

Commit 39d7bb3

Browse files
google-genai-botcopybara-github
authored andcommitted
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=#3209 from googleapis:release-please--branches--main 39c95f2 PiperOrigin-RevId: 1008500737
1 parent 0dc788c commit 39d7bb3

5 files changed

Lines changed: 307 additions & 25 deletions

File tree

‎google/genai/_gaos/lib/compat_errors.py‎

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
else (httpx.HTTPError, httpx2.HTTPError)
4848
)
4949

50+
from ... import errors as _native_errors
5051
from ..errors.genaierror import GenAiError
5152
from ..errors.no_response_error import NoResponseError
5253
from ..errors.responsevalidationerror import ResponseValidationError
@@ -79,14 +80,20 @@ def __init__(
7980
*,
8081
body: object,
8182
) -> None:
82-
super().__init__(message)
83+
# Call Exception.__init__ directly so subclasses with multiple bases
84+
# only initialize Exception with `message`.
85+
Exception.__init__(self, message)
8386
self.request = request
8487
self.message = message
8588
self.body = body
8689

8790
def __str__(self) -> str:
8891
return self.message
8992

93+
def __reduce__(self) -> tuple[Callable[..., "APIError"], tuple[Any, ...]]:
94+
# Preserve the concrete error subclass across copy and pickle.
95+
return (_rebuild_error, (type(self), self.args, self.__dict__.copy()))
96+
9097
@classmethod
9198
def generate(
9299
cls,
@@ -105,17 +112,25 @@ def generate(
105112
)
106113
status_cls = cast(type[APIStatusError], target_cls)
107114
return status_cls(msg, response=response, body=body)
108-
# Plain APIError fallback (status_code None or unexpected success
109-
# status like 201). Attach status_code to the instance for callers
110-
# that need to inspect it; not part of the Stainless contract for
111-
# APIError but covers the unexpected-status path.
115+
# Plain APIError fallback (status_code None or unexpected non-4xx/5xx
116+
# status).
112117
request = response.request if response is not None else None
113118
err = target_cls(msg, request, body=body) # type: ignore[arg-type]
114119
if status_code is not None:
115120
err.status_code = status_code
116121
return err
117122

118123

124+
def _rebuild_error(
125+
cls: type[APIError], args: tuple[Any, ...], state: dict[str, Any]
126+
) -> APIError:
127+
"""Copy/unpickle helper for `APIError.__reduce__`."""
128+
error = cls.__new__(cls)
129+
Exception.__init__(error, *args)
130+
error.__dict__.update(state)
131+
return error
132+
133+
119134
class APIResponseValidationError(APIError):
120135
response: httpx.Response
121136

@@ -135,7 +150,7 @@ def __init__(
135150
self.status_code = response.status_code
136151

137152

138-
class APIStatusError(APIError):
153+
class APIStatusError(APIError, _native_errors.APIError): # type: ignore[misc]
139154
"""Raised when an API response has a 4xx or 5xx status code."""
140155

141156
response: httpx.Response
@@ -150,6 +165,21 @@ def __init__(
150165
super().__init__(message, response.request, body=body)
151166
self.response = response
152167
self.status_code = response.status_code
168+
self._set_native_fields(response)
169+
170+
def _set_native_fields(self, response: httpx.Response) -> None:
171+
"""Populate `code`, `status`, and `details` from the HTTP response."""
172+
details = self.body
173+
if isinstance(details, list) and len(details) == 1:
174+
details = details[0]
175+
if not isinstance(details, (dict, list)):
176+
details = {
177+
"message": details if isinstance(details, str) else "",
178+
"status": response.reason_phrase,
179+
}
180+
self.code = response.status_code
181+
self.status = _error_status(details)
182+
self.details = details
153183

154184

155185
class APIConnectionError(APIError):
@@ -174,38 +204,42 @@ def __init__(self, request: httpx.Request) -> None:
174204
)
175205

176206

177-
class BadRequestError(APIStatusError):
207+
class BadRequestError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
178208
status_code: int = 400 # type: ignore[assignment]
179209

180210

181-
class AuthenticationError(APIStatusError):
211+
class AuthenticationError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
182212
status_code: int = 401 # type: ignore[assignment]
183213

184214

185-
class PermissionDeniedError(APIStatusError):
215+
class PermissionDeniedError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
186216
status_code: int = 403 # type: ignore[assignment]
187217

188218

189-
class NotFoundError(APIStatusError):
219+
class NotFoundError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
190220
status_code: int = 404 # type: ignore[assignment]
191221

192222

193-
class ConflictError(APIStatusError):
223+
class ConflictError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
194224
status_code: int = 409 # type: ignore[assignment]
195225

196226

197-
class UnprocessableEntityError(APIStatusError):
227+
class UnprocessableEntityError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
198228
status_code: int = 422 # type: ignore[assignment]
199229

200230

201-
class RateLimitError(APIStatusError):
231+
class RateLimitError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
202232
status_code: int = 429 # type: ignore[assignment]
203233

204234

205-
class InternalServerError(APIStatusError):
235+
class InternalServerError(APIStatusError, _native_errors.ServerError): # type: ignore[misc]
206236
pass
207237

208238

239+
class _ClientStatusError(APIStatusError, _native_errors.ClientError): # type: ignore[misc]
240+
"""Fallback status error for 4xx responses without a dedicated subclass."""
241+
242+
209243
_STATUS_MAP = {
210244
400: BadRequestError,
211245
401: AuthenticationError,
@@ -226,7 +260,7 @@ def _status_class(status_code: Optional[int]) -> type[APIError]:
226260
if 500 <= status_code < 600:
227261
return InternalServerError
228262
if 400 <= status_code < 500:
229-
return APIStatusError
263+
return _ClientStatusError
230264
return APIError
231265

232266

@@ -245,6 +279,18 @@ def _parse_body(body: Any) -> Any:
245279
return text
246280

247281

282+
def _error_status(body: Any) -> Optional[str]:
283+
"""Extract the API status string from a parsed error body."""
284+
if not isinstance(body, dict):
285+
return None
286+
status = body.get("status")
287+
if isinstance(status, str):
288+
return status
289+
error = body.get("error")
290+
nested = error.get("status") if isinstance(error, dict) else None
291+
return nested if isinstance(nested, str) else None
292+
293+
248294
def _compose_message(status_code: Optional[int], body: Any) -> str:
249295
"""Build an error message in `Error code: {status} - {body}` form.
250296
@@ -279,13 +325,10 @@ def _wrap_no_response_error(error: NoResponseError) -> APIConnectionError:
279325

280326

281327
def _wrap_httpx_error(error: BaseException) -> APIConnectionError:
282-
"""Translate an `httpx.HTTPError` into the compat connection-error class.
328+
"""Translate an `httpx.HTTPError` into `APIConnectionError` or `APITimeoutError`.
283329
284-
`httpx.HTTPError.request` is a descriptor that raises `RuntimeError` when
285-
the request was never attached. Reads the underlying `_request` attribute
286-
directly. May pass `None` if the error was raised before request
287-
construction — type hint stays strict (mirrors Stainless) but runtime
288-
tolerates the edge case rather than fabricating a misleading stand-in.
330+
Reads `_request` directly since `httpx.HTTPError.request` raises
331+
`RuntimeError` when no request is attached.
289332
"""
290333
request = getattr(error, "_request", None)
291334
if isinstance(error, _HTTPX_TIMEOUT_ERRORS):

‎google/genai/_interactions/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,4 @@ def file_from_path(
251251
"RateLimitError",
252252
"UnprocessableEntityError",
253253
):
254-
globals()[_name].__module__ = "google.genai._interactions"
254+
globals()[_name].__module__ = __name__ # pylint: disable=bad-indentation

‎google/genai/errors.py‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@
5454

5555

5656
class APIError(Exception):
57-
"""General errors raised by the GenAI API."""
57+
"""General errors raised by the GenAI API.
58+
59+
Interactions API status errors (`google.genai.interactions.APIStatusError`
60+
and its subclasses) also inherit from this class. On those, `message` and
61+
`str()` keep the interactions format (e.g. `Error code: 400 - {...}`); the
62+
server-provided error message is available in `details`.
63+
"""
5864
code: int
5965
response: Union[
6066
'requests.Response',
@@ -380,3 +386,57 @@ def __init__(
380386

381387

382388
ExperimentalWarning = _common.ExperimentalWarning
389+
390+
if TYPE_CHECKING:
391+
from ._gaos.lib.compat_errors import ( # pylint: disable=g-import-not-at-top,g-multiple-import
392+
APIConnectionError,
393+
APIError as InteractionsAPIError,
394+
APIResponseValidationError,
395+
APIStatusError,
396+
APITimeoutError,
397+
AuthenticationError,
398+
BadRequestError,
399+
ConflictError,
400+
GeminiNextGenAPIClientError,
401+
InternalServerError,
402+
NotFoundError,
403+
PermissionDeniedError,
404+
RateLimitError,
405+
UnprocessableEntityError,
406+
)
407+
408+
_GAOS_COMPAT_ERROR_NAMES = (
409+
'APIConnectionError',
410+
'APIResponseValidationError',
411+
'APIStatusError',
412+
'APITimeoutError',
413+
'AuthenticationError',
414+
'BadRequestError',
415+
'ConflictError',
416+
'GeminiNextGenAPIClientError',
417+
'InternalServerError',
418+
'NotFoundError',
419+
'PermissionDeniedError',
420+
'RateLimitError',
421+
'UnprocessableEntityError',
422+
)
423+
_GAOS_COMPAT_ERROR_MAP = {
424+
**{name: name for name in _GAOS_COMPAT_ERROR_NAMES},
425+
'InteractionsAPIError': 'APIError',
426+
}
427+
428+
429+
if not TYPE_CHECKING:
430+
431+
def __getattr__(name: str) -> Any: # pylint: disable=invalid-name
432+
target_name = _GAOS_COMPAT_ERROR_MAP.get(name)
433+
if target_name is not None:
434+
from ._gaos.lib import compat_errors as _compat_errors # pylint: disable=g-import-not-at-top
435+
436+
value = getattr(_compat_errors, target_name)
437+
globals()[name] = value
438+
return value
439+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
440+
441+
def __dir__() -> list[str]: # pylint: disable=invalid-name
442+
return sorted(set(globals()).union(_GAOS_COMPAT_ERROR_MAP))

‎google/genai/interactions.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@
3030
from ._gaos.types.environments import __all__ as _environments_all
3131
from ._gaos.types.interactions import * # noqa: F401,F403
3232
from ._gaos.types.interactions import __all__ as _interactions_all
33+
from ._gaos.lib.compat_errors import ( # pylint: disable=g-multiple-import
34+
APIConnectionError,
35+
APIError,
36+
APIResponseValidationError,
37+
APIStatusError,
38+
APITimeoutError,
39+
AuthenticationError,
40+
BadRequestError,
41+
ConflictError,
42+
GeminiNextGenAPIClientError,
43+
InternalServerError,
44+
NotFoundError,
45+
PermissionDeniedError,
46+
RateLimitError,
47+
UnprocessableEntityError,
48+
)
3349
from ._gaos.models.listagents import ListAgentsRequestParam as AgentListParams
3450
from ._gaos.models.listwebhooks import ListWebhooksRequestParam as WebhookListParams
3551
from ._gaos.resources.interactions import * # noqa: F401,F403
@@ -111,22 +127,36 @@ class InteractionGetParamsStreaming(InteractionGetParamsBase):
111127

112128

113129
__all__ = [
130+
"APIConnectionError",
131+
"APIError",
132+
"APIResponseValidationError",
133+
"APIStatusError",
134+
"APITimeoutError",
114135
"Agent",
115136
"AgentCreateParams",
116137
"AgentDeleteResponse",
117138
"AgentListParams",
118139
"AgentListResponse",
140+
"AuthenticationError",
141+
"BadRequestError",
142+
"ConflictError",
119143
"CreateAgentInteractionParamsNonStreaming",
120144
"CreateAgentInteractionParamsStreaming",
121145
"CreateModelInteractionParamsNonStreaming",
122146
"CreateModelInteractionParamsStreaming",
147+
"GeminiNextGenAPIClientError",
123148
"InteractionCreateParams",
124149
"InteractionGetParams",
125150
"InteractionGetParamsBase",
126151
"InteractionGetParamsNonStreaming",
127152
"InteractionGetParamsStreaming",
153+
"InternalServerError",
128154
"ModelParam",
155+
"NotFoundError",
156+
"PermissionDeniedError",
157+
"RateLimitError",
129158
"SigningSecret",
159+
"UnprocessableEntityError",
130160
"Webhook",
131161
"WebhookCreateParams",
132162
"WebhookDeleteResponse",

0 commit comments

Comments
 (0)