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
25 changes: 25 additions & 0 deletions src/blaxel/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 22 additions & 2 deletions src/blaxel/core/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug (P0): The async response hook must await response.aread() before accessing the body. The shared check_gateway_error() calls response.read() (sync), which does not read the body in an async context and will raise ResponseNotRead.

Suggested change
Suggested change
async def _async_gateway_hook(response: httpx.Response) -> None:
check_gateway_error(response)
async def _async_gateway_hook(response: httpx.Response) -> None:
await response.aread()
check_gateway_error(response)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/blaxel/core/client/client.py, line 15:

<issue>
The async response hook must `await response.aread()` before accessing the body. The shared `check_gateway_error()` calls `response.read()` (sync), which does not read the body in an async context and will raise `ResponseNotRead`.
</issue>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d73de8 — async hook now calls await response.aread() before check_gateway_error().



@define
class Client:
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/blaxel/core/client/error_codes.py
Original file line number Diff line number Diff line change
@@ -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",
]
72 changes: 71 additions & 1 deletion src/blaxel/core/client/errors.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand All @@ -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
Comment on lines +61 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug (P0): check_gateway_error() raises for every response with X-Blaxel-Source: platform, including 2xx. Successful gateway responses will incorrectly raise GatewayError.

Suggested change
Suggested change
if response.headers.get("X-Blaxel-Source") != "platform":
return
if response.headers.get("X-Blaxel-Source") != "platform":
return
if response.is_success:
return
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/blaxel/core/client/errors.py, line 61:

<issue>
`check_gateway_error()` raises for every response with `X-Blaxel-Source: platform`, including 2xx. Successful gateway responses will incorrectly raise `GatewayError`.
</issue>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d73de8 — added if response.is_success: return guard so 2xx responses are not raised as GatewayError.


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
Comment on lines +64 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug (P0): httpx fires response event hooks before the body is read (response.read() is called later in Client.send). Calling response.json() here always raises httpx.ResponseNotRead. The body must be read explicitly inside the hook.

Suggested change
Suggested change
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
error_obj: dict[str, Any] = {}
try:
response.read()
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
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/blaxel/core/client/errors.py, line 64:

<issue>
httpx fires response event hooks before the body is read (`response.read()` is called later in `Client.send`). Calling `response.json()` here always raises `httpx.ResponseNotRead`. The body must be read explicitly inside the hook.
</issue>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d73de8 — added response.read() before calling check_gateway_error() in the sync hook so the body is available.


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"]
2 changes: 2 additions & 0 deletions src/blaxel/core/sandbox/default/action.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions src/blaxel/core/sandbox/sync/action.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading