From f9949cef3c1a3407f342fdfe432c3745c2475dd7 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Tue, 24 Mar 2026 10:51:57 -0400 Subject: [PATCH] sandbox: Add error hierarchy Add specific error classes to help downstream consumers to do conditional error handling and bring structure to our generic APIError class. --- src/vercel/_internal/sandbox/__init__.py | 14 +- src/vercel/_internal/sandbox/core.py | 82 +++++++--- src/vercel/_internal/sandbox/errors.py | 51 +++++- src/vercel/sandbox/__init__.py | 14 +- .../test_sandbox_streaming_errors.py | 153 +++++++++++------- tests/unit/test_sandbox_errors.py | 116 +++++++++++++ 6 files changed, 350 insertions(+), 80 deletions(-) create mode 100644 tests/unit/test_sandbox_errors.py diff --git a/src/vercel/_internal/sandbox/__init__.py b/src/vercel/_internal/sandbox/__init__.py index e2ce41b..9945823 100644 --- a/src/vercel/_internal/sandbox/__init__.py +++ b/src/vercel/_internal/sandbox/__init__.py @@ -1,10 +1,22 @@ """Internal sandbox client implementations.""" from vercel._internal.sandbox.core import AsyncSandboxOpsClient, SyncSandboxOpsClient -from vercel._internal.sandbox.errors import APIError +from vercel._internal.sandbox.errors import ( + APIError, + SandboxAuthError, + SandboxError, + SandboxPermissionError, + SandboxRateLimitError, + SandboxServerError, +) __all__ = [ "AsyncSandboxOpsClient", "SyncSandboxOpsClient", + "SandboxError", "APIError", + "SandboxAuthError", + "SandboxPermissionError", + "SandboxRateLimitError", + "SandboxServerError", ] diff --git a/src/vercel/_internal/sandbox/core.py b/src/vercel/_internal/sandbox/core.py index fe17ca9..bea5e66 100644 --- a/src/vercel/_internal/sandbox/core.py +++ b/src/vercel/_internal/sandbox/core.py @@ -9,6 +9,7 @@ from __future__ import annotations import io +import json import platform import posixpath import sys @@ -27,7 +28,13 @@ create_request_client, ) from vercel._internal.iter_coroutine import iter_coroutine -from vercel._internal.sandbox.errors import APIError +from vercel._internal.sandbox.errors import ( + APIError, + SandboxAuthError, + SandboxPermissionError, + SandboxRateLimitError, + SandboxServerError, +) from vercel._internal.sandbox.models import ( CommandFinishedResponse, CommandResponse, @@ -63,8 +70,8 @@ class SandboxRequestClient: """Low-level request layer wrapping a :class:`RequestClient`. - Translates non-2xx responses into :class:`APIError` and provides - a ``request_json`` convenience method. + Translates non-2xx responses into sandbox-specific :class:`APIError` + subclasses and provides a ``request_json`` convenience method. """ def __init__(self, *, request_client: RequestClient) -> None: @@ -96,36 +103,46 @@ async def request( if 200 <= response.status_code < 300: return response + error_body: bytes | None = None + try: + error_body = await response.aread() + except Exception: + try: + error_body = response.read() + except Exception: + error_body = None + # Parse a helpful error message parsed: Any | None = None message = f"HTTP {response.status_code}" - try: - parsed = response.json() - if isinstance(parsed, dict): - if "message" in parsed and isinstance(parsed["message"], str): - message = f"{message}: {parsed['message']}" - elif "error" in parsed: - err = parsed["error"] - if isinstance(err, dict): - code = err.get("code") - msg = err.get("message") or err.get("msg") - if msg: - message = f"{message}: {msg}" - if code: - message = f"{message} (code={code})" - except Exception: - parsed = None + if error_body: + try: + parsed = json.loads(error_body) + if isinstance(parsed, dict): + if "message" in parsed and isinstance(parsed["message"], str): + message = f"{message}: {parsed['message']}" + elif "error" in parsed: + err = parsed["error"] + if isinstance(err, dict): + code = err.get("code") + msg = err.get("message") or err.get("msg") + if msg: + message = f"{message}: {msg}" + if code: + message = f"{message} (code={code})" + except Exception: + parsed = None if parsed is None: try: - text = response.text + text = error_body.decode() if error_body is not None else response.text if text: snippet = text if len(text) <= 500 else text[:500] + "\u2026" message = f"{message}: {snippet}" except Exception: pass - raise APIError(response, message, data=parsed) + raise _build_sandbox_error(response, message, data=parsed) async def request_json( self, @@ -139,6 +156,29 @@ async def request_json( return r.json() +def _build_sandbox_error( + response: httpx.Response, + message: str, + *, + data: Any | None = None, +) -> APIError: + status_code = response.status_code + if status_code == 401: + return SandboxAuthError(response, message, data=data) + if status_code == 403: + return SandboxPermissionError(response, message, data=data) + if status_code == 429: + return SandboxRateLimitError( + response, + message, + data=data, + retry_after=response.headers.get("retry-after"), + ) + if 500 <= status_code < 600: + return SandboxServerError(response, message, data=data) + return APIError(response, message, data=data) + + # --------------------------------------------------------------------------- # Tarball builder (pure Python, no I/O) # --------------------------------------------------------------------------- diff --git a/src/vercel/_internal/sandbox/errors.py b/src/vercel/_internal/sandbox/errors.py index 7dc8384..3f4e108 100644 --- a/src/vercel/_internal/sandbox/errors.py +++ b/src/vercel/_internal/sandbox/errors.py @@ -7,7 +7,22 @@ import httpx -class APIError(Exception): +def _normalize_retry_after(value: str | int | None) -> int | str | None: + if value is None: + return None + if isinstance(value, int): + return value + try: + return int(value) + except (TypeError, ValueError): + return value + + +class SandboxError(Exception): + """Base class for sandbox-specific errors.""" + + +class APIError(SandboxError): def __init__(self, response: httpx.Response, message: str, *, data: Any | None = None): super().__init__(message) self.response = response @@ -15,4 +30,36 @@ def __init__(self, response: httpx.Response, message: str, *, data: Any | None = self.data = data -__all__ = ["APIError"] +class SandboxAuthError(APIError): + """Authentication failures returned by the sandbox API.""" + + +class SandboxPermissionError(APIError): + """Authorization failures returned by the sandbox API.""" + + +class SandboxRateLimitError(APIError): + def __init__( + self, + response: httpx.Response, + message: str, + *, + data: Any | None = None, + retry_after: str | int | None = None, + ) -> None: + super().__init__(response, message, data=data) + self.retry_after: int | str | None = _normalize_retry_after(retry_after) + + +class SandboxServerError(APIError): + """5xx responses returned by the sandbox API.""" + + +__all__ = [ + "SandboxError", + "APIError", + "SandboxAuthError", + "SandboxPermissionError", + "SandboxRateLimitError", + "SandboxServerError", +] diff --git a/src/vercel/sandbox/__init__.py b/src/vercel/sandbox/__init__.py index 7990ce0..366d50f 100644 --- a/src/vercel/sandbox/__init__.py +++ b/src/vercel/sandbox/__init__.py @@ -1,4 +1,11 @@ -from vercel._internal.sandbox.errors import APIError +from vercel._internal.sandbox import ( + APIError, + SandboxAuthError, + SandboxError, + SandboxPermissionError, + SandboxRateLimitError, + SandboxServerError, +) from vercel._internal.sandbox.network_policy import ( NetworkPolicy, NetworkPolicyCustom, @@ -14,7 +21,12 @@ from .snapshot import AsyncSnapshot, Snapshot __all__ = [ + "SandboxError", "APIError", + "SandboxAuthError", + "SandboxPermissionError", + "SandboxRateLimitError", + "SandboxServerError", "AsyncSandbox", "AsyncSandboxPage", "AsyncSnapshot", diff --git a/tests/integration/test_sandbox_streaming_errors.py b/tests/integration/test_sandbox_streaming_errors.py index 9d4e2c1..4fdd649 100644 --- a/tests/integration/test_sandbox_streaming_errors.py +++ b/tests/integration/test_sandbox_streaming_errors.py @@ -1,10 +1,17 @@ -"""Test that non-2xx streaming responses raise APIError.""" +"""Test that non-2xx streaming responses raise sandbox-specific errors.""" import httpx import pytest import respx -from vercel.sandbox import APIError +from vercel.sandbox import ( + APIError, + SandboxAuthError, + SandboxError, + SandboxPermissionError, + SandboxRateLimitError, + SandboxServerError, +) SANDBOX_API_BASE = "https://api.vercel.com" SANDBOX_ID = "sbx_test123" @@ -23,77 +30,113 @@ def _async_client(**kwargs): return AsyncSandboxOpsClient(**kwargs) -class TestStreamingErrorsSync: - """Sync get_logs raises APIError on non-2xx responses.""" +def _make_error_response( + status_code: int, + *, + code: str, + message: str, + headers: dict[str, str] | None = None, +) -> httpx.Response: + return httpx.Response( + status_code, + headers=headers, + json={"error": {"code": code, "message": message}}, + ) - @respx.mock - def test_get_logs_raises_on_401(self): - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{SANDBOX_ID}/cmd/{CMD_ID}/logs").mock( - return_value=httpx.Response( - 401, - json={"error": {"code": "unauthorized", "message": "Authentication required."}}, - ) - ) - client = _sync_client(host=SANDBOX_API_BASE, team_id="team_test", token="bad_token") - with pytest.raises(APIError) as exc_info: - list(client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID)) +SYNC_CASES = [ + (401, SandboxAuthError, "unauthorized", "Authentication required.", None, None), + (403, SandboxPermissionError, "forbidden", "Access denied.", None, None), + (429, SandboxRateLimitError, "rate_limited", "Slow down.", {"retry-after": "120"}, 120), + (500, SandboxServerError, "internal_server_error", "Something broke.", None, None), +] - assert exc_info.value.status_code == 401 - client.close() + +class TestStreamingErrorsSync: + """Sync get_logs raises sandbox-specific errors on non-2xx responses.""" @respx.mock - def test_get_logs_raises_on_500(self): + @pytest.mark.parametrize( + "status_code,error_type,code,message,headers,retry_after", + SYNC_CASES, + ) + def test_get_logs_raises_specific_error( + self, + status_code, + error_type, + code, + message, + headers, + retry_after, + ): respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{SANDBOX_ID}/cmd/{CMD_ID}/logs").mock( - return_value=httpx.Response( - 500, - json={"error": {"code": "internal_server_error", "message": "Something broke."}}, + return_value=_make_error_response( + status_code, + code=code, + message=message, + headers=headers, ) ) client = _sync_client(host=SANDBOX_API_BASE, team_id="team_test", token="tok") - with pytest.raises(APIError) as exc_info: - list(client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID)) - - assert exc_info.value.status_code == 500 - client.close() + try: + with pytest.raises(error_type) as exc_info: + list(client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID)) + finally: + client.close() + + error = exc_info.value + assert type(error) is error_type + assert isinstance(error, APIError) + assert isinstance(error, SandboxError) + assert error.status_code == status_code + assert error.data == {"error": {"code": code, "message": message}} + assert message in str(error) + if retry_after is not None: + assert getattr(error, "retry_after", None) == retry_after class TestStreamingErrorsAsync: - """Async get_logs raises APIError on non-2xx responses.""" + """Async get_logs raises sandbox-specific errors on non-2xx responses.""" @respx.mock @pytest.mark.asyncio - async def test_get_logs_raises_on_401(self): + @pytest.mark.parametrize( + "status_code,error_type,code,message,headers,retry_after", + SYNC_CASES, + ) + async def test_get_logs_raises_specific_error( + self, + status_code, + error_type, + code, + message, + headers, + retry_after, + ): respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{SANDBOX_ID}/cmd/{CMD_ID}/logs").mock( - return_value=httpx.Response( - 401, - json={"error": {"code": "unauthorized", "message": "Authentication required."}}, - ) - ) - - client = _async_client(host=SANDBOX_API_BASE, team_id="team_test", token="bad_token") - with pytest.raises(APIError) as exc_info: - async for _ in client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID): - pass - - assert exc_info.value.status_code == 401 - await client.aclose() - - @respx.mock - @pytest.mark.asyncio - async def test_get_logs_raises_on_500(self): - respx.get(f"{SANDBOX_API_BASE}/v1/sandboxes/{SANDBOX_ID}/cmd/{CMD_ID}/logs").mock( - return_value=httpx.Response( - 500, - json={"error": {"code": "internal_server_error", "message": "Something broke."}}, + return_value=_make_error_response( + status_code, + code=code, + message=message, + headers=headers, ) ) client = _async_client(host=SANDBOX_API_BASE, team_id="team_test", token="tok") - with pytest.raises(APIError) as exc_info: - async for _ in client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID): - pass - - assert exc_info.value.status_code == 500 - await client.aclose() + try: + with pytest.raises(error_type) as exc_info: + async for _ in client.get_logs(sandbox_id=SANDBOX_ID, cmd_id=CMD_ID): + pass + finally: + await client.aclose() + + error = exc_info.value + assert type(error) is error_type + assert isinstance(error, APIError) + assert isinstance(error, SandboxError) + assert error.status_code == status_code + assert error.data == {"error": {"code": code, "message": message}} + assert message in str(error) + if retry_after is not None: + assert getattr(error, "retry_after", None) == retry_after diff --git a/tests/unit/test_sandbox_errors.py b/tests/unit/test_sandbox_errors.py new file mode 100644 index 0000000..e13a3b6 --- /dev/null +++ b/tests/unit/test_sandbox_errors.py @@ -0,0 +1,116 @@ +"""Unit tests for sandbox error hierarchy and classification.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from vercel._internal.http import BaseTransport, RequestClient +from vercel._internal.http.transport import RequestBody +from vercel._internal.iter_coroutine import iter_coroutine +from vercel._internal.sandbox.core import SandboxRequestClient +from vercel._internal.sandbox.errors import ( + APIError, + SandboxAuthError, + SandboxError, + SandboxPermissionError, + SandboxRateLimitError, + SandboxServerError, +) +from vercel.sandbox import ( + APIError as PublicAPIError, + SandboxAuthError as PublicSandboxAuthError, + SandboxError as PublicSandboxError, + SandboxPermissionError as PublicSandboxPermissionError, + SandboxRateLimitError as PublicSandboxRateLimitError, + SandboxServerError as PublicSandboxServerError, +) + + +class StaticTransport(BaseTransport): + def __init__(self, response: httpx.Response) -> None: + self.response = response + + async def send( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: RequestBody = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + follow_redirects: bool | None = None, + stream: bool = False, + ) -> httpx.Response: + return self.response + + +def _make_response( + status_code: int, + *, + code: str, + message: str, + headers: dict[str, str] | None = None, +) -> httpx.Response: + return httpx.Response( + status_code, + headers=headers, + json={"error": {"code": code, "message": message}}, + ) + + +def _make_request_client(response: httpx.Response) -> SandboxRequestClient: + request_client = RequestClient( + transport=StaticTransport(response), + token="test-token", + sleep_fn=lambda _seconds: None, + ) + return SandboxRequestClient(request_client=request_client) + + +def test_public_error_hierarchy_is_exposed() -> None: + assert issubclass(PublicAPIError, PublicSandboxError) + assert issubclass(PublicSandboxAuthError, PublicAPIError) + assert issubclass(PublicSandboxPermissionError, PublicAPIError) + assert issubclass(PublicSandboxRateLimitError, PublicAPIError) + assert issubclass(PublicSandboxServerError, PublicAPIError) + + +@pytest.mark.parametrize( + "status_code,error_type,code,message,headers,retry_after", + [ + (401, SandboxAuthError, "unauthorized", "Authentication required.", None, None), + (403, SandboxPermissionError, "forbidden", "Access denied.", None, None), + (429, SandboxRateLimitError, "rate_limited", "Slow down.", {"retry-after": "120"}, 120), + (500, SandboxServerError, "internal_server_error", "Something broke.", None, None), + (404, APIError, "not_found", "Missing file.", None, None), + ], +) +def test_request_classifies_sandbox_http_errors( + status_code: int, + error_type: type[APIError], + code: str, + message: str, + headers: dict[str, str] | None, + retry_after: int | None, +) -> None: + response = _make_response(status_code, code=code, message=message, headers=headers) + client = _make_request_client(response) + + with pytest.raises(error_type) as exc_info: + iter_coroutine(client.request("GET", "/v1/sandboxes/test")) + + error = exc_info.value + assert type(error) is error_type + assert isinstance(error, SandboxError) + assert isinstance(error, APIError) + assert error.response is response + assert error.status_code == status_code + assert error.data == {"error": {"code": code, "message": message}} + assert f"HTTP {status_code}" in str(error) + assert message in str(error) + if retry_after is not None: + assert getattr(error, "retry_after", None) == retry_after