diff --git a/.changeset/trace-id-error-messages.md b/.changeset/trace-id-error-messages.md new file mode 100644 index 0000000000..30150a73f3 --- /dev/null +++ b/.changeset/trace-id-error-messages.md @@ -0,0 +1,62 @@ +--- +"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) +``` + +Every error class carries the field, but per-domain: the SDK has one root per +domain rather than a single shared base, so narrow to the root for the call you +made — `SandboxError`/`SandboxException` for sandbox operations, +`VolumeError`/`VolumeException` for volumes, `SecretError`/`SecretException` for +secrets, `BuildError`/`BuildException` for template builds, and +`AuthenticationError`/`AuthenticationException`, which is orthogonal to all of +them and can surface from any of these calls. + +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 four classes that are +actually handed a stack trace take the latter — `InvalidArgumentError`, +`TemplateError`, `BuildError`, and `FileUploadError`. 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. diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index f59a522208..220268498c 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -1,3 +1,4 @@ +import { extractTraceId } from 'e2b' import status from 'statuses' /** @@ -16,13 +17,19 @@ type E2BResponse = | { 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) { @@ -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( @@ -68,5 +75,5 @@ export function handleE2BRequestError( if (!res.error) { return } - throwE2BRequestError(res.error, errMsg) + throwE2BRequestError(res.error, errMsg, extractTraceId(res.response?.headers)) } diff --git a/packages/cli/tests/utils/errors.test.ts b/packages/cli/tests/utils/errors.test.ts index ed3d2c0c08..294c432a21 100644 --- a/packages/cli/tests/utils/errors.test.ts +++ b/packages/cli/tests/utils/errors.test.ts @@ -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' + ) + }) }) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 66726513b3..a86a57c81b 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -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)}` @@ -33,32 +35,39 @@ export function apiErrorFromCode( content: unknown, errorClass: new ( message: string, - stackTrace?: string + opts?: ErrorOptsWithStackTrace ) => Error = SandboxError, - stackTrace?: string + opts?: ErrorOptsWithStackTrace ): Error { + // An expired key or a rate limit is a property of the request, not of the + // builder step that happened to make it, so these two keep the frame where + // they were constructed even when the caller supplied one. if (code === 401) { const message = 'Unauthorized, please check your credentials.' return new AuthenticationError( - content ? `${message} - ${content}` : message + content ? `${message} - ${content}` : message, + { traceId: opts?.traceId } ) } 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, { + traceId: opts?.traceId, + }) } - return new errorClass(`${code}: ${content}`, stackTrace) + return new errorClass(`${code}: ${content}`, opts) } export function handleApiError( response: FetchResponse, 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 ): Error | undefined { // openapi-fetch leaves `error` undefined for non-2xx responses with // Content-Length: 0, so check the status instead @@ -66,13 +75,15 @@ export function handleApiError( 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 } ) } @@ -80,7 +91,7 @@ export function handleApiError( status, response.error?.message || response.error || response.response.statusText, errorClass, - stackTrace + { ...opts, traceId } ) } diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts index 673648c886..162e19b5ea 100644 --- a/packages/js-sdk/src/envd/api.ts +++ b/packages/js-sdk/src/envd/api.ts @@ -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, @@ -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 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 @@ -102,7 +110,7 @@ export async function handleEnvdApiError( error?: ApiError response: Response }, - errorMap?: Record Error> + errorMap?: Record Error> ) { // openapi-fetch leaves `error` empty for non-2xx responses without content // (undefined for Content-Length: 0, '' for an empty body without the @@ -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( diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index 5ae98e06ab..9aa9686792 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -1,7 +1,35 @@ +/** + * Optional context attached to an SDK error. + */ +export interface ErrorOpts { + /** + * Trace ID of the failed request, appended to the message. + */ + traceId?: string +} + +/** + * Context for errors that can point somewhere other than where they were + * constructed. + */ +export interface ErrorOptsWithStackTrace extends ErrorOpts { + /** + * Stack trace to use instead of the one captured where the error is + * constructed, so the error points at the user's call site or at where the + * failure happened server-side. + */ + stackTrace?: string +} + +function formatMessage(message?: string, traceId?: string) { + return message && traceId ? `${message} (trace ID: ${traceId})` : message +} + // This is the message for the sandbox timeout error when the response code is 502/Unavailable -export function formatSandboxTimeoutError(message: string) { +export function formatSandboxTimeoutError(message: string, opts?: ErrorOpts) { return new TimeoutError( - `${message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout.` + `${message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout.`, + opts ) } @@ -11,9 +39,15 @@ export function formatSandboxTimeoutError(message: string) { * Thrown when general sandbox errors occur. */ export class SandboxError extends Error { - constructor(message?: string) { - super(message) + /** + * Trace ID of the failed request, when the response carried one. + */ + readonly traceId?: string + + constructor(message?: string, opts?: ErrorOpts) { + super(formatMessage(message, opts?.traceId)) this.name = 'SandboxError' + this.traceId = opts?.traceId } } @@ -29,8 +63,8 @@ export class SandboxError extends Error { * The [unknown] error type is sometimes caused by the sandbox timeout when the request is not processed correctly. */ export class TimeoutError extends SandboxError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'TimeoutError' } } @@ -39,11 +73,11 @@ export class TimeoutError extends SandboxError { * Thrown when an invalid argument is provided. */ export class InvalidArgumentError extends SandboxError { - constructor(message: string, stackTrace?: string) { - super(message) + constructor(message: string, opts?: ErrorOptsWithStackTrace) { + super(message, opts) this.name = 'InvalidArgumentError' - if (stackTrace) { - this.stack = stackTrace + if (opts?.stackTrace) { + this.stack = opts.stackTrace } } } @@ -52,8 +86,8 @@ export class InvalidArgumentError extends SandboxError { * Thrown when there is not enough disk space. */ export class NotEnoughSpaceError extends SandboxError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'NotEnoughSpaceError' } } @@ -64,8 +98,8 @@ export class NotEnoughSpaceError extends SandboxError { * @deprecated Use {@link FileNotFoundError} or {@link SandboxNotFoundError} instead. This class will be removed in the next major version. */ export class NotFoundError extends SandboxError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'NotFoundError' } } @@ -74,8 +108,8 @@ export class NotFoundError extends SandboxError { * Thrown when a file or directory is not found inside a sandbox. */ export class FileNotFoundError extends NotFoundError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'FileNotFoundError' } } @@ -84,8 +118,8 @@ export class FileNotFoundError extends NotFoundError { * Thrown when a sandbox is not found (e.g. it doesn't exist or is no longer running). */ export class SandboxNotFoundError extends NotFoundError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'SandboxNotFoundError' } } @@ -94,9 +128,15 @@ export class SandboxNotFoundError extends NotFoundError { * Thrown when authentication fails. */ export class AuthenticationError extends Error { - constructor(message: string) { - super(message) + /** + * Trace ID of the failed request, when the response carried one. + */ + readonly traceId?: string + + constructor(message: string, opts?: ErrorOpts) { + super(formatMessage(message, opts?.traceId)) this.name = 'AuthenticationError' + this.traceId = opts?.traceId } } @@ -104,8 +144,8 @@ export class AuthenticationError extends Error { * Thrown when git authentication fails. */ export class GitAuthError extends AuthenticationError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'GitAuthError' } } @@ -114,8 +154,8 @@ export class GitAuthError extends AuthenticationError { * Thrown when git upstream tracking is missing. */ export class GitUpstreamError extends SandboxError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'GitUpstreamError' } } @@ -124,11 +164,11 @@ export class GitUpstreamError extends SandboxError { * Thrown when the template uses old envd version. It isn't compatible with the new SDK. */ export class TemplateError extends SandboxError { - constructor(message: string, stackTrace?: string) { - super(message) + constructor(message: string, opts?: ErrorOptsWithStackTrace) { + super(message, opts) this.name = 'TemplateError' - if (stackTrace) { - this.stack = stackTrace + if (opts?.stackTrace) { + this.stack = opts.stackTrace } } } @@ -137,8 +177,8 @@ export class TemplateError extends SandboxError { * Thrown when the API rate limit is exceeded. */ export class RateLimitError extends SandboxError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'RateLimitError' } } @@ -147,11 +187,17 @@ export class RateLimitError extends SandboxError { * Thrown when the build fails. */ export class BuildError extends Error { - constructor(message: string, stackTrace?: string) { - super(message) + /** + * Trace ID of the failed request, when the response carried one. + */ + readonly traceId?: string + + constructor(message: string, opts?: ErrorOptsWithStackTrace) { + super(formatMessage(message, opts?.traceId)) this.name = 'BuildError' - if (stackTrace) { - this.stack = stackTrace + this.traceId = opts?.traceId + if (opts?.stackTrace) { + this.stack = opts.stackTrace } } } @@ -160,8 +206,8 @@ export class BuildError extends Error { * Thrown when the file upload fails. */ export class FileUploadError extends BuildError { - constructor(message: string, stackTrace?: string) { - super(message, stackTrace) + constructor(message: string, opts?: ErrorOptsWithStackTrace) { + super(message, opts) this.name = 'FileUploadError' } } @@ -172,9 +218,15 @@ export class FileUploadError extends BuildError { * Thrown when general volume errors occur. */ export class VolumeError extends Error { - constructor(message: string) { - super(message) + /** + * Trace ID of the failed request, when the response carried one. + */ + readonly traceId?: string + + constructor(message: string, opts?: ErrorOpts) { + super(formatMessage(message, opts?.traceId)) this.name = 'VolumeError' + this.traceId = opts?.traceId } } @@ -182,8 +234,8 @@ export class VolumeError extends Error { * Thrown when a volume is not found. */ export class VolumeNotFoundError extends VolumeError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'VolumeNotFoundError' } } @@ -192,8 +244,8 @@ export class VolumeNotFoundError extends VolumeError { * Thrown when a file or directory is not found inside a volume. */ export class VolumePathNotFoundError extends VolumeError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'VolumePathNotFoundError' } } @@ -204,9 +256,15 @@ export class VolumePathNotFoundError extends VolumeError { * Thrown when general secret errors occur. */ export class SecretError extends Error { - constructor(message: string) { - super(message) + /** + * Trace ID of the failed request, when the response carried one. + */ + readonly traceId?: string + + constructor(message: string, opts?: ErrorOpts) { + super(formatMessage(message, opts?.traceId)) this.name = 'SecretError' + this.traceId = opts?.traceId } } @@ -214,8 +272,8 @@ export class SecretError extends Error { * Thrown when a secret is not found. */ export class SecretNotFoundError extends SecretError { - constructor(message: string) { - super(message) + constructor(message: string, opts?: ErrorOpts) { + super(message, opts) this.name = 'SecretNotFoundError' } } diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index a803efcda9..87a244eb64 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -28,8 +28,11 @@ export { SecretError, SecretNotFoundError, } from './errors' +export type { ErrorOpts, ErrorOptsWithStackTrace } from './errors' export type { Logger } from './logs' +export { extractTraceId } from './traceId' + export { getSignature } from './sandbox/signature' export { FileType } from './sandbox/filesystem' diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 453b0012ac..a2f473ddc5 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -47,6 +47,7 @@ import { ENVD_VERSION_RECURSIVE_WATCH, ENVD_VERSION_WATCH_NETWORK_MOUNTS, } from '../../envd/versions' +import type { ErrorOpts } from '../../errors' import { FileNotFoundError, InvalidArgumentError, @@ -55,8 +56,12 @@ import { import { isReadableStreamLike } from '../../is' import { runtime, toBlob, toUploadBody } from '../../utils' -const FILESYSTEM_HTTP_ERROR_MAP: Record Error> = { - 404: (message: string) => new FileNotFoundError(message), +const FILESYSTEM_HTTP_ERROR_MAP: Record< + number, + (message: string, opts?: ErrorOpts) => Error +> = { + 404: (message: string, opts?: ErrorOpts) => + new FileNotFoundError(message, opts), } const FILESYSTEM_RPC_ERROR_MAP: Partial< diff --git a/packages/js-sdk/src/secret.ts b/packages/js-sdk/src/secret.ts index 37ae24bea3..36e0cb79bb 100644 --- a/packages/js-sdk/src/secret.ts +++ b/packages/js-sdk/src/secret.ts @@ -10,6 +10,7 @@ import { SecretNotFoundError, } from './errors' import { Paginator } from './paginator' +import { extractTraceId } from './traceId' import type { SandboxIamToken } from './sandbox/sandboxApi' const INVALID_SECRET_NAME_CHARS = /[{}\p{Cc}]/u @@ -227,7 +228,9 @@ export class Secret extends ClientFactory { }) if (res.response.status === 404) { - throw new SecretNotFoundError(`Secret ${secret} not found`) + throw new SecretNotFoundError(`Secret ${secret} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, SecretError) @@ -269,7 +272,9 @@ export class Secret extends ClientFactory { }) if (res.response.status === 404) { - throw new SecretNotFoundError(`Secret ${secret} not found`) + throw new SecretNotFoundError(`Secret ${secret} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, SecretError) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index f79321166e..ad3a470e9f 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -94,13 +94,15 @@ export async function getFileUploadLink( } ) - const error = handleApiError(fileUploadLinkRes, FileUploadError, stackTrace) + const error = handleApiError(fileUploadLinkRes, FileUploadError, { + stackTrace, + }) if (error) { throw error } if (!fileUploadLinkRes.data) { - throw new FileUploadError('Failed to get file upload link', stackTrace) + throw new FileUploadError('Failed to get file upload link', { stackTrace }) } return fileUploadLinkRes.data @@ -155,16 +157,17 @@ export async function uploadFile( const res = await putFileStream(url, tar.path, tar.size, signal) if (!res.ok) { - throw new FileUploadError( - `Failed to upload file: ${res.statusText}`, - stackTrace - ) + throw new FileUploadError(`Failed to upload file: ${res.statusText}`, { + stackTrace, + }) } } catch (error) { if (error instanceof FileUploadError) { throw error } - throw new FileUploadError(`Failed to upload file: ${error}`, stackTrace) + throw new FileUploadError(`Failed to upload file: ${error}`, { + stackTrace, + }) } finally { await cleanup?.() } @@ -388,10 +391,9 @@ export async function waitForBuildFinish( stackError = stackTraces[step] } - throw new BuildError( - buildStatus?.reason?.message ?? 'Unknown error', - stackError - ) + throw new BuildError(buildStatus?.reason?.message ?? 'Unknown error', { + stackTrace: stackError, + }) } case 'waiting': { break diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 1b712c18ae..932bf973c7 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -476,7 +476,7 @@ export class TemplateBase if (credentials && (!credentials.username || !credentials.password)) { throw new InvalidArgumentError( 'Both username and password are required when providing registry credentials', - getCallerFrame() + { stackTrace: getCallerFrame() } ) } @@ -857,7 +857,7 @@ export class TemplateBase if (this.baseTemplate !== 'mcp-gateway') { throw new BuildError( 'MCP servers can only be added to mcp-gateway template', - getCallerFrame() + { stackTrace: getCallerFrame() } ) } @@ -937,7 +937,7 @@ export class TemplateBase if (this.baseTemplate !== 'devcontainer') { throw new BuildError( 'Devcontainers can only used in the devcontainer template', - getCallerFrame() + { stackTrace: getCallerFrame() } ) } @@ -951,7 +951,7 @@ export class TemplateBase if (this.baseTemplate !== 'devcontainer') { throw new BuildError( 'Devcontainers can only used in the devcontainer template', - getCallerFrame() + { stackTrace: getCallerFrame() } ) } diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 868c389493..39bf64539c 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -36,7 +36,7 @@ export function validateRelativePath( if (path.isAbsolute(src)) { const error = new TemplateError( `Invalid source path "${src}": absolute paths are not allowed. Use a relative path within the context directory.`, - stackTrace + { stackTrace } ) throw error } @@ -58,7 +58,7 @@ export function validateRelativePath( if (escapes) { const error = new TemplateError( `Invalid source path "${src}": path escapes the context directory. The path must stay within the context directory.`, - stackTrace + { stackTrace } ) throw error } diff --git a/packages/js-sdk/src/traceId.ts b/packages/js-sdk/src/traceId.ts new file mode 100644 index 0000000000..de2f88823f --- /dev/null +++ b/packages/js-sdk/src/traceId.ts @@ -0,0 +1,59 @@ +/** + * Extract a trace ID from the HTTP response headers of a failed request, so it + * can be reported to E2B and correlated with server-side traces. + * + * The SDK does this for the errors it throws — reach for it when you handle an + * E2B response yourself. + * + * Headers are checked in order: + * 1. `X-Trace-ID` — used verbatim when present. + * 2. `X-Cloud-Trace-Context` (GCP edge) — `TRACE_ID/SPAN_ID;o=OPTIONS`, + * the part before `/` is the trace ID. + * 3. `X-Amzn-Trace-Id` (AWS edge) — `Root=1-<8 hex>-<24 hex>;...`, the two + * hex parts joined are the 32-hex trace ID the server logs. + * + * @param headers Response headers of the failed request. + * + * @returns The trace ID, or `undefined` when no trace header is present. + * + * @example + * ```ts + * import { extractTraceId } from 'e2b' + * + * const res = await fetch(url) + * if (!res.ok) { + * const traceId = extractTraceId(res.headers) + * throw new Error(`Request failed${traceId ? ` (trace ID: ${traceId})` : ''}`) + * } + * ``` + */ +export function extractTraceId(headers?: Headers): string | undefined { + if (!headers) { + return undefined + } + + const direct = headers.get('x-trace-id')?.trim() + if (direct) { + return direct + } + + const gcp = headers.get('x-cloud-trace-context')?.split('/')[0]?.trim() + if (gcp) { + return gcp + } + + const aws = headers.get('x-amzn-trace-id') + if (aws) { + for (const field of aws.split(';')) { + const [key, value] = field.trim().split('=') + if (key?.toLowerCase() !== 'root' || !value) { + continue + } + + const match = value.match(/^1-([0-9a-f]{8})-([0-9a-f]{24})$/i) + return match ? match[1] + match[2] : value + } + } + + return undefined +} diff --git a/packages/js-sdk/src/volume/index.ts b/packages/js-sdk/src/volume/index.ts index 4563d0f9d8..998a5f3871 100644 --- a/packages/js-sdk/src/volume/index.ts +++ b/packages/js-sdk/src/volume/index.ts @@ -19,6 +19,7 @@ import { VolumeNotFoundError, VolumePathNotFoundError, } from '../errors' +import { extractTraceId } from '../traceId' import { toUploadBody } from '../utils' import { VolumeFileType } from './types' import type { @@ -204,7 +205,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumeNotFoundError(`Volume ${volumeId} not found`) + throw new VolumeNotFoundError(`Volume ${volumeId} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -312,7 +315,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -356,7 +361,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -398,7 +405,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -473,7 +482,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -590,7 +601,9 @@ export class Volume extends ClientFactory { await res.response.body.cancel().catch(() => {}) } cleanup() - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -631,7 +644,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -713,7 +728,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) @@ -753,7 +770,9 @@ export class Volume extends ClientFactory { }) if (res.response.status === 404) { - throw new VolumePathNotFoundError(`Path ${path} not found`) + throw new VolumePathNotFoundError(`Path ${path} not found`, { + traceId: extractTraceId(res.response.headers), + }) } const err = handleApiError(res, VolumeError) diff --git a/packages/js-sdk/tests/api/handleApiError.test.ts b/packages/js-sdk/tests/api/handleApiError.test.ts index 6d00483b01..2675d12454 100644 --- a/packages/js-sdk/tests/api/handleApiError.test.ts +++ b/packages/js-sdk/tests/api/handleApiError.test.ts @@ -2,6 +2,8 @@ import { assert, test, describe } from 'vitest' import { handleApiError } from '../../src/api' import { AuthenticationError, + BuildError, + FileUploadError, RateLimitError, SandboxError, } from '../../src/errors' @@ -9,14 +11,19 @@ import { function createMockResponse( status: number, error: unknown, - data?: unknown + data?: unknown, + headers?: Record ): { - response: { status: number; ok: boolean } + response: { status: number; ok: boolean; headers?: Headers } error: unknown data: unknown } { return { - response: { status, ok: status >= 200 && status < 300 }, + response: { + status, + ok: status >= 200 && status < 300, + ...(headers && { headers: new Headers(headers) }), + }, error, data, } @@ -117,6 +124,111 @@ describe('handleApiError', () => { }) }) + // `getFileUploadLink` is the one caller that supplies a stack trace, so the + // classes a template file upload can produce have to apply it + describe('stack trace', () => { + const stackTrace = 'Error: boom\n at userCallSite (/app/index.ts:1:1)' + + test('applies the caller stack trace to the error class', () => { + const res = createMockResponse(500, { message: 'Internal error' }) + const err = handleApiError(res as any, FileUploadError, { stackTrace }) + assert.instanceOf(err, FileUploadError) + assert.equal(err?.stack, stackTrace) + }) + + // A bad key or a rate limit belongs to the request, not to the builder step + // that made it, so these two keep the frame where they were constructed + test('leaves the caller stack trace off 401 errors', () => { + const res = createMockResponse(401, { message: 'Invalid token' }) + const err = handleApiError(res as any, FileUploadError, { stackTrace }) + assert.instanceOf(err, AuthenticationError) + assert.notEqual(err?.stack, stackTrace) + }) + + test('leaves the caller stack trace off 429 errors', () => { + const res = createMockResponse(429, { message: 'Too many requests' }) + const err = handleApiError(res as any, FileUploadError, { stackTrace }) + assert.instanceOf(err, RateLimitError) + assert.notEqual(err?.stack, stackTrace) + }) + }) + + describe('trace ID header', () => { + test('appends the trace ID from X-Trace-ID to the message', () => { + const res = createMockResponse( + 500, + { message: 'Internal error' }, + undefined, + { 'X-Trace-ID': 'abc123' } + ) + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '(trace ID: abc123)') + }) + + test('appends the trace ID from the GCP edge header', () => { + const res = createMockResponse( + 429, + { message: 'Too many requests' }, + undefined, + { 'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1' } + ) + const err = handleApiError(res as any) + assert.instanceOf(err, RateLimitError) + assert.include( + err?.message, + '(trace ID: 105445aa7843bc8bf206b12000100000)' + ) + }) + + test('appends the trace ID for 401 errors', () => { + const res = createMockResponse( + 401, + { message: 'Invalid token' }, + undefined, + { 'X-Trace-ID': 'abc123' } + ) + const err = handleApiError(res as any) + assert.instanceOf(err, AuthenticationError) + assert.include(err?.message, '(trace ID: abc123)') + }) + + test('leaves the message unchanged without trace headers', () => { + const res = createMockResponse(500, { message: 'Internal error' }) + const err = handleApiError(res as any) + assert.notInclude(err?.message, 'trace ID') + }) + + test('passes the trace ID through a custom error class', () => { + const res = createMockResponse( + 500, + { message: 'Build failed' }, + undefined, + { 'X-Trace-ID': 'abc123' } + ) + const err = handleApiError(res as any, BuildError) + assert.instanceOf(err, BuildError) + assert.include(err?.message, '(trace ID: abc123)') + }) + + test('exposes the trace ID on the error', () => { + const res = createMockResponse( + 500, + { message: 'Internal error' }, + undefined, + { 'X-Trace-ID': 'abc123' } + ) + const err = handleApiError(res as any) + assert.equal((err as SandboxError).traceId, 'abc123') + }) + + test('leaves the trace ID undefined without trace headers', () => { + const res = createMockResponse(500, { message: 'Internal error' }) + const err = handleApiError(res as any) + assert.isUndefined((err as SandboxError).traceId) + }) + }) + describe('success responses', () => { test('returns undefined for 200 success', () => { const res = createMockResponse(200, undefined, { id: '123' }) diff --git a/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts b/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts index b886f3fe51..d2e9d22d1c 100644 --- a/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts +++ b/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts @@ -12,7 +12,8 @@ import { function createMockResponse( status: number, - error?: { message?: string } | string + error?: { message?: string } | string, + headers?: Record ): { error?: { message?: string } | string response: Response @@ -26,6 +27,7 @@ function createMockResponse( // openapi-fetch consumes the body whenever it produces an error value bodyUsed: error !== undefined, text: async () => (typeof error === 'string' ? error : ''), + ...(headers && { headers: new Headers(headers) }), } as unknown as Response, } } @@ -101,6 +103,45 @@ describe('handleEnvdApiError', () => { assert.instanceOf(err, SandboxError) assert.include(err?.message, '500') }) + + test('appends the trace ID from X-Trace-ID to mapped errors', async () => { + const res = createMockResponse( + 404, + { message: 'Not found' }, + { 'X-Trace-ID': 'abc123' } + ) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, NotFoundError) + assert.include(err?.message, '(trace ID: abc123)') + }) + + test('appends the trace ID at the end of explanatory messages', async () => { + const res = createMockResponse( + 502, + { message: 'Bad gateway' }, + { 'X-Trace-ID': 'abc123' } + ) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, TimeoutError) + assert.isTrue(err?.message.endsWith('(trace ID: abc123)')) + }) + + test('appends the trace ID to unmapped errors', async () => { + const res = createMockResponse( + 500, + { message: 'Internal error' }, + { 'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1' } + ) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '(trace ID: 105445aa7843bc8bf206b12000100000)') + }) + + test('leaves the message unchanged without trace headers', async () => { + const res = createMockResponse(500, { message: 'Internal error' }) + const err = await handleEnvdApiError(res) + assert.notInclude(err?.message, 'trace ID') + }) }) describe('handleEnvdApiFetchError', () => { diff --git a/packages/js-sdk/tests/secret/secret.test.ts b/packages/js-sdk/tests/secret/secret.test.ts index 3714ad7743..d2302334e4 100644 --- a/packages/js-sdk/tests/secret/secret.test.ts +++ b/packages/js-sdk/tests/secret/secret.test.ts @@ -162,6 +162,24 @@ describe('Secret CRUD', () => { await expect(Secret.getInfo('missing')).rejects.toThrow(SecretNotFoundError) }) + // The 404 is thrown before handleApiError sees the response, so the trace ID + // has to be read at the throw site + it('should carry the trace ID on SecretNotFoundError', async () => { + server.use( + http.get(apiUrl('/secrets/:secretID'), () => + HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404, headers: { 'X-Trace-ID': 'abc123' } } + ) + ) + ) + + const err = await Secret.getInfo('missing').catch((err) => err) + expect(err).toBeInstanceOf(SecretNotFoundError) + expect(err.traceId).toBe('abc123') + expect(err.message).toContain('(trace ID: abc123)') + }) + it('should list secrets with pagination', async () => { await Secret.create('key-a', 'a') await Secret.create('key-b', 'b') diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index 08cf0d193d..ab1d0d5cea 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -4,7 +4,12 @@ import { assert, afterAll, afterEach, beforeAll } from 'vitest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { Template, waitForTimeout } from '../../src' +import { + AuthenticationError, + RateLimitError, + Template, + waitForTimeout, +} from '../../src' import { apiUrl, buildTemplateTest } from '../setup' import { randomUUID } from 'node:crypto' @@ -140,6 +145,60 @@ async function expectToThrowAndCheckTrace( } } +// `getFileUploadLink` is the one caller that hands `handleApiError` a stack +// trace, and 401/429 are the two statuses it can hit that swap in a different +// error class. A bad key or a rate limit belongs to the request rather than to +// the builder step that made it, so those two do not take the builder's frame. +async function buildAndCatch(buildTemplate: any, name: string) { + try { + await buildTemplate( + Template().fromBaseImage().copy('stacktrace.test.ts', '.'), + { name } + ) + assert.fail('Expected Template.build to throw an error') + } catch (error) { + return error + } +} + +buildTemplateTest( + 'does not trace a 401 from the file upload link to the builder call', + async ({ buildTemplate }) => { + server.use( + http.get(apiUrl('/templates/:templateID/files/:hash'), () => + HttpResponse.json({ message: 'Expired key' }, { status: 401 }) + ) + ) + + const error = await buildAndCatch(buildTemplate, 'fileUploadLink401') + + assert.instanceOf(error, AuthenticationError) + assert.notEqual( + getStackTraceCallerMethod(__fileContent, (error as Error).stack), + 'copy' + ) + } +) + +buildTemplateTest( + 'does not trace a 429 from the file upload link to the builder call', + async ({ buildTemplate }) => { + server.use( + http.get(apiUrl('/templates/:templateID/files/:hash'), () => + HttpResponse.json({ message: 'Slow down' }, { status: 429 }) + ) + ) + + const error = await buildAndCatch(buildTemplate, 'fileUploadLink429') + + assert.instanceOf(error, RateLimitError) + assert.notEqual( + getStackTraceCallerMethod(__fileContent, (error as Error).stack), + 'copy' + ) + } +) + buildTemplateTest('traces on fromImage', async ({ buildTemplate }) => { const template = Template().fromImage('e2b.dev/this-image-does-not-exist') await expectToThrowAndCheckTrace(async () => { diff --git a/packages/js-sdk/tests/traceId.test.ts b/packages/js-sdk/tests/traceId.test.ts new file mode 100644 index 0000000000..45c125af27 --- /dev/null +++ b/packages/js-sdk/tests/traceId.test.ts @@ -0,0 +1,107 @@ +import { assert, describe, test } from 'vitest' +import { extractTraceId } from '../src/traceId' +import { + AuthenticationError, + BuildError, + SandboxError, + TimeoutError, + VolumeError, +} from '../src/errors' + +describe('extractTraceId', () => { + test('returns undefined without headers', () => { + assert.isUndefined(extractTraceId()) + assert.isUndefined(extractTraceId(undefined)) + }) + + test('returns undefined when no trace header is present', () => { + assert.isUndefined(extractTraceId(new Headers({ 'content-type': 'text' }))) + }) + + test('reads X-Trace-ID verbatim', () => { + const headers = new Headers({ 'X-Trace-ID': 'abc123' }) + assert.equal(extractTraceId(headers), 'abc123') + }) + + test('ignores an empty X-Trace-ID', () => { + const headers = new Headers({ 'X-Trace-ID': ' ' }) + assert.isUndefined(extractTraceId(headers)) + }) + + test('reads the trace ID part of X-Cloud-Trace-Context', () => { + const headers = new Headers({ + 'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1', + }) + assert.equal(extractTraceId(headers), '105445aa7843bc8bf206b12000100000') + }) + + test('normalizes X-Amzn-Trace-Id to the 32-hex trace ID', () => { + const headers = new Headers({ + 'X-Amzn-Trace-Id': 'Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1', + }) + assert.equal(extractTraceId(headers), '5759e988bd862e3fe1be46a994272793') + }) + + test('falls back to the raw Root value for an unexpected AWS format', () => { + const headers = new Headers({ 'X-Amzn-Trace-Id': 'Root=custom-value' }) + assert.equal(extractTraceId(headers), 'custom-value') + }) + + test('prefers X-Trace-ID over the cloud edge headers', () => { + const headers = new Headers({ + 'X-Trace-ID': 'explicit', + 'X-Cloud-Trace-Context': '105445aa7843bc8bf206b12000100000/1;o=1', + 'X-Amzn-Trace-Id': 'Root=1-5759e988-bd862e3fe1be46a994272793', + }) + assert.equal(extractTraceId(headers), 'explicit') + }) +}) + +describe('error classes with a trace ID', () => { + test('SandboxError appends the trace ID to the message', () => { + const err = new SandboxError('500: failure', { traceId: 'abc123' }) + assert.equal(err.message, '500: failure (trace ID: abc123)') + }) + + test('SandboxError leaves the message unchanged without a trace ID', () => { + const err = new SandboxError('500: failure') + assert.equal(err.message, '500: failure') + }) + + test('AuthenticationError appends the trace ID to the message', () => { + const err = new AuthenticationError('unauthorized', { traceId: 'abc123' }) + assert.equal(err.message, 'unauthorized (trace ID: abc123)') + }) + + test('subclasses append the trace ID to the message', () => { + const err = new TimeoutError('timed out', { traceId: 'abc123' }) + assert.equal(err.message, 'timed out (trace ID: abc123)') + }) + + test('the trace ID is readable as a property', () => { + assert.equal( + new SandboxError('500: failure', { traceId: 'abc123' }).traceId, + 'abc123' + ) + assert.equal( + new TimeoutError('timed out', { traceId: 'abc123' }).traceId, + 'abc123' + ) + assert.equal( + new AuthenticationError('unauthorized', { traceId: 'abc123' }).traceId, + 'abc123' + ) + assert.equal( + new BuildError('build failed', { traceId: 'abc123' }).traceId, + 'abc123' + ) + assert.equal( + new VolumeError('volume failed', { traceId: 'abc123' }).traceId, + 'abc123' + ) + }) + + test('the trace ID property is undefined when there is none', () => { + assert.isUndefined(new SandboxError('500: failure').traceId) + }) +}) diff --git a/packages/js-sdk/tests/volume/volume.test.ts b/packages/js-sdk/tests/volume/volume.test.ts index b0f5bed88e..dee8ab0845 100644 --- a/packages/js-sdk/tests/volume/volume.test.ts +++ b/packages/js-sdk/tests/volume/volume.test.ts @@ -151,6 +151,24 @@ describe('Volume CRUD', () => { expect(err).toBeInstanceOf(VolumeError) }) + // The 404 is thrown before handleApiError sees the response, so the trace ID + // has to be read at the throw site + it('should carry the trace ID on VolumeNotFoundError', async () => { + server.use( + http.get(apiUrl('/volumes/:volumeID'), () => + HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404, headers: { 'X-Trace-ID': 'abc123' } } + ) + ) + ) + + const err = await Volume.getInfo('non-existent-id').catch((err) => err) + expect(err).toBeInstanceOf(VolumeNotFoundError) + expect(err.traceId).toBe('abc123') + expect(err.message).toContain('(trace ID: abc123)') + }) + it('should throw VolumeError for a non-2xx response without content', async () => { server.use( http.post( @@ -337,6 +355,23 @@ describe('Volume content readFile', () => { expect(err).toBeInstanceOf(VolumeError) }) + it('should carry the trace ID on VolumePathNotFoundError', async () => { + const vol = await Volume.create('content-volume') + server.use( + http.get(apiUrl('/volumecontent/:volumeID/file'), () => + HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404, headers: { 'X-Trace-ID': 'abc123' } } + ) + ) + ) + + const err = await vol.readFile('missing.txt').catch((err) => err) + expect(err).toBeInstanceOf(VolumePathNotFoundError) + expect(err.traceId).toBe('abc123') + expect(err.message).toContain('(trace ID: abc123)') + }) + it('should reject at call time for a missing file with stream format', async () => { const vol = await Volume.create('content-volume') diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 1d3cac9f4d..cfd9472171 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -57,6 +57,7 @@ SecretException, SecretNotFoundException, ) +from .trace_id import extract_trace_id from .sandbox.commands.command_handle import ( CommandExitException, CommandResult, @@ -191,6 +192,7 @@ "VolumeException", "VolumeNotFoundException", "VolumePathNotFoundException", + "extract_trace_id", # Sandbox API "SandboxInfo", "SandboxInfoLifecycle", diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 2db0fa80c6..f0b91f9150 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -4,7 +4,7 @@ import re from dataclasses import dataclass from types import TracebackType -from typing import NamedTuple, Optional, Protocol, Tuple, Union +from typing import Mapping, NamedTuple, Optional, Protocol, Tuple, Union from urllib.parse import quote import httpx @@ -17,10 +17,12 @@ from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.exceptions import ( AuthenticationException, + ExceptionFactory, InvalidArgumentException, RateLimitException, SandboxException, ) +from e2b.trace_id import extract_trace_id def encode_path_param(value: str) -> str: @@ -143,32 +145,38 @@ class SandboxCreateResponse: def api_exception_from_code( status_code: int, message: Optional[str] = None, - default_exception_class: type[Exception] = SandboxException, + default_exception_class: ExceptionFactory = SandboxException, stack_trace: Optional[TracebackType] = None, + *, + trace_id: Optional[str] = None, ) -> Exception: """Map an API error code and message to the matching exception class — the same mapping :func:`handle_api_exception` applies to HTTP responses, usable - for error objects embedded in response bodies (e.g. per-fork results).""" + for error objects embedded in response bodies (e.g. per-fork results). + + An expired key or a rate limit is a property of the request, not of the + builder step that happened to make it, so 401 and 429 keep their own + traceback even when ``stack_trace`` is supplied.""" if status_code == 401: text = f"{status_code}: Unauthorized, please check your credentials." if message: text += f" - {message}" - return AuthenticationException(text) + return AuthenticationException(text, trace_id=trace_id) if status_code == 429: text = f"{status_code}: Rate limit exceeded, please try again later." if message: text += f" - {message}" - return RateLimitException(text) + return RateLimitException(text, trace_id=trace_id) - return default_exception_class(f"{status_code}: {message}").with_traceback( - stack_trace - ) + return default_exception_class( + f"{status_code}: {message}", trace_id=trace_id + ).with_traceback(stack_trace) def handle_api_exception( e: "SupportsApiErrorResponse", - default_exception_class: type[Exception] = SandboxException, + default_exception_class: ExceptionFactory = SandboxException, stack_trace: Optional[TracebackType] = None, ): try: @@ -176,14 +184,20 @@ def handle_api_exception( except json.JSONDecodeError: body = {} + trace_id = extract_trace_id(e.headers) + message = body["message"] if "message" in body else None if message is None and e.status_code not in (401, 429): - return default_exception_class(f"{e.status_code}: {e.content}").with_traceback( - stack_trace - ) + return default_exception_class( + f"{e.status_code}: {e.content}", trace_id=trace_id + ).with_traceback(stack_trace) return api_exception_from_code( - e.status_code, message, default_exception_class, stack_trace + e.status_code, + message, + default_exception_class, + stack_trace, + trace_id=trace_id, ) @@ -194,6 +208,9 @@ def status_code(self) -> int: ... @property def content(self) -> Union[str, bytes]: ... + @property + def headers(self) -> Optional[Mapping[str, str]]: ... + _API_KEY_PATTERN = re.compile(r"\Ae2b_[0-9a-f]+\Z") _API_KEY_EXAMPLE = "e2b_" + "0" * 40 diff --git a/packages/python-sdk/e2b/envd/api.py b/packages/python-sdk/e2b/envd/api.py index 811d58d592..91078ad909 100644 --- a/packages/python-sdk/e2b/envd/api.py +++ b/packages/python-sdk/e2b/envd/api.py @@ -1,10 +1,11 @@ import httpx import json -from typing import Callable, Optional +from typing import Mapping, Optional from e2b.envd.rpc import format_terminated_exception from e2b.exceptions import ( + ExceptionFactory, SandboxException, NotFoundException, AuthenticationException, @@ -13,17 +14,19 @@ RateLimitException, format_sandbox_timeout_exception, ) +from e2b.trace_id import extract_trace_id ENVD_API_FILES_ROUTE = "/files" ENVD_API_HEALTH_ROUTE = "/health" -_DEFAULT_API_ERROR_MAP: dict[int, Callable[[str], Exception]] = { + +_DEFAULT_API_ERROR_MAP: dict[int, ExceptionFactory] = { 400: InvalidArgumentException, 401: AuthenticationException, 404: NotFoundException, - 429: lambda message: RateLimitException( - f"{message}: The requests are being rate limited." + 429: lambda message, *, trace_id=None: RateLimitException( + f"{message}: The requests are being rate limited.", trace_id=trace_id ), 502: format_sandbox_timeout_exception, 507: NotEnoughSpaceException, @@ -120,7 +123,7 @@ def get_message(e: httpx.Response) -> str: def handle_envd_api_exception( res: httpx.Response, - error_map: Optional[dict[int, Callable[[str], Exception]]] = None, + error_map: Optional[Mapping[int, ExceptionFactory]] = None, ): """Handle errors from envd API responses by mapping HTTP status codes to specific exception types. @@ -133,12 +136,17 @@ def handle_envd_api_exception( res.read() - return format_envd_api_exception(res.status_code, get_message(res), error_map) + return format_envd_api_exception( + res.status_code, + get_message(res), + error_map, + trace_id=extract_trace_id(res.headers), + ) async def ahandle_envd_api_exception( res: httpx.Response, - error_map: Optional[dict[int, Callable[[str], Exception]]] = None, + error_map: Optional[Mapping[int, ExceptionFactory]] = None, ): """Async version of :func:`handle_envd_api_exception`.""" if res.is_success: @@ -146,25 +154,33 @@ async def ahandle_envd_api_exception( await res.aread() - return format_envd_api_exception(res.status_code, get_message(res), error_map) + return format_envd_api_exception( + res.status_code, + get_message(res), + error_map, + trace_id=extract_trace_id(res.headers), + ) def format_envd_api_exception( status_code: int, message: str, - error_map: Optional[dict[int, Callable[[str], Exception]]] = None, + error_map: Optional[Mapping[int, ExceptionFactory]] = None, + *, + trace_id: Optional[str] = None, ): """Map an HTTP status code and message to the appropriate exception. :param status_code: The HTTP status code. :param message: The error message from the response body. :param error_map: Optional map of HTTP status codes to exception factories that override the defaults. + :param trace_id: Optional trace ID of the failed request. :return: The corresponding exception. """ if error_map and status_code in error_map: - return error_map[status_code](message) + return error_map[status_code](message, trace_id=trace_id) if status_code in _DEFAULT_API_ERROR_MAP: - return _DEFAULT_API_ERROR_MAP[status_code](message) + return _DEFAULT_API_ERROR_MAP[status_code](message, trace_id=trace_id) - return SandboxException(f"{status_code}: {message}") + return SandboxException(f"{status_code}: {message}", trace_id=trace_id) diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py index 7846654da7..839b29d07e 100644 --- a/packages/python-sdk/e2b/exceptions.py +++ b/packages/python-sdk/e2b/exceptions.py @@ -1,6 +1,28 @@ -def format_sandbox_timeout_exception(message: str): +from typing import Optional, Protocol + + +class ExceptionFactory(Protocol): + """Builds an exception from an error message and an optional trace ID. + + Exception classes satisfy this, so they can be used directly as factories + in the HTTP status code maps. + """ + + def __call__( + self, message: str, *, trace_id: Optional[str] = None + ) -> Exception: ... + + +def _format_message_with_trace_id(message: str, trace_id: Optional[str] = None) -> str: + if trace_id and message: + return f"{message} (trace ID: {trace_id})" + return message + + +def format_sandbox_timeout_exception(message: str, *, trace_id: Optional[str] = None): return TimeoutException( - f"{message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout." + f"{message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout.", + trace_id=trace_id, ) @@ -15,9 +37,18 @@ class SandboxException(Exception): Base class for all sandbox errors. Raised when a general sandbox exception occurs. + + :ivar trace_id: Trace ID of the failed request, when the response carried + one. """ - pass + # Class-level default so subclasses that bypass this ``__init__`` — the + # ``@dataclass`` CommandExitException generates its own — still expose it + trace_id: Optional[str] = None + + def __init__(self, message: str = "", *, trace_id: Optional[str] = None): + super().__init__(_format_message_with_trace_id(message, trace_id)) + self.trace_id = trace_id class TimeoutException(SandboxException): @@ -80,9 +111,16 @@ class SandboxNotFoundException(NotFoundException): class AuthenticationException(Exception): """ Raised when authentication fails. + + :ivar trace_id: Trace ID of the failed request, when the response carried + one. """ - pass + trace_id: Optional[str] = None + + def __init__(self, message: str = "", *, trace_id: Optional[str] = None): + super().__init__(_format_message_with_trace_id(message, trace_id)) + self.trace_id = trace_id class GitAuthException(AuthenticationException): @@ -116,8 +154,17 @@ class RateLimitException(SandboxException): class BuildException(Exception): """ Raised when the build fails. + + :ivar trace_id: Trace ID of the failed request, when the response carried + one. """ + trace_id: Optional[str] = None + + def __init__(self, message: str = "", *, trace_id: Optional[str] = None): + super().__init__(_format_message_with_trace_id(message, trace_id)) + self.trace_id = trace_id + class FileUploadException(BuildException): """ @@ -130,8 +177,17 @@ class VolumeException(Exception): Base class for all volume errors. Raised when general volume errors occur. + + :ivar trace_id: Trace ID of the failed request, when the response carried + one. """ + trace_id: Optional[str] = None + + def __init__(self, message: str = "", *, trace_id: Optional[str] = None): + super().__init__(_format_message_with_trace_id(message, trace_id)) + self.trace_id = trace_id + class VolumeNotFoundException(NotFoundException): """ @@ -150,8 +206,17 @@ class SecretException(Exception): Base class for all secret errors. Raised when general secret errors occur. + + :ivar trace_id: Trace ID of the failed request, when the response carried + one. """ + trace_id: Optional[str] = None + + def __init__(self, message: str = "", *, trace_id: Optional[str] = None): + super().__init__(_format_message_with_trace_id(message, trace_id)) + self.trace_id = trace_id + class SecretNotFoundException(SecretException): """ diff --git a/packages/python-sdk/e2b/secret/secret_async.py b/packages/python-sdk/e2b/secret/secret_async.py index f0e3cdd323..390e077378 100644 --- a/packages/python-sdk/e2b/secret/secret_async.py +++ b/packages/python-sdk/e2b/secret/secret_async.py @@ -20,6 +20,7 @@ from e2b.api.client_async import get_api_client from e2b.connection_config import ApiParams, ConnectionConfig, merge_api_params from e2b.exceptions import SecretException, SecretNotFoundException +from e2b.trace_id import extract_trace_id from e2b.secret.base import SecretBase, SecretPaginatorBase from e2b.secret.types import SecretInfo @@ -161,7 +162,10 @@ async def update( ) if res.status_code == 404: - raise SecretNotFoundException(f"Secret {secret} not found") + raise SecretNotFoundException( + f"Secret {secret} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, SecretException) @@ -192,7 +196,10 @@ async def get_info(cls, secret: str, **opts: Unpack[ApiParams]) -> SecretInfo: ) if res.status_code == 404: - raise SecretNotFoundException(f"Secret {secret} not found") + raise SecretNotFoundException( + f"Secret {secret} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, SecretException) diff --git a/packages/python-sdk/e2b/secret/secret_sync.py b/packages/python-sdk/e2b/secret/secret_sync.py index 908936112f..6f73930b9a 100644 --- a/packages/python-sdk/e2b/secret/secret_sync.py +++ b/packages/python-sdk/e2b/secret/secret_sync.py @@ -20,6 +20,7 @@ from e2b.api.client_sync import get_api_client from e2b.connection_config import ApiParams, ConnectionConfig, merge_api_params from e2b.exceptions import SecretException, SecretNotFoundException +from e2b.trace_id import extract_trace_id from e2b.secret.base import SecretBase, SecretPaginatorBase from e2b.secret.types import SecretInfo @@ -161,7 +162,10 @@ def update( ) if res.status_code == 404: - raise SecretNotFoundException(f"Secret {secret} not found") + raise SecretNotFoundException( + f"Secret {secret} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, SecretException) @@ -192,7 +196,10 @@ def get_info(cls, secret: str, **opts: Unpack[ApiParams]) -> SecretInfo: ) if res.status_code == 404: - raise SecretNotFoundException(f"Secret {secret} not found") + raise SecretNotFoundException( + f"Secret {secret} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, SecretException) diff --git a/packages/python-sdk/e2b/trace_id.py b/packages/python-sdk/e2b/trace_id.py new file mode 100644 index 0000000000..82eb8b9d3f --- /dev/null +++ b/packages/python-sdk/e2b/trace_id.py @@ -0,0 +1,49 @@ +import re + +from typing import Mapping, Optional + +_AWS_ROOT_PATTERN = re.compile(r"\A1-([0-9a-f]{8})-([0-9a-f]{24})\Z", re.IGNORECASE) + + +def extract_trace_id(headers: Optional[Mapping[str, str]] = None) -> Optional[str]: + """Extract a trace ID from the HTTP response headers of a failed request, + so it can be reported to E2B and correlated with server-side traces. + + The SDK does this for the exceptions it raises — reach for it when you + handle an E2B response yourself. + + Headers are checked in order: + + 1. ``X-Trace-ID`` — used verbatim when present. + 2. ``X-Cloud-Trace-Context`` (GCP edge) — ``TRACE_ID/SPAN_ID;o=OPTIONS``, + the part before ``/`` is the trace ID. + 3. ``X-Amzn-Trace-Id`` (AWS edge) — ``Root=1-<8 hex>-<24 hex>;...``, the + two hex parts joined are the 32-hex trace ID the server logs. + + :param headers: Response headers of the failed request. + :return: The trace ID, or ``None`` when no trace header is present. + """ + if headers is None: + return None + + # httpx.Headers is case-insensitive, but plain dicts are not + lowered = {key.lower(): value for key, value in headers.items()} + + direct = (lowered.get("x-trace-id") or "").strip() + if direct: + return direct + + gcp = (lowered.get("x-cloud-trace-context") or "").split("/")[0].strip() + if gcp: + return gcp + + aws = lowered.get("x-amzn-trace-id") or "" + for field in aws.split(";"): + key, _, value = field.strip().partition("=") + if key.lower() != "root" or not value: + continue + + match = _AWS_ROOT_PATTERN.match(value) + return match.group(1) + match.group(2) if match else value + + return None diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index 192234f40e..b5b0a6b03f 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -32,6 +32,7 @@ VolumeNotFoundException, VolumePathNotFoundException, ) +from e2b.trace_id import extract_trace_id from e2b.volume.client.api.volumes import ( get_volumecontent_volume_id_path as get_path, get_volumecontent_volume_id_dir as get_dir, @@ -193,7 +194,10 @@ async def _class_get_info( ) if res.status_code == 404: - raise VolumeNotFoundException(f"Volume {volume_id} not found") + raise VolumeNotFoundException( + f"Volume {volume_id} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -287,7 +291,10 @@ async def _instance_list( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -339,7 +346,10 @@ async def make_dir( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -391,7 +401,10 @@ async def _instance_get_info( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -443,7 +456,10 @@ async def update_metadata( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -552,7 +568,10 @@ async def stream_file() -> AsyncIterator[bytes]: response = await read_bounded(stream_cm.__aenter__()) try: if response.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(response.headers), + ) if response.status_code >= 300: api_response = Response( @@ -585,7 +604,10 @@ async def stream_file() -> AsyncIterator[bytes]: ) if response.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(response.headers), + ) if response.status_code >= 300: api_response = Response( @@ -659,7 +681,10 @@ async def write_file( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -693,7 +718,10 @@ async def remove( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index eee5d1b1db..53401ca81a 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -31,6 +31,7 @@ VolumeNotFoundException, VolumePathNotFoundException, ) +from e2b.trace_id import extract_trace_id from e2b.volume.client.api.volumes import ( get_volumecontent_volume_id_path as get_path, get_volumecontent_volume_id_dir as get_dir, @@ -192,7 +193,10 @@ def _class_get_info( ) if res.status_code == 404: - raise VolumeNotFoundException(f"Volume {volume_id} not found") + raise VolumeNotFoundException( + f"Volume {volume_id} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -286,7 +290,10 @@ def _instance_list( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -338,7 +345,10 @@ def make_dir( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -390,7 +400,10 @@ def _instance_get_info( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -442,7 +455,10 @@ def update_metadata( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -532,7 +548,10 @@ def stream_file() -> Iterator[bytes]: timeout=stream_timeout, ) as response: if response.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(response.headers), + ) if response.status_code >= 300: api_response = Response( @@ -555,7 +574,10 @@ def stream_file() -> Iterator[bytes]: ) if response.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(response.headers), + ) if response.status_code >= 300: api_response = Response( @@ -631,7 +653,10 @@ def write_file( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) @@ -665,7 +690,10 @@ def remove( ) if res.status_code == 404: - raise VolumePathNotFoundException(f"Path {path} not found") + raise VolumePathNotFoundException( + f"Path {path} not found", + trace_id=extract_trace_id(res.headers), + ) if res.status_code >= 300: raise handle_api_exception(res, VolumeException) diff --git a/packages/python-sdk/tests/async/secret_async/test_secret.py b/packages/python-sdk/tests/async/secret_async/test_secret.py index 90d715a1c2..c24a48c3bb 100644 --- a/packages/python-sdk/tests/async/secret_async/test_secret.py +++ b/packages/python-sdk/tests/async/secret_async/test_secret.py @@ -163,6 +163,26 @@ async def test_get_info_nonexistent_secret(): await AsyncSecret.get_info("missing") +async def test_get_info_nonexistent_secret_carries_trace_id(monkeypatch): + """The 404 is raised before handle_api_exception sees the response, so the + trace ID has to be read at the raise site.""" + + async def mock_get_secret(secret_id, *, client): + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={"X-Trace-ID": "abc123"}, + parsed=None, + ) + + monkeypatch.setattr(get_secret_mod, "asyncio_detailed", mock_get_secret) + + with pytest.raises(SecretNotFoundException) as exc_info: + await AsyncSecret.get_info("missing") + assert exc_info.value.trace_id == "abc123" + assert "(trace ID: abc123)" in str(exc_info.value) + + async def test_list_secrets_with_pagination(): await AsyncSecret.create("key-a", "a") await AsyncSecret.create("key-b", "b") diff --git a/packages/python-sdk/tests/async/volume_async/test_volume.py b/packages/python-sdk/tests/async/volume_async/test_volume.py index 317beab725..976e806063 100644 --- a/packages/python-sdk/tests/async/volume_async/test_volume.py +++ b/packages/python-sdk/tests/async/volume_async/test_volume.py @@ -135,6 +135,26 @@ async def test_get_info_nonexistent_volume(): assert isinstance(exc_info.value, NotFoundException) +async def test_get_info_nonexistent_volume_carries_trace_id(monkeypatch): + """The 404 is raised before handle_api_exception sees the response, so the + trace ID has to be read at the raise site.""" + + async def mock_get_volume(volume_id, *, client): + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={"X-Trace-ID": "abc123"}, + parsed=None, + ) + + monkeypatch.setattr(get_volume_mod, "asyncio_detailed", mock_get_volume) + + with pytest.raises(VolumeNotFoundException) as exc_info: + await AsyncVolume.get_info("non-existent-id") + assert exc_info.value.trace_id == "abc123" + assert "(trace ID: abc123)" in str(exc_info.value) + + async def test_create_volume_keeps_proxy_for_content_calls(): vol = await AsyncVolume.create( "proxy-volume", proxy="http://user:pass@127.0.0.1:8080" diff --git a/packages/python-sdk/tests/sync/secret_sync/test_secret.py b/packages/python-sdk/tests/sync/secret_sync/test_secret.py index 8b4b939985..e63384dd07 100644 --- a/packages/python-sdk/tests/sync/secret_sync/test_secret.py +++ b/packages/python-sdk/tests/sync/secret_sync/test_secret.py @@ -161,6 +161,26 @@ def test_get_info_nonexistent_secret(): Secret.get_info("missing") +def test_get_info_nonexistent_secret_carries_trace_id(monkeypatch): + """The 404 is raised before handle_api_exception sees the response, so the + trace ID has to be read at the raise site.""" + + def mock_get_secret(secret_id, *, client): + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={"X-Trace-ID": "abc123"}, + parsed=None, + ) + + monkeypatch.setattr(get_secret_mod, "sync_detailed", mock_get_secret) + + with pytest.raises(SecretNotFoundException) as exc_info: + Secret.get_info("missing") + assert exc_info.value.trace_id == "abc123" + assert "(trace ID: abc123)" in str(exc_info.value) + + def test_list_secrets_with_pagination(): Secret.create("key-a", "a") Secret.create("key-b", "b") diff --git a/packages/python-sdk/tests/sync/volume_sync/test_volume.py b/packages/python-sdk/tests/sync/volume_sync/test_volume.py index 453eb90413..35729479e1 100644 --- a/packages/python-sdk/tests/sync/volume_sync/test_volume.py +++ b/packages/python-sdk/tests/sync/volume_sync/test_volume.py @@ -135,6 +135,26 @@ def test_get_info_nonexistent_volume(): assert isinstance(exc_info.value, NotFoundException) +def test_get_info_nonexistent_volume_carries_trace_id(monkeypatch): + """The 404 is raised before handle_api_exception sees the response, so the + trace ID has to be read at the raise site.""" + + def mock_get_volume(volume_id, *, client): + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={"X-Trace-ID": "abc123"}, + parsed=None, + ) + + monkeypatch.setattr(get_volume_mod, "sync_detailed", mock_get_volume) + + with pytest.raises(VolumeNotFoundException) as exc_info: + Volume.get_info("non-existent-id") + assert exc_info.value.trace_id == "abc123" + assert "(trace ID: abc123)" in str(exc_info.value) + + def test_create_volume_keeps_proxy_for_content_calls(): vol = Volume.create("proxy-volume", proxy="http://user:pass@127.0.0.1:8080") diff --git a/packages/python-sdk/tests/test_trace_id.py b/packages/python-sdk/tests/test_trace_id.py new file mode 100644 index 0000000000..199a83ed5c --- /dev/null +++ b/packages/python-sdk/tests/test_trace_id.py @@ -0,0 +1,209 @@ +import httpx + +from e2b.api import handle_api_exception +from e2b.envd.api import ahandle_envd_api_exception, handle_envd_api_exception +from e2b.exceptions import ( + AuthenticationException, + BuildException, + NotFoundException, + RateLimitException, + SandboxException, + TimeoutException, + VolumeException, +) +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.trace_id import extract_trace_id + + +class FakeApiResponse: + def __init__(self, status_code, content, headers=None): + self.status_code = status_code + self.content = content + self.headers = headers + + +def _caller_traceback(): + try: + raise ValueError("boom") + except ValueError as e: + return e.__traceback__ + + +def test_extract_returns_none_without_headers(): + assert extract_trace_id() is None + assert extract_trace_id(None) is None + assert extract_trace_id({}) is None + + +def test_extract_returns_none_when_no_trace_header_present(): + assert extract_trace_id({"content-type": "text"}) is None + + +def test_extract_reads_x_trace_id_verbatim(): + assert extract_trace_id({"X-Trace-ID": "abc123"}) == "abc123" + + +def test_extract_is_case_insensitive(): + assert extract_trace_id({"x-trace-id": "abc123"}) == "abc123" + + +def test_extract_ignores_empty_x_trace_id(): + assert extract_trace_id({"X-Trace-ID": " "}) is None + + +def test_extract_reads_gcp_trace_context(): + headers = {"X-Cloud-Trace-Context": "105445aa7843bc8bf206b12000100000/1;o=1"} + assert extract_trace_id(headers) == "105445aa7843bc8bf206b12000100000" + + +def test_extract_normalizes_aws_trace_id(): + headers = {"X-Amzn-Trace-Id": "Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1"} + assert extract_trace_id(headers) == "5759e988bd862e3fe1be46a994272793" + + +def test_extract_falls_back_to_raw_aws_root_value(): + assert extract_trace_id({"X-Amzn-Trace-Id": "Root=custom-value"}) == "custom-value" + + +def test_extract_prefers_x_trace_id_over_edge_headers(): + headers = { + "X-Trace-ID": "explicit", + "X-Cloud-Trace-Context": "105445aa7843bc8bf206b12000100000/1;o=1", + "X-Amzn-Trace-Id": "Root=1-5759e988-bd862e3fe1be46a994272793", + } + assert extract_trace_id(headers) == "explicit" + + +def test_exception_appends_trace_id_to_message(): + err = SandboxException("500: failure", trace_id="abc123") + assert str(err) == "500: failure (trace ID: abc123)" + + +def test_exception_leaves_message_unchanged_without_trace_id(): + err = SandboxException("500: failure") + assert str(err) == "500: failure" + + +def test_authentication_exception_appends_trace_id(): + err = AuthenticationException("unauthorized", trace_id="abc123") + assert str(err) == "unauthorized (trace ID: abc123)" + + +def test_subclasses_append_trace_id(): + err = TimeoutException("timed out", trace_id="abc123") + assert str(err) == "timed out (trace ID: abc123)" + + +def test_trace_id_is_readable_as_an_attribute(): + assert SandboxException("500: failure", trace_id="abc123").trace_id == "abc123" + assert TimeoutException("timed out", trace_id="abc123").trace_id == "abc123" + assert AuthenticationException("unauthorized", trace_id="abc123").trace_id == ( + "abc123" + ) + assert BuildException("build failed", trace_id="abc123").trace_id == "abc123" + assert VolumeException("volume failed", trace_id="abc123").trace_id == "abc123" + + +def test_trace_id_attribute_is_none_when_there_is_none(): + assert SandboxException("500: failure").trace_id is None + + +def test_command_exit_exception_exposes_the_attribute(): + # The dataclass subclass generates its own __init__, so the attribute comes + # from the class-level default + err = CommandExitException(stderr="err", stdout="out", exit_code=1, error=None) + assert err.trace_id is None + + +def test_api_exception_includes_trace_id(): + res = FakeApiResponse( + 500, b'{"message": "Internal error"}', {"X-Trace-ID": "abc123"} + ) + err = handle_api_exception(res) + assert isinstance(err, SandboxException) + assert "(trace ID: abc123)" in str(err) + assert err.trace_id == "abc123" + + +def test_api_exception_includes_trace_id_for_rate_limit(): + res = FakeApiResponse(429, b"", {"X-Trace-ID": "abc123"}) + err = handle_api_exception(res) + assert isinstance(err, RateLimitException) + assert "(trace ID: abc123)" in str(err) + + +def test_api_exception_without_headers(): + res = FakeApiResponse(500, b'{"message": "Internal error"}') + err = handle_api_exception(res) + assert isinstance(err, SandboxException) + assert "trace ID" not in str(err) + assert err.trace_id is None + + +def test_api_exception_applies_the_stack_trace(): + stack_trace = _caller_traceback() + res = FakeApiResponse(500, b'{"message": "Internal error"}') + err = handle_api_exception(res, stack_trace=stack_trace) + assert err.__traceback__ is stack_trace + + +# A bad key or a rate limit belongs to the request rather than to the builder +# step that made it, so these two keep their own traceback — mirroring the JS +# side, where AuthenticationError / RateLimitError take no `stackTrace` +def test_authentication_exception_keeps_its_own_traceback(): + res = FakeApiResponse(401, b'{"message": "Invalid token"}') + err = handle_api_exception(res, stack_trace=_caller_traceback()) + assert isinstance(err, AuthenticationException) + assert err.__traceback__ is None + + +def test_rate_limit_exception_keeps_its_own_traceback(): + res = FakeApiResponse(429, b'{"message": "Too many requests"}') + err = handle_api_exception(res, stack_trace=_caller_traceback()) + assert isinstance(err, RateLimitException) + assert err.__traceback__ is None + + +def test_envd_api_exception_includes_trace_id(): + res = httpx.Response( + 404, + text="Not found", + headers={"X-Trace-ID": "abc123"}, + request=httpx.Request("GET", "http://sandbox/files"), + ) + err = handle_envd_api_exception(res) + assert isinstance(err, NotFoundException) + assert "(trace ID: abc123)" in str(err) + + +def test_envd_api_exception_appends_trace_id_at_the_end(): + res = httpx.Response( + 502, + text="Bad gateway", + headers={"X-Trace-ID": "abc123"}, + request=httpx.Request("GET", "http://sandbox/files"), + ) + err = handle_envd_api_exception(res) + assert str(err).endswith("(trace ID: abc123)") + + +def test_envd_api_exception_without_trace_headers(): + res = httpx.Response( + 500, + text="Internal error", + request=httpx.Request("GET", "http://sandbox/files"), + ) + err = handle_envd_api_exception(res) + assert "trace ID" not in str(err) + + +async def test_async_envd_api_exception_includes_trace_id(): + res = httpx.Response( + 404, + text="Not found", + headers={"X-Trace-ID": "abc123"}, + request=httpx.Request("GET", "http://sandbox/files"), + ) + err = await ahandle_envd_api_exception(res) + assert isinstance(err, NotFoundException) + assert "(trace ID: abc123)" in str(err)