Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 55 additions & 0 deletions .changeset/trace-id-error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"e2b": minor
"@e2b/python-sdk": patch
"@e2b/cli": patch
---

Append the trace ID of failed requests to SDK and CLI error messages. When a
failed API or envd response carries a trace header (`X-Trace-ID`, or the GCP
`X-Cloud-Trace-Context` / AWS `X-Amzn-Trace-Id` edge headers), the error
message now ends with `(trace ID: ...)` so users can include the ID when
reporting the failure to E2B and it can be correlated with server-side traces.
The ID is also readable off the error itself — `error.traceId` in JS,
`exception.trace_id` in Python — so it can be forwarded to your own error
reporting without parsing the message:

```ts
try {
await sandbox.files.read('/missing')
} catch (error) {
if (error instanceof SandboxError) {
reportToSentry({ traceId: error.traceId })
}
}
```

```python
try:
sandbox.files.read("/missing")
except SandboxException as error:
report_to_sentry(trace_id=error.trace_id)
```

The error classes take the trace ID as optional constructor context — a trailing
options object in JS (`new SandboxError(message, { traceId })`) and a
keyword-only argument in Python (`SandboxException(message, trace_id=...)`).

Two exported option types describe that context: `ErrorOpts` (`traceId`) and
`ErrorOptsWithStackTrace` (adds `stackTrace`). Only the classes that are actually
handed a stack trace take the latter — `InvalidArgumentError`, `TemplateError`,
`BuildError`, `FileUploadError`, and `AuthenticationError` / `RateLimitError`,
which a 401 or 429 during a template file upload can produce. Every other class
takes `ErrorOpts`, so passing `stackTrace` to one is a type error instead of
being silently ignored.

**Breaking (JS):** the options object replaces the positional `stackTrace`
parameter on the four classes that had one, so
`new TemplateError(message, stackTrace)` becomes
`new TemplateError(message, { stackTrace })` (likewise `InvalidArgumentError`,
`BuildError`, and `FileUploadError`). TypeScript callers get a compile error on
the old form; plain-JS callers silently lose the stack trace. Python is
unaffected — its exceptions already took a single positional argument.

The header parser is exported too, for callers that handle an E2B response
themselves and want the same ID in their own error: `extractTraceId(headers)`
in JS, `extract_trace_id(headers)` in Python.
15 changes: 11 additions & 4 deletions packages/cli/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractTraceId } from 'e2b'
import status from 'statuses'

