Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dd9d4eb
feat(sdk): add SandboxUnreachableError for an unreachable sandbox
devin-ai-integration[bot] Oct 6, 2026
23130a0
revert proxy 502 'port is not open' mapping; keep sandbox-timeout Tim…
devin-ai-integration[bot] Oct 6, 2026
9dcc6f8
fix(js-sdk): map Unavailable on command/watch start through the rpc e…
devin-ai-integration[bot] Oct 6, 2026
f30aebd
fix(js-sdk): treat browsers' opaque network TypeErrors as transport f…
devin-ai-integration[bot] Oct 6, 2026
bac187c
feat(sdk): SandboxUnreachableError extends TimeoutError; raise it for…
devin-ai-integration[bot] Oct 6, 2026
d9612f9
feat(sdk): raise SandboxNotFoundError/SandboxNotFoundException for a …
devin-ai-integration[bot] Oct 6, 2026
db210d2
feat(sdk): probe sandbox health on an Unavailable with an unknown mes…
devin-ai-integration[bot] Oct 6, 2026
5080b27
feat(sdk): raise SandboxUnreachableError for every 502/Unavailable fr…
devin-ai-integration[bot] Oct 6, 2026
ca30ac8
fix(js-sdk): let Connect-encoded 502 errors through rejectProxyUnavai…
devin-ai-integration[bot] Oct 6, 2026
25d3dc5
fix(js-sdk): only forward 502 bodies whose string code is a Connect c…
devin-ai-integration[bot] Oct 6, 2026
4bd05ee
fix(sdk): scope the start error map to watchDir; filesystem RPCs keep…
devin-ai-integration[bot] Oct 6, 2026
8a0c90b
feat(sdk): add SandboxNotRunningError/Exception for a proxy or probe …
devin-ai-integration[bot] Oct 6, 2026
19c97cc
fix(sdk): keep SandboxNotFoundError on JS command/PTY/watch start, pr…
devin-ai-integration[bot] Oct 6, 2026
5a6f97a
feat(js-sdk)!: command/PTY/watch start on a gone sandbox throws Sandb…
devin-ai-integration[bot] Oct 6, 2026
e730668
chore: bump the sandbox unreachable changeset to minor
devin-ai-integration[bot] Oct 6, 2026
9b42f78
docs: shorten the sandbox unreachable changeset
devin-ai-integration[bot] Oct 6, 2026
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
12 changes: 12 additions & 0 deletions .changeset/sandbox-unreachable-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@e2b/python-sdk': minor
'e2b': minor
---

Add `SandboxNotRunningError` / `SandboxNotRunningException` and `SandboxUnreachableError` / `SandboxUnreachableException` for envd requests that fail because the sandbox cannot be reached. Both extend `TimeoutError` / `TimeoutException`, which these cases raised before, so existing `instanceof TimeoutError` / `except TimeoutException` handling keeps working.

- `SandboxNotRunning*` — the proxy answered `502 "The sandbox was not found"` (killed, paused or timed out), either directly or from the health probe the SDK runs after a dropped connection or an unexplained `Unavailable` (e.g. the sandbox was killed mid-command). Retrying will not help.
- `SandboxUnreachable*` — the sandbox is not confirmed stopped but cannot be reached: `502 "port is not open"`, other `502`s, or a connection failure whose health probe got no answer. The original error is the `cause` / `__cause__`.
- A connection failure whose health probe finds the sandbox running still raises the original transport error. The probe itself runs without connection retries.

**Breaking (JS):** `commands.run` / `connect`, `pty.create` / `connect` and `files.watchDir` on a gone sandbox now throw `SandboxNotRunningError` instead of `SandboxNotFoundError`, like every other call. `SandboxNotFoundError` is only raised by the control plane API.
155 changes: 76 additions & 79 deletions packages/js-sdk/src/envd/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import {
InvalidArgumentError,
NotFoundError,
NotEnoughSpaceError,
SandboxNotFoundError,
formatSandboxTimeoutError,
formatSandboxUnavailableError,
SandboxNotRunningError,
AuthenticationError,
RateLimitError,
TimeoutError,
} from '../errors'
import { StartResponse, ConnectResponse } from './process/process_pb'
import { Code, ConnectError } from '@connectrpc/connect'
import { WatchDirResponse } from './filesystem/filesystem_pb'
import { isConnectionTerminatedMessage, SandboxHealthCheck } from './rpc'
import {
isConnectionTerminatedMessage,
resolveTransportFailure,
SandboxHealthCheck,
} from './rpc'
import { isNetworkError } from '../retry'

type ApiError = { message?: string } | string

Expand All @@ -27,7 +30,7 @@ const DEFAULT_ERROR_MAP: Record<number, (message: string) => Error> = {
404: (message) => new NotFoundError(message),
429: (message) =>
new RateLimitError(`${message}: The requests are being rate limited.`),
502: formatSandboxTimeoutError,
502: formatSandboxUnavailableError,
507: (message) => new NotEnoughSpaceError(message),
}

