diff --git a/src/blaxel/core/__init__.py b/src/blaxel/core/__init__.py index 219d1b2..c55a1f3 100644 --- a/src/blaxel/core/__init__.py +++ b/src/blaxel/core/__init__.py @@ -4,6 +4,19 @@ from .authentication import BlaxelAuth, auth, get_credentials from .cache import find_from_cache from .client.client import client +from .client.error_codes import ( + AUTHENTICATION_FAILED, + AUTHENTICATION_REQUIRED, + BAD_REQUEST, + FORBIDDEN, + POLICY_VIOLATION, + ROUTE_NOT_FOUND, + USAGE_LIMIT_EXCEEDED, + WORKLOAD_NOT_FOUND, + WORKLOAD_UNAVAILABLE, + WORKSPACE_NOT_FOUND, +) +from .client.errors import GatewayError, check_gateway_error from .common import ( autoload, env, @@ -73,6 +86,18 @@ "SyncDriveInstance", "DriveCreateConfiguration", "DriveAPIError", + "GatewayError", + "check_gateway_error", + "ROUTE_NOT_FOUND", + "WORKLOAD_NOT_FOUND", + "WORKSPACE_NOT_FOUND", + "WORKLOAD_UNAVAILABLE", + "AUTHENTICATION_REQUIRED", + "AUTHENTICATION_FAILED", + "FORBIDDEN", + "BAD_REQUEST", + "USAGE_LIMIT_EXCEEDED", + "POLICY_VIOLATION", "verify_webhook_signature", "verify_webhook_from_request", "ImageInstance", diff --git a/src/blaxel/core/client/client.py b/src/blaxel/core/client/client.py index 00f5eb3..e80c9c0 100644 --- a/src/blaxel/core/client/client.py +++ b/src/blaxel/core/client/client.py @@ -5,6 +5,16 @@ import httpx from attrs import define, evolve, field +from .errors import check_gateway_error + + +def _sync_gateway_hook(response: httpx.Response) -> None: + check_gateway_error(response) + + +async def _async_gateway_hook(response: httpx.Response) -> None: + check_gateway_error(response) + @define class Client: @@ -109,6 +119,10 @@ def set_httpx_client(self, client: httpx.Client) -> "Client": def get_httpx_client(self) -> httpx.Client: """Get the underlying httpx.Client, constructing a new one if not previously set""" if self._client is None: + httpx_args = {**self._httpx_args} + hooks = httpx_args.pop("event_hooks", {}) + response_hooks = list(hooks.get("response", [])) + response_hooks.insert(0, _sync_gateway_hook) self._client = httpx.Client( base_url=self._base_url, cookies=self._cookies, @@ -117,7 +131,8 @@ def get_httpx_client(self) -> httpx.Client: verify=self._verify_ssl, follow_redirects=self._follow_redirects, auth=self._auth, - **self._httpx_args, + event_hooks={**hooks, "response": response_hooks}, + **httpx_args, ) return self._client @@ -150,6 +165,10 @@ def get_async_httpx_client(self) -> httpx.AsyncClient: self._async_client_loop = None if self._async_client is None: + httpx_args = {**self._httpx_args} + hooks = httpx_args.pop("event_hooks", {}) + response_hooks = list(hooks.get("response", [])) + response_hooks.insert(0, _async_gateway_hook) self._async_client = httpx.AsyncClient( base_url=self._base_url, cookies=self._cookies, @@ -158,7 +177,8 @@ def get_async_httpx_client(self) -> httpx.AsyncClient: verify=self._verify_ssl, follow_redirects=self._follow_redirects, auth=self._auth, - **self._httpx_args, + event_hooks={**hooks, "response": response_hooks}, + **httpx_args, ) self._async_client_loop = current_loop return self._async_client diff --git a/src/blaxel/core/client/error_codes.py b/src/blaxel/core/client/error_codes.py new file mode 100644 index 0000000..db45f19 --- /dev/null +++ b/src/blaxel/core/client/error_codes.py @@ -0,0 +1,41 @@ +"""Stable error codes emitted by the Blaxel gateway proxy. + +These codes appear in the ``X-Blaxel-Error-Code`` response header and the +``error.code`` field of the JSON body on gateway-synthesized error responses. + +Usage:: + + from blaxel.core.client.errors import GatewayError + from blaxel.core.client.error_codes import WORKLOAD_UNAVAILABLE + + try: + result = await some_api_call() + except GatewayError as exc: + if exc.error_code == WORKLOAD_UNAVAILABLE: + # retry with backoff + ... +""" + +ROUTE_NOT_FOUND: str = "ROUTE_NOT_FOUND" +WORKLOAD_NOT_FOUND: str = "WORKLOAD_NOT_FOUND" +WORKSPACE_NOT_FOUND: str = "WORKSPACE_NOT_FOUND" +WORKLOAD_UNAVAILABLE: str = "WORKLOAD_UNAVAILABLE" +AUTHENTICATION_REQUIRED: str = "AUTHENTICATION_REQUIRED" +AUTHENTICATION_FAILED: str = "AUTHENTICATION_FAILED" +FORBIDDEN: str = "FORBIDDEN" +BAD_REQUEST: str = "BAD_REQUEST" +USAGE_LIMIT_EXCEEDED: str = "USAGE_LIMIT_EXCEEDED" +POLICY_VIOLATION: str = "POLICY_VIOLATION" + +__all__ = [ + "ROUTE_NOT_FOUND", + "WORKLOAD_NOT_FOUND", + "WORKSPACE_NOT_FOUND", + "WORKLOAD_UNAVAILABLE", + "AUTHENTICATION_REQUIRED", + "AUTHENTICATION_FAILED", + "FORBIDDEN", + "BAD_REQUEST", + "USAGE_LIMIT_EXCEEDED", + "POLICY_VIOLATION", +] diff --git a/src/blaxel/core/client/errors.py b/src/blaxel/core/client/errors.py index 5f92e76..3271b21 100644 --- a/src/blaxel/core/client/errors.py +++ b/src/blaxel/core/client/errors.py @@ -1,5 +1,12 @@ """Contains shared errors types that can be raised from API functions""" +from __future__ import annotations + +import json +from typing import Any + +import httpx + class UnexpectedStatus(Exception): """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" @@ -13,4 +20,67 @@ def __init__(self, status_code: int, content: bytes): ) -__all__ = ["UnexpectedStatus"] +class GatewayError(Exception): + """Raised when the Blaxel gateway proxy synthesizes an error response. + + The gateway sets ``X-Blaxel-Source: platform`` on every response it + generates itself (as opposed to forwarding from the upstream workload). + This exception exposes the stable error code and agent-readable metadata + so callers can branch on ``error_code`` instead of parsing free-text + messages. + """ + + def __init__( + self, + *, + error_code: str, + message: str, + status_code: int, + retryable: bool, + action: str, + do_not: str | None = None, + docs_url: str | None = None, + response: httpx.Response, + ): + super().__init__(message) + self.error_code = error_code + self.status_code = status_code + self.retryable = retryable + self.action = action + self.do_not = do_not + self.docs_url = docs_url + self.response = response + + +def check_gateway_error(response: httpx.Response) -> None: + """Raise :class:`GatewayError` if *response* is a gateway-synthesized error. + + Call this before any other response parsing so that gateway errors are + surfaced consistently across generated and hand-written API calls. + """ + if response.headers.get("X-Blaxel-Source") != "platform": + return + + error_obj: dict[str, Any] = {} + try: + body = response.json() + if isinstance(body, dict): + error_obj = body.get("error", {}) + if not isinstance(error_obj, dict): + error_obj = {} + except (json.JSONDecodeError, ValueError): + pass + + raise GatewayError( + error_code=response.headers.get("X-Blaxel-Error-Code", ""), + message=error_obj.get("message", response.text), + status_code=response.status_code, + retryable=bool(error_obj.get("retryable", False)), + action=error_obj.get("action", ""), + do_not=error_obj.get("do_not"), + docs_url=error_obj.get("docs_url"), + response=response, + ) + + +__all__ = ["UnexpectedStatus", "GatewayError", "check_gateway_error"] diff --git a/src/blaxel/core/sandbox/default/action.py b/src/blaxel/core/sandbox/default/action.py index 5cad331..8cc088a 100644 --- a/src/blaxel/core/sandbox/default/action.py +++ b/src/blaxel/core/sandbox/default/action.py @@ -1,5 +1,6 @@ import httpx +from ...client.errors import check_gateway_error from ...common.internal import get_forced_url, get_global_unique_hash from ...common.settings import settings from ..types import ResponseError, SandboxConfiguration @@ -69,5 +70,6 @@ def get_client(self) -> httpx.AsyncClient: return self._client def handle_response_error(self, response: httpx.Response): + check_gateway_error(response) if not response.is_success: raise ResponseError(response) diff --git a/src/blaxel/core/sandbox/sync/action.py b/src/blaxel/core/sandbox/sync/action.py index 528a5aa..3758144 100644 --- a/src/blaxel/core/sandbox/sync/action.py +++ b/src/blaxel/core/sandbox/sync/action.py @@ -1,5 +1,6 @@ import httpx +from ...client.errors import check_gateway_error from ...common.internal import get_forced_url, get_global_unique_hash from ...common.settings import settings from ..types import ResponseError, SandboxConfiguration @@ -60,5 +61,6 @@ def get_client(self) -> httpx.Client: ) def handle_response_error(self, response: httpx.Response): + check_gateway_error(response) if not response.is_success: raise ResponseError(response)