/**
Expand All @@ -16,13 +17,19 @@ type E2BResponse<TData> =
| {
data: TData
error?: undefined
response?: { headers?: Headers }
}
| {
data?: undefined
error: E2BResponseError
response?: { headers?: Headers }
}

function throwE2BRequestError(error: E2BResponseError, errMsg?: string): never {
function throwE2BRequestError(
error: E2BResponseError,
errMsg?: string,
traceId?: string
): never {
let message: string
const code = error.code ?? 0
switch (code) {
Expand All @@ -49,12 +56,12 @@ function throwE2BRequestError(error: E2BResponseError, errMsg?: string): never {
throw new E2BRequestError(
`${errMsg && `${errMsg}: `}[${code}] ${message && `${message}: `}${
error.message ?? 'no message'
}`
}${traceId ? ` (trace ID: ${traceId})` : ''}`
)
}

export function handleE2BRequestError(
res: { error: E2BResponseError },
res: { error: E2BResponseError; response?: { headers?: Headers } },
errMsg?: string
): never
export function handleE2BRequestError<TData>(
Expand All @@ -68,5 +75,5 @@ export function handleE2BRequestError(
if (!res.error) {
return
}
throwE2BRequestError(res.error, errMsg)
throwE2BRequestError(res.error, errMsg, extractTraceId(res.response?.headers))
}
77 changes: 77 additions & 0 deletions packages/cli/tests/utils/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,81 @@ describe('handleE2BRequestError', () => {
'[502] Bad Gateway: upstream down'
)
})

test('appends the trace ID from X-Trace-ID to the message', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: { headers: new Headers({ 'X-Trace-ID': 'abc123' }) },
}
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
'Request failed: [500] internal server error: internal error (trace ID: abc123)'
)
})

test('appends the trace ID from the GCP edge header', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: {
headers: new Headers({
'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1',
}),
},
}
expect(() => handleE2BRequestError(res)).toThrow(
'(trace ID: 105445aa7843bc8bf206b12000100000)'
)
})

test('normalizes the AWS edge header to the 32-hex trace ID', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: {
headers: new Headers({
'X-Amzn-Trace-Id': 'Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1',
}),
},
}
expect(() => handleE2BRequestError(res)).toThrow(
'(trace ID: 5759e988bd862e3fe1be46a994272793)'
)
})

test('falls back to the raw Root value for an unexpected AWS format', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: {
headers: new Headers({ 'X-Amzn-Trace-Id': 'Root=custom-value' }),
},
}
expect(() => handleE2BRequestError(res)).toThrow(
'(trace ID: custom-value)'
)
})

test('prefers X-Trace-ID over the cloud edge headers', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: {
headers: new Headers({
'X-Trace-ID': 'explicit',
'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1',
'X-Amzn-Trace-Id': 'Root=1-5759e988-bd862e3fe1be46a994272793',
}),
},
}
expect(() => handleE2BRequestError(res)).toThrow('(trace ID: explicit)')
})

test('leaves the message unchanged without trace headers', () => {
const res = {
error: { code: 500, message: 'internal error' },
response: { headers: new Headers() },
}
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
'Request failed: [500] internal server error: internal error'
)
expect(() => handleE2BRequestError(res, 'Request failed')).not.toThrow(
'trace ID'
)
})
})
27 changes: 18 additions & 9 deletions packages/js-sdk/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { defaultHeaders } from './metadata'
import { createApiFetch } from './http2'
import { ConnectionConfig } from '../connectionConfig'
import { AuthenticationError, RateLimitError, SandboxError } from '../errors'
import type { ErrorOptsWithStackTrace } from '../errors'
import { createApiLogger } from '../logs'
import { extractTraceId } from '../traceId'

const API_KEY_PATTERN = /^e2b_[0-9a-f]+$/
const API_KEY_EXAMPLE = `e2b_${'0'.repeat(40)}`
Expand Down Expand Up @@ -33,54 +35,61 @@ export function apiErrorFromCode(
content: unknown,
errorClass: new (
message: string,
stackTrace?: string
opts?: ErrorOptsWithStackTrace
) => Error = SandboxError,
stackTrace?: string
opts?: ErrorOptsWithStackTrace
): Error {
if (code === 401) {
const message = 'Unauthorized, please check your credentials.'
return new AuthenticationError(
content ? `${message} - ${content}` : message
content ? `${message} - ${content}` : message,
opts
)
}

if (code === 429) {
const message = 'Rate limit exceeded, please try again later'
return new RateLimitError(content ? `${message} - ${content}` : message)
return new RateLimitError(
content ? `${message} - ${content}` : message,
opts
)
}

return new errorClass(`${code}: ${content}`, stackTrace)
return new errorClass(`${code}: ${content}`, opts)
}

export function handleApiError(
response: FetchResponse<any, any, any>,
errorClass: new (
message: string,
stackTrace?: string
opts?: ErrorOptsWithStackTrace
) => Error = SandboxError,
stackTrace?: string
// `traceId` is read off the response, so only `stackTrace` is caller-supplied
opts?: Pick<ErrorOptsWithStackTrace, 'stackTrace'>
): Error | undefined {
// openapi-fetch leaves `error` undefined for non-2xx responses with
// Content-Length: 0, so check the status instead
if (response.response.ok) {
return
}

const traceId = extractTraceId(response.response.headers)

const status = response.response.status
if (status === 401 || status === 429) {
return apiErrorFromCode(
status,
response.error?.message ?? response.error,
errorClass,
stackTrace
{ ...opts, traceId }
)
}

return apiErrorFromCode(
status,
response.error?.message || response.error || response.response.statusText,
errorClass,
stackTrace
{ ...opts, traceId }
)
}

Expand Down
32 changes: 21 additions & 11 deletions packages/js-sdk/src/envd/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import createClient from 'openapi-fetch'
import type { components, paths } from './schema.gen'
import { ConnectionConfig } from '../connectionConfig'
import { createApiLogger } from '../logs'
import type { ErrorOpts } from '../errors'
import {
SandboxError,
InvalidArgumentError,
Expand All @@ -18,17 +19,24 @@ 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 { extractTraceId } from '../traceId'

type ApiError = { message?: string } | string

const DEFAULT_ERROR_MAP: Record<number, (message: string) => Error> = {
400: (message) => new InvalidArgumentError(message),
401: (message) => new AuthenticationError(message),
404: (message) => new NotFoundError(message),
429: (message) =>
new RateLimitError(`${message}: The requests are being rate limited.`),
const DEFAULT_ERROR_MAP: Record<
number,
(message: string, opts?: ErrorOpts) => Error
> = {
400: (message, opts) => new InvalidArgumentError(message, opts),
401: (message, opts) => new AuthenticationError(message, opts),
404: (message, opts) => new NotFoundError(message, opts),
429: (message, opts) =>
new RateLimitError(
`${message}: The requests are being rate limited.`,
opts
),
502: formatSandboxTimeoutError,
507: (message) => new NotEnoughSpaceError(message),
507: (message, opts) => new NotEnoughSpaceError(message, opts),
}

const HEALTH_CHECK_TIMEOUT_MS = 5_000
Expand Down Expand Up @@ -102,7 +110,7 @@ export async function handleEnvdApiError(
error?: ApiError
response: Response
},
errorMap?: Record<number, (message: string) => Error>
errorMap?: Record<number, (message: string, opts?: ErrorOpts) => Error>
) {
// openapi-fetch leaves `error` empty for non-2xx responses without content
// (undefined for Content-Length: 0, '' for an empty body without the
Expand All @@ -126,18 +134,20 @@ export async function handleEnvdApiError(

message = message || res.response.statusText

const traceId = extractTraceId(res.response.headers)

// Check if a custom error mapping is provided for this error code
if (errorMap && res.response.status in errorMap) {
return errorMap[res.response.status]?.(message)
return errorMap[res.response.status]?.(message, { traceId })
}

// Check if there is a default error mapping for this error code
if (res.response.status in DEFAULT_ERROR_MAP) {
return DEFAULT_ERROR_MAP[res.response.status]?.(message)
return DEFAULT_ERROR_MAP[res.response.status]?.(message, { traceId })
}

// Fallback to a generic SandboxError if no specific mapping is found
return new SandboxError(`${res.response.status}: ${message}`)
return new SandboxError(`${res.response.status}: ${message}`, { traceId })
}

export async function handleProcessStartEvent(
Expand Down
Loading
Loading