Expand All @@ -37,59 +40,90 @@ const HEALTH_CHECK_TIMEOUT_MS = 5_000
* Probes the sandbox's envd health endpoint.
*
* @param envdApi - The envd API client of the sandbox.
* @returns `true` if the sandbox is running, `false` if it is not, `undefined` if its state could not be determined.
* @returns `true` if the sandbox is running, `false` if it is not, `undefined` if it answered but its state could not be determined.
* @throws The `fetch` failure when the sandbox could not be reached (no answer within the probe timeout), or the `SandboxUnreachableError` a request would get when the proxy reports the sandbox running but envd's port not open.
*/
export async function checkSandboxHealth(
envdApi: EnvdApiClient
): Promise<boolean | undefined> {
try {
const res = await envdApi.api.GET('/health', {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
})
const res = await envdApi.api.GET('/health', {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
})

if (res.response.status === 502) {
if (res.response.status === 502) {
const err = formatSandboxUnavailableError(await getEnvdApiErrorMessage(res))
if (err instanceof SandboxNotRunningError) {
return false
}
if (res.response.ok) {
return true
}

return undefined
} catch {
return undefined
throw err
}
if (res.response.ok) {
return true
}

return undefined
}

/**
* Checks whether a `fetch` rejection is a transport-level failure of the request to
* the sandbox: the connection could not be established or was dropped mid-request
* (the latter surfaces with runtime-specific wording, e.g. undici's `terminated`).
*/
export function isFetchTransportFailure(err: unknown): err is Error {
return (
err instanceof Error &&
(isConnectionTerminatedMessage(err.message) || isNetworkError(err))
)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

/**
* Handles transport-level fetch failures from envd API calls. When the connection was
* dropped mid-request, probes the sandbox health to tell apart the sandbox being killed
* from a transient network failure (e.g. a load balancer dropping the connection).
* Handles transport-level fetch failures from envd API calls. When the connection to
* the sandbox could not be established or was dropped mid-request, probes the sandbox
* health to tell apart the sandbox being killed from the sandbox being unreachable or
* a transient network failure (e.g. a load balancer dropping the connection).
*
* @param err - The caught error, expected to be a fetch transport failure.
* @param checkHealth - Probe returning whether the sandbox is running, or `undefined` when unknown.
* @returns A `TimeoutError` when the connection was terminated mid-request and the sandbox is confirmed gone, or the original error otherwise.
* @param checkHealth - Probe resolving to whether the sandbox is running (`undefined` when unknown) and rejecting when it cannot be reached.
* @returns A `SandboxNotRunningError` when the sandbox is confirmed gone, a `SandboxUnreachableError` when the probe got no answer either, or the original error otherwise.
*/
export async function handleEnvdApiFetchError(
err: unknown,
checkHealth?: SandboxHealthCheck
): Promise<Error> {
// A connection dropped mid-body surfaces as a fetch failure whose message varies
// by runtime (e.g. undici's 'terminated'); match every known variant.
if (err instanceof Error && isConnectionTerminatedMessage(err.message)) {
const running = checkHealth
? await checkHealth().catch(() => undefined)
: undefined

if (running === false) {
return new TimeoutError(
`${err.message}: The sandbox was killed or reached its end of life while the request was in flight.`
)
if (checkHealth && isFetchTransportFailure(err)) {
const resolved = await resolveTransportFailure(err, checkHealth)
if (resolved) {
return resolved
}
}

return err as Error
}

/**
* Extracts the error message of a non-2xx envd API response: the `message` of its
* JSON body, else its text body, else its status text.
*/
export async function getEnvdApiErrorMessage(res: {
error?: ApiError
response: Response
}): Promise<string> {
let message =
(typeof res.error === 'string' ? res.error : res.error?.message) ?? ''

// openapi-fetch consumes the body when parsing the error, except for
// responses without content
if (!message && !res.response.bodyUsed) {
try {
message = await res.response.text()
} catch {
// ignore unreadable bodies
}
}

return message || res.response.statusText
}

/**
* Handles errors from envd API responses by mapping HTTP status codes to specific error types.
*
Expand All @@ -111,20 +145,7 @@ export async function handleEnvdApiError(
return
}

let message =
(typeof res.error === 'string' ? res.error : res.error?.message) ?? ''

// openapi-fetch consumes the body when parsing the error, except for
// responses without content
if (!message && !res.response.bodyUsed) {
try {
message = await res.response.text()
} catch {
// ignore unreadable bodies
}
}

message = message || res.response.statusText
const message = await getEnvdApiErrorMessage(res)

// Check if a custom error mapping is provided for this error code
if (errorMap && res.response.status in errorMap) {
Expand All @@ -143,21 +164,9 @@ export async function handleEnvdApiError(
export async function handleProcessStartEvent(
events: AsyncIterable<StartResponse | ConnectResponse>
) {
let startEvent: StartResponse | ConnectResponse

try {
startEvent = (await events[Symbol.asyncIterator]().next()).value
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.Unavailable) {
throw new SandboxNotFoundError(
'Sandbox is probably not running anymore'
)
}
}

throw err
}
const startEvent: StartResponse | ConnectResponse = (
await events[Symbol.asyncIterator]().next()
).value
if (startEvent.event?.event.case !== 'start') {
throw new Error('Expected start event')
}
Expand All @@ -168,21 +177,9 @@ export async function handleProcessStartEvent(
export async function handleWatchDirStartEvent(
events: AsyncIterable<WatchDirResponse>
) {
let startEvent: WatchDirResponse

try {
startEvent = (await events[Symbol.asyncIterator]().next()).value
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.Unavailable) {
throw new SandboxNotFoundError(
'Sandbox is probably not running anymore'
)
}
}

throw err
}
const startEvent: WatchDirResponse = (
await events[Symbol.asyncIterator]().next()
).value
if (startEvent.event?.case !== 'start') {
throw new Error('Expected start event')
}
Expand Down
Loading
Loading