140 lines
6.1 KiB
Python
140 lines
6.1 KiB
Python
import base64
|
|
|
|
import httpcore
|
|
from typing import Awaitable, Callable, Optional
|
|
from packaging.version import Version
|
|
from e2b_connect.client import Code, ConnectException
|
|
|
|
from e2b.exceptions import (
|
|
SandboxException,
|
|
InvalidArgumentException,
|
|
NotFoundException,
|
|
TimeoutException,
|
|
format_sandbox_timeout_exception,
|
|
AuthenticationException,
|
|
RateLimitException,
|
|
)
|
|
from e2b.connection_config import Username, default_username
|
|
from e2b.envd.versions import ENVD_DEFAULT_USER
|
|
|
|
_DEFAULT_RPC_ERROR_MAP: dict[Code, Callable[[str], Exception]] = {
|
|
Code.invalid_argument: InvalidArgumentException,
|
|
Code.unauthenticated: AuthenticationException,
|
|
Code.not_found: NotFoundException,
|
|
Code.unavailable: format_sandbox_timeout_exception,
|
|
Code.resource_exhausted: lambda message: RateLimitException(
|
|
f"{message}: Rate limit exceeded, please try again later."
|
|
),
|
|
Code.canceled: lambda message: TimeoutException(
|
|
f"{message}: This error is likely due to exceeding 'request_timeout'. You can pass the request timeout value as an option when making the request."
|
|
),
|
|
Code.deadline_exceeded: lambda message: TimeoutException(
|
|
f"{message}: This error is likely due to exceeding 'timeout' — the total time a long running request (like process or directory watch) can be active. It can be modified by passing 'timeout' when making the request. Use '0' to disable the timeout."
|
|
),
|
|
}
|
|
|
|
|
|
def format_terminated_exception(
|
|
e: Exception,
|
|
sandbox_running: Optional[bool],
|
|
) -> Exception:
|
|
"""Handle an exception for a connection to the sandbox dropped mid-request: when a
|
|
sandbox health probe confirmed the sandbox is gone (``sandbox_running is False``),
|
|
return a ``TimeoutException``; otherwise return the original error unchanged."""
|
|
if sandbox_running is False:
|
|
return TimeoutException(
|
|
f"{e}: The sandbox was killed or reached its end of life while the request was in flight."
|
|
)
|
|
return e
|
|
|
|
|
|
def handle_rpc_exception(
|
|
e: Exception,
|
|
error_map: Optional[dict[Code, Callable[[str], Exception]]] = None,
|
|
sandbox_running: Optional[bool] = None,
|
|
):
|
|
"""Handle errors from envd RPC calls by mapping gRPC status codes to specific exception types.
|
|
|
|
:param e: The caught exception, expected to be a ``ConnectException`` or a transport-level ``httpcore`` error.
|
|
:param error_map: Optional map of gRPC codes to exception factories that override the defaults.
|
|
:param sandbox_running: Result of a sandbox health probe (``None`` when unknown), used to disambiguate a connection dropped mid-request.
|
|
:return: The corresponding exception. A connection dropped mid-request with the sandbox confirmed gone becomes a ``TimeoutException``; non-``ConnectException`` errors are otherwise returned as-is.
|
|
"""
|
|
if isinstance(e, ConnectException):
|
|
if error_map and e.status in error_map:
|
|
return error_map[e.status](e.message)
|
|
|
|
if e.status in _DEFAULT_RPC_ERROR_MAP:
|
|
return _DEFAULT_RPC_ERROR_MAP[e.status](e.message)
|
|
|
|
return SandboxException(f"{e.status}: {e.message}")
|
|
|
|
# A remote protocol error (e.g. an HTTP/2 stream reset) means the connection to the
|
|
# sandbox was dropped mid-request — either the sandbox died or the network failed
|
|
if isinstance(e, httpcore.RemoteProtocolError):
|
|
return format_terminated_exception(e, sandbox_running)
|
|
|
|
# A transport-level timeout from httpcore means a configured timeout was exceeded
|
|
# before the server responded: `request_timeout` on a unary call's read phase, or
|
|
# `connect`/`pool`/`write` on a stream's setup/send phase. Streams have no read
|
|
# timeout — the command `timeout` is enforced server-side and surfaces as a
|
|
# `deadline_exceeded` ConnectException instead. Unlike the JS SDK, where the
|
|
# request timeout is an `AbortSignal` that connect normalizes into a `Code.canceled`
|
|
# ConnectError, httpcore raises this raw transport error outside the ConnectException
|
|
# path, so we map it here to a `TimeoutException` for a consistent timeout error.
|
|
if isinstance(e, httpcore.TimeoutException):
|
|
return TimeoutException(
|
|
f"{e}: This error is likely due to exceeding 'timeout' — the total time a long running request (like process or directory watch) can be active — or 'request_timeout'. You can modify these by passing 'timeout' or 'request_timeout' when making the request. Use '0' to disable the timeout."
|
|
)
|
|
|
|
return e
|
|
|
|
|
|
def handle_rpc_exception_with_health(
|
|
e: Exception,
|
|
check_health: Optional[Callable[[], Optional[bool]]] = None,
|
|
error_map: Optional[dict[Code, Callable[[str], Exception]]] = None,
|
|
):
|
|
"""Like :func:`handle_rpc_exception`, but when the connection to the sandbox was
|
|
dropped mid-request it probes the sandbox health to tell apart the sandbox being
|
|
killed from a transient network failure (e.g. a load balancer dropping the connection).
|
|
"""
|
|
sandbox_running = None
|
|
if check_health is not None and isinstance(e, httpcore.RemoteProtocolError):
|
|
try:
|
|
sandbox_running = check_health()
|
|
except Exception:
|
|
sandbox_running = None
|
|
return handle_rpc_exception(e, error_map, sandbox_running)
|
|
|
|
|
|
async def ahandle_rpc_exception_with_health(
|
|
e: Exception,
|
|
check_health: Optional[Callable[[], Awaitable[Optional[bool]]]] = None,
|
|
error_map: Optional[dict[Code, Callable[[str], Exception]]] = None,
|
|
):
|
|
"""Async version of :func:`handle_rpc_exception_with_health`."""
|
|
sandbox_running = None
|
|
if check_health is not None and isinstance(e, httpcore.RemoteProtocolError):
|
|
try:
|
|
sandbox_running = await check_health()
|
|
except Exception:
|
|
sandbox_running = None
|
|
return handle_rpc_exception(e, error_map, sandbox_running)
|
|
|
|
|
|
def authentication_header(
|
|
envd_version: Version, user: Optional[Username] = None
|
|
) -> dict[str, str]:
|
|
if user is None and envd_version < ENVD_DEFAULT_USER:
|
|
user = default_username
|
|
|
|
if not user:
|
|
return {}
|
|
|
|
value = f"{user}:"
|
|
|
|
encoded = base64.b64encode(value.encode("utf-8")).decode("utf-8")
|
|
|
|
return {"Authorization": f"Basic {encoded}"}
|