diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt index 177c8ff61..02f58e5b1 100644 --- a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt +++ b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt @@ -126,6 +126,7 @@ class CodesAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt index 1dce45999..f6fc793f5 100644 --- a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt +++ b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt @@ -19,12 +19,14 @@ package com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.service import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.config.ConnectionConfig +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -133,4 +135,20 @@ class CodesAdapterTest { assertEquals("/code", request.requestUrl?.encodedPath) assertEquals("exec-123", request.requestUrl?.queryParameter("id")) } + + @Test + fun `run should expose request id on api exception`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .addHeader("X-Request-ID", "req-kotlin-code-123") + .setBody("""{"code":"INTERNAL_ERROR","message":"boom"}"""), + ) + + val request = RunCodeRequest.builder().code("print('boom')").build() + val ex = assertThrows(SandboxApiException::class.java) { codesAdapter.run(request) } + + assertEquals(500, ex.statusCode) + assertEquals("req-kotlin-code-123", ex.requestId) + } } diff --git a/sdks/code-interpreter/kotlin/gradle.properties b/sdks/code-interpreter/kotlin/gradle.properties index 2188f7007..d96bbaf80 100644 --- a/sdks/code-interpreter/kotlin/gradle.properties +++ b/sdks/code-interpreter/kotlin/gradle.properties @@ -5,5 +5,5 @@ org.gradle.parallel=true # Project metadata project.group=com.alibaba.opensandbox -project.version=1.0.4 +project.version=1.0.5 project.description=A Kotlin SDK for Code Interpreter diff --git a/sdks/code-interpreter/kotlin/gradle/libs.versions.toml b/sdks/code-interpreter/kotlin/gradle/libs.versions.toml index 1a2b026cc..2de35102c 100644 --- a/sdks/code-interpreter/kotlin/gradle/libs.versions.toml +++ b/sdks/code-interpreter/kotlin/gradle/libs.versions.toml @@ -23,7 +23,7 @@ spotless = "6.23.3" maven-publish = "0.35.0" dokka = "1.9.10" jackson = "2.18.2" -sandbox = "1.0.4" +sandbox = "1.0.5" junit-platform = "1.13.4" [libraries] diff --git a/sdks/code-interpreter/python/pyproject.toml b/sdks/code-interpreter/python/pyproject.toml index 4932a0df9..f76ea965e 100644 --- a/sdks/code-interpreter/python/pyproject.toml +++ b/sdks/code-interpreter/python/pyproject.toml @@ -43,7 +43,7 @@ classifiers = [ ] dependencies = [ "pydantic>=2.4.2,<3.0", - "opensandbox>=0.1.1,<0.2.0", + "opensandbox>=0.1.5,<0.2.0", ] [project.urls] diff --git a/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py index 9cf2c81c9..b16230814 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py +++ b/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py @@ -32,6 +32,7 @@ ExecutionEventDispatcher, ) from opensandbox.adapters.converter.response_handler import ( + extract_request_id, handle_api_error, require_parsed, ) @@ -287,6 +288,7 @@ async def run( raise SandboxApiException( message=f"Failed to run code. Status code: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) dispatcher = ExecutionEventDispatcher(execution, handlers) diff --git a/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py index 304720034..908bab9d3 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py +++ b/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py @@ -26,6 +26,7 @@ ExceptionConverter, ) from opensandbox.adapters.converter.response_handler import ( + extract_request_id, handle_api_error, require_parsed, ) @@ -252,6 +253,7 @@ def run( raise SandboxApiException( message=f"Failed to run code. Status code: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) for line in response.iter_lines(): diff --git a/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py b/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py index d6680e9f7..1c8e25a2d 100644 --- a/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py +++ b/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py @@ -52,7 +52,12 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: ) return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=sse, request=request) - return httpx.Response(400, content=b"bad", request=request) + return httpx.Response( + 400, + headers={"x-request-id": "req-code-123"}, + content=b"bad", + request=request, + ) def test_code_execution_converter_includes_context() -> None: @@ -115,5 +120,6 @@ async def test_run_code_non_200_raises_api_exception() -> None: endpoint = SandboxEndpoint(endpoint="localhost:44772", port=44772) adapter = CodesAdapter(endpoint, cfg) - with pytest.raises(SandboxApiException): + with pytest.raises(SandboxApiException) as ei: await adapter.run("other") + assert ei.value.request_id == "req-code-123" diff --git a/sdks/sandbox/csharp/README.md b/sdks/sandbox/csharp/README.md index 6c52c1dcc..940d272c7 100644 --- a/sdks/sandbox/csharp/README.md +++ b/sdks/sandbox/csharp/README.md @@ -55,6 +55,7 @@ try catch (SandboxException ex) { Console.Error.WriteLine($"Sandbox Error: [{ex.Error.Code}] {ex.Error.Message}"); + Console.Error.WriteLine($"Request ID: {ex.RequestId}"); } ``` diff --git a/sdks/sandbox/csharp/README_zh.md b/sdks/sandbox/csharp/README_zh.md index d6fd82d6f..4465f9564 100644 --- a/sdks/sandbox/csharp/README_zh.md +++ b/sdks/sandbox/csharp/README_zh.md @@ -55,6 +55,7 @@ try catch (SandboxException ex) { Console.Error.WriteLine($"沙箱错误: [{ex.Error.Code}] {ex.Error.Message}"); + Console.Error.WriteLine($"Request ID: {ex.RequestId}"); } ``` diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs b/sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs index d27489a7a..c714b5521 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs @@ -85,19 +85,42 @@ public class SandboxException : Exception /// public SandboxError Error { get; } + /// + /// Gets the request ID from the server response when available. + /// + public string? RequestId { get; } + /// /// Initializes a new instance of the class. + /// Kept for binary compatibility with previous SDK versions. /// /// The error message. /// The inner exception. /// The structured error information. + public SandboxException( + string? message, + Exception? innerException, + SandboxError? error) + : this(message, innerException, error, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + /// The structured error information. + /// The request ID. public SandboxException( string? message = null, Exception? innerException = null, - SandboxError? error = null) + SandboxError? error = null, + string? requestId = null) : base(message ?? error?.Message, innerException) { Error = error ?? new SandboxError(SandboxErrorCodes.InternalUnknownError, message); + RequestId = requestId; } } @@ -112,9 +135,10 @@ public class SandboxApiException : SandboxException public int? StatusCode { get; } /// - /// Gets the request ID from the server response. + /// Gets the request ID from the server response when available. + /// Kept on the derived type for binary compatibility with older releases. /// - public string? RequestId { get; } + public new string? RequestId => base.RequestId; /// /// Gets the raw response body. @@ -137,10 +161,9 @@ public SandboxApiException( object? rawBody = null, Exception? innerException = null, SandboxError? error = null) - : base(message, innerException, error ?? new SandboxError(SandboxErrorCodes.UnexpectedResponse, message)) + : base(message, innerException, error ?? new SandboxError(SandboxErrorCodes.UnexpectedResponse, message), requestId) { StatusCode = statusCode; - RequestId = requestId; RawBody = rawBody; } } diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ExceptionTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ExceptionTests.cs index b7a081230..f60793b52 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ExceptionTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ExceptionTests.cs @@ -71,6 +71,30 @@ public void SandboxException_ShouldContainError() exception.Error.Should().Be(error); } + [Fact] + public void SandboxException_ShouldContainRequestId() + { + // Arrange & Act + var exception = new SandboxException("Exception message", requestId: "req-base-123"); + + // Assert + exception.RequestId.Should().Be("req-base-123"); + } + + [Fact] + public void SandboxException_ShouldDeclareLegacyConstructor_ForBinaryCompatibility() + { + var constructor = typeof(SandboxException).GetConstructor( + new[] + { + typeof(string), + typeof(Exception), + typeof(SandboxError) + }); + + constructor.Should().NotBeNull(); + } + [Fact] public void SandboxException_WithoutError_ShouldCreateDefaultError() { @@ -100,6 +124,18 @@ public void SandboxApiException_ShouldContainStatusCodeAndRequestId() exception.Error.Code.Should().Be(SandboxErrorCodes.UnexpectedResponse); } + [Fact] + public void SandboxApiException_ShouldDeclareRequestIdProperty_ForBinaryCompatibility() + { + var requestIdProperty = typeof(SandboxApiException).GetProperty( + "RequestId", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.DeclaredOnly); + + requestIdProperty.Should().NotBeNull(); + } + [Fact] public void SandboxApiException_WithCustomError_ShouldUseProvidedError() { diff --git a/sdks/sandbox/javascript/README.md b/sdks/sandbox/javascript/README.md index 49b93a81d..235c6b8b2 100644 --- a/sdks/sandbox/javascript/README.md +++ b/sdks/sandbox/javascript/README.md @@ -58,6 +58,7 @@ try { console.error( `Sandbox Error: [${err.error.code}] ${err.error.message ?? ""}`, ); + console.error(`Request ID: ${err.requestId ?? "N/A"}`); } else { console.error(err); } diff --git a/sdks/sandbox/javascript/README_zh.md b/sdks/sandbox/javascript/README_zh.md index a9d7192f2..943f66df8 100644 --- a/sdks/sandbox/javascript/README_zh.md +++ b/sdks/sandbox/javascript/README_zh.md @@ -55,6 +55,7 @@ try { } catch (err) { if (err instanceof SandboxException) { console.error(`沙箱错误: [${err.error.code}] ${err.error.message ?? ""}`); + console.error(`Request ID: ${err.requestId ?? "N/A"}`); } else { console.error(err); } diff --git a/sdks/sandbox/javascript/package.json b/sdks/sandbox/javascript/package.json index cf5b72635..1df5cbbd0 100644 --- a/sdks/sandbox/javascript/package.json +++ b/sdks/sandbox/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@alibaba-group/opensandbox", - "version": "0.1.4", + "version": "0.1.5", "description": "OpenSandbox TypeScript/JavaScript SDK (sandbox lifecycle + execd APIs)", "license": "Apache-2.0", "type": "module", diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 571eac185..99dc66c26 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -800,15 +800,20 @@ export interface components { path: string; }; /** - * @description Kubernetes PersistentVolumeClaim mount backend. References an existing - * PVC in the same namespace as the sandbox pod. + * @description Platform-managed named volume backend. A runtime-neutral abstraction + * for referencing a pre-existing, platform-managed named volume. * - * Only available in Kubernetes runtime. + * - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. + * - Docker: maps to a Docker named volume (created via `docker volume create`). + * + * The volume must already exist on the target platform before sandbox + * creation. */ PVC: { /** - * @description Name of the PersistentVolumeClaim in the same namespace. - * Must be a valid Kubernetes resource name. + * @description Name of the volume on the target platform. + * In Kubernetes this is the PVC name; in Docker this is the named + * volume name. Must be a valid DNS label. */ claimName: string; }; diff --git a/sdks/sandbox/javascript/src/core/constants.ts b/sdks/sandbox/javascript/src/core/constants.ts index 46527e038..843baf85e 100644 --- a/sdks/sandbox/javascript/src/core/constants.ts +++ b/sdks/sandbox/javascript/src/core/constants.ts @@ -26,4 +26,4 @@ export const DEFAULT_READY_TIMEOUT_SECONDS = 30; export const DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS = 200; export const DEFAULT_REQUEST_TIMEOUT_SECONDS = 30; -export const DEFAULT_USER_AGENT = "OpenSandbox-JS-SDK/0.1.4"; \ No newline at end of file +export const DEFAULT_USER_AGENT = "OpenSandbox-JS-SDK/0.1.5"; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/core/exceptions.ts b/sdks/sandbox/javascript/src/core/exceptions.ts index d0297e758..1d596a0d4 100644 --- a/sdks/sandbox/javascript/src/core/exceptions.ts +++ b/sdks/sandbox/javascript/src/core/exceptions.ts @@ -44,6 +44,7 @@ interface SandboxExceptionOpts { message?: string; cause?: unknown; error?: SandboxError; + requestId?: string; } /** @@ -55,32 +56,32 @@ export class SandboxException extends Error { readonly name: string = "SandboxException"; readonly error: SandboxError; readonly cause?: unknown; + readonly requestId?: string; constructor(opts: SandboxExceptionOpts = {}) { super(opts.message); this.cause = opts.cause; this.error = opts.error ?? new SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR); + this.requestId = opts.requestId; } } export class SandboxApiException extends SandboxException { readonly name: string = "SandboxApiException"; readonly statusCode?: number; - readonly requestId?: string; readonly rawBody?: unknown; constructor(opts: SandboxExceptionOpts & { statusCode?: number; - requestId?: string; rawBody?: unknown; }) { super({ message: opts.message, cause: opts.cause, error: opts.error ?? new SandboxError(SandboxError.UNEXPECTED_RESPONSE, opts.message), + requestId: opts.requestId, }); this.statusCode = opts.statusCode; - this.requestId = opts.requestId; this.rawBody = opts.rawBody; } } @@ -131,4 +132,4 @@ export class InvalidArgumentException extends SandboxException { error: new SandboxError(SandboxError.INVALID_ARGUMENT, opts.message), }); } -} \ No newline at end of file +} diff --git a/sdks/sandbox/kotlin/README.md b/sdks/sandbox/kotlin/README.md index 2b492c8ba..ee4207001 100644 --- a/sdks/sandbox/kotlin/README.md +++ b/sdks/sandbox/kotlin/README.md @@ -64,6 +64,7 @@ public class QuickStart { } catch (SandboxException e) { // Handle Sandbox specific exceptions System.err.println("Sandbox Error: [" + e.getError().getCode() + "] " + e.getError().getMessage()); + System.err.println("Request ID: " + e.getRequestId()); } catch (Exception e) { e.printStackTrace(); } @@ -236,6 +237,10 @@ ConnectionPool sharedPool = new ConnectionPool(50, 30, TimeUnit.SECONDS); ConnectionConfig sharedConfig = ConnectionConfig.builder() .apiKey("your-key") .domain("api.opensandbox.io") + .headers(Map.of( + "X-Custom-Header", "value", + "X-Request-ID", "trace-123" + )) .connectionPool(sharedPool) // Inject shared pool .build(); ``` diff --git a/sdks/sandbox/kotlin/README_zh.md b/sdks/sandbox/kotlin/README_zh.md index 0edfedf42..932311642 100644 --- a/sdks/sandbox/kotlin/README_zh.md +++ b/sdks/sandbox/kotlin/README_zh.md @@ -65,6 +65,7 @@ public class QuickStart { } catch (SandboxException e) { // 处理 Sandbox 特定异常 System.err.println("沙箱错误: [" + e.getError().getCode() + "] " + e.getError().getMessage()); + System.err.println("Request ID: " + e.getRequestId()); } catch (Exception e) { e.printStackTrace(); } @@ -237,6 +238,10 @@ ConnectionPool sharedPool = new ConnectionPool(50, 30, TimeUnit.SECONDS); ConnectionConfig sharedConfig = ConnectionConfig.builder() .apiKey("your-key") .domain("api.opensandbox.io") + .headers(Map.of( + "X-Custom-Header", "value", + "X-Request-ID", "trace-123" + )) .connectionPool(sharedPool) // 注入共享连接池 .build(); ``` diff --git a/sdks/sandbox/kotlin/gradle.properties b/sdks/sandbox/kotlin/gradle.properties index bdc85d3d7..966e7d6f6 100644 --- a/sdks/sandbox/kotlin/gradle.properties +++ b/sdks/sandbox/kotlin/gradle.properties @@ -5,5 +5,5 @@ org.gradle.parallel=true # Project metadata project.group=com.alibaba.opensandbox -project.version=1.0.4 +project.version=1.0.5 project.description=A Kotlin SDK for Open Sandbox API diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt index 86181c5ab..efadc5b16 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt @@ -53,7 +53,7 @@ class ConnectionConfig private constructor( private const val ENV_API_KEY = "OPEN_SANDBOX_API_KEY" private const val ENV_DOMAIN = "OPEN_SANDBOX_DOMAIN" - private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.4" + private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.5" private const val API_VERSION = "v1" @JvmStatic diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt index da033beb9..a9e9aca2c 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt @@ -27,7 +27,15 @@ open class SandboxException( message: String? = null, cause: Throwable? = null, val error: SandboxError, -) : RuntimeException(message, cause) + val requestId: String? = null, +) : RuntimeException(message, cause) { + // Keep the old constructor signature for binary compatibility with already-compiled clients. + constructor( + message: String?, + cause: Throwable?, + error: SandboxError, + ) : this(message = message, cause = cause, error = error, requestId = null) +} /** * Thrown when the Sandbox API returns an error response (e.g., HTTP 4xx or 5xx) or meet unexpected error when calling api. @@ -37,7 +45,16 @@ class SandboxApiException( cause: Throwable? = null, val statusCode: Int? = null, error: SandboxError = SandboxError(SandboxError.UNEXPECTED_RESPONSE), -) : SandboxException(message, cause, error) + requestId: String? = null, +) : SandboxException(message, cause, error, requestId) { + // Keep the old constructor signature for binary compatibility with already-compiled clients. + constructor( + message: String?, + cause: Throwable?, + statusCode: Int?, + error: SandboxError, + ) : this(message = message, cause = cause, statusCode = statusCode, error = error, requestId = null) +} /** * Thrown when an unexpected internal error occurs within the SDK diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExceptionConverter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExceptionConverter.kt index b6dc3a0dc..b1c8c333f 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExceptionConverter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExceptionConverter.kt @@ -74,6 +74,15 @@ private fun Exception.toApiException(): SandboxApiException { else -> 0 to null } + val requestId = + when (rawResponse) { + is ClientError<*> -> rawResponse.headers.extractRequestId() + is ServerError<*> -> rawResponse.headers.extractRequestId() + is ExecdClientError<*> -> rawResponse.headers.extractRequestId() + is ExecdServerError<*> -> rawResponse.headers.extractRequestId() + else -> null + } + val errorBody = when (rawResponse) { is ClientError<*> -> rawResponse.body @@ -95,9 +104,16 @@ private fun Exception.toApiException(): SandboxApiException { statusCode = statusCode, cause = this, error = sandboxError, + requestId = requestId, ) } +private fun Map>.extractRequestId(): String? { + return entries.firstOrNull { (key, _) -> + key.equals("X-Request-ID", ignoreCase = true) + }?.value?.firstOrNull()?.takeIf { it.isNotBlank() } +} + fun parseSandboxError(body: Any?): SandboxError? { if (body == null) return null diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapter.kt index 446e03eb3..8d3a22f01 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapter.kt @@ -101,6 +101,7 @@ internal class CommandsAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt index d79463f4a..7fa5404d7 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt @@ -100,6 +100,7 @@ internal class FilesystemAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } @@ -127,6 +128,7 @@ internal class FilesystemAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } return response.body?.bytes() ?: ByteArray(0) @@ -154,6 +156,7 @@ internal class FilesystemAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } catch (e: Exception) { response.close() @@ -236,6 +239,7 @@ internal class FilesystemAdapter( message = message, statusCode = response.code, error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE), + requestId = response.header("X-Request-ID"), ) } } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxExceptionCompatibilityTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxExceptionCompatibilityTest.kt new file mode 100644 index 000000000..21612d7aa --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxExceptionCompatibilityTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.domain.exceptions + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class SandboxExceptionCompatibilityTest { + @Test + fun `base exception should keep legacy constructor signature`() { + val ex = SandboxException("boom", null, SandboxError("INTERNAL_UNKNOWN_ERROR")) + + assertEquals("boom", ex.message) + assertNull(ex.requestId) + } + + @Test + fun `api exception should keep legacy constructor signature`() { + val ex = SandboxApiException("boom", null, 500, SandboxError("UNEXPECTED_RESPONSE")) + + assertEquals("boom", ex.message) + assertEquals(500, ex.statusCode) + assertNull(ex.requestId) + } +} diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt index ddfc792cf..ad272aa0c 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt @@ -18,6 +18,7 @@ package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.config.ConnectionConfig +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint @@ -25,6 +26,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -106,4 +108,20 @@ class CommandsAdapterTest { assertEquals("/command", recordedRequest.path) assertEquals("POST", recordedRequest.method) } + + @Test + fun `run should expose request id on api exception`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .addHeader("X-Request-ID", "req-kotlin-123") + .setBody("""{"code":"INTERNAL_ERROR","message":"boom"}"""), + ) + + val request = RunCommandRequest.builder().command("echo Hello").build() + val ex = assertThrows(SandboxApiException::class.java) { commandsAdapter.run(request) } + + assertEquals(500, ex.statusCode) + assertEquals("req-kotlin-123", ex.requestId) + } } diff --git a/sdks/sandbox/python/README.md b/sdks/sandbox/python/README.md index 7b39bdbe2..674e2cdd7 100644 --- a/sdks/sandbox/python/README.md +++ b/sdks/sandbox/python/README.md @@ -58,6 +58,8 @@ async def main(): except SandboxException as e: # Handle Sandbox specific exceptions print(f"Sandbox Error: [{e.error.code}] {e.error.message}") + # Server logs can be correlated by this request id (if available) + print(f"Request ID: {e.request_id}") except Exception as e: print(f"Error: {e}") @@ -265,7 +267,10 @@ import httpx config = ConnectionConfig( api_key="your-key", domain="api.opensandbox.io", - headers={"X-Custom-Header": "value"}, + headers={ + "X-Custom-Header": "value", + "X-Request-ID": "trace-123", + }, transport=httpx.AsyncHTTPTransport( limits=httpx.Limits( max_connections=100, diff --git a/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py index 541ec3fcc..e1fd78d05 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py @@ -39,7 +39,10 @@ from opensandbox.adapters.converter.execution_event_dispatcher import ( ExecutionEventDispatcher, ) -from opensandbox.adapters.converter.response_handler import handle_api_error +from opensandbox.adapters.converter.response_handler import ( + extract_request_id, + handle_api_error, +) from opensandbox.config import ConnectionConfig from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.execd import ( @@ -186,6 +189,7 @@ async def run( raise SandboxApiException( message=f"Failed to run command. Status code: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) dispatcher = ExecutionEventDispatcher(execution, handlers) diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py index 0a4ac8174..3354197d7 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py @@ -180,6 +180,11 @@ def _convert_httpx_error_to_api_exception(e: Exception) -> SandboxApiException: response = getattr(e, "response", None) status_code = response.status_code if response else 0 content = response.content if response else b"" + request_id = None + if response is not None: + from opensandbox.adapters.converter.response_handler import extract_request_id + + request_id = extract_request_id(response.headers) # Try to parse error body sandbox_error = _parse_error_body(content) @@ -189,6 +194,7 @@ def _convert_httpx_error_to_api_exception(e: Exception) -> SandboxApiException: status_code=status_code, cause=e, error=sandbox_error, + request_id=request_id, ) diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py index 92988bf82..e1439497a 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py @@ -35,6 +35,23 @@ T = TypeVar("T") + +def extract_request_id(headers: Any) -> str | None: + """ + Extract X-Request-ID from response headers in a case-insensitive way. + """ + if not headers: + return None + try: + # httpx.Headers supports case-insensitive lookup. + value = headers.get("X-Request-ID") or headers.get("x-request-id") + if isinstance(value, str): + value = value.strip() + return value or None + except Exception: + return None + + def _status_code_to_int(status_code: Any) -> int: """ Normalize status_code from openapi-python-client responses to a plain int. @@ -63,17 +80,20 @@ def require_parsed(response_obj: Any, expected_type: type[T], operation_name: st - parsed payload must match the expected type """ status_code = _status_code_to_int(getattr(response_obj, "status_code", 0)) + request_id = extract_request_id(getattr(response_obj, "headers", None)) parsed = getattr(response_obj, "parsed", None) if parsed is None: raise SandboxApiException( message=f"{operation_name} failed: empty response", status_code=status_code, + request_id=request_id, ) if not isinstance(parsed, expected_type): raise SandboxApiException( message=f"{operation_name} failed: unexpected response type", status_code=status_code, + request_id=request_id, ) return parsed @@ -92,6 +112,7 @@ def handle_api_error(response_obj: Any, operation_name: str = "API call") -> Non SandboxApiException: If the response indicates an error """ status_code = _status_code_to_int(getattr(response_obj, "status_code", 0)) + request_id = extract_request_id(getattr(response_obj, "headers", None)) logger.debug(f"{operation_name} response: status={status_code}") @@ -109,4 +130,5 @@ def handle_api_error(response_obj: Any, operation_name: str = "API call") -> Non raise SandboxApiException( message=error_message, status_code=status_code, + request_id=request_id, ) diff --git a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py index 14c0b0892..c3d8c2c58 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py @@ -35,7 +35,10 @@ from opensandbox.adapters.converter.filesystem_model_converter import ( FilesystemModelConverter, ) -from opensandbox.adapters.converter.response_handler import handle_api_error +from opensandbox.adapters.converter.response_handler import ( + extract_request_id, + handle_api_error, +) from opensandbox.config import ConnectionConfig from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( @@ -214,6 +217,7 @@ async def read_bytes_stream( raise SandboxApiException( f"Failed to stream file {path}: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) return response.aiter_bytes(chunk_size=chunk_size) except Exception as e: @@ -446,6 +450,7 @@ async def search(self, entry: SearchEntry) -> list[EntryInfo]: return FilesystemModelConverter.to_entry_info_list(parsed) raise SandboxApiException( message="Search files failed: unexpected response type", + request_id=extract_request_id(getattr(response_obj, "headers", None)), ) except Exception as e: diff --git a/sdks/sandbox/python/src/opensandbox/config/connection.py b/sdks/sandbox/python/src/opensandbox/config/connection.py index 88e370343..033acb62b 100644 --- a/sdks/sandbox/python/src/opensandbox/config/connection.py +++ b/sdks/sandbox/python/src/opensandbox/config/connection.py @@ -65,7 +65,7 @@ class ConnectionConfig(BaseModel): default=False, description="Enable debug logging for HTTP requests" ) user_agent: str = Field( - default="OpenSandbox-Python-SDK/0.1.4", description="User agent string" + default="OpenSandbox-Python-SDK/0.1.5", description="User agent string" ) headers: dict[str, str] = Field( default_factory=dict, description="User defined headers" diff --git a/sdks/sandbox/python/src/opensandbox/config/connection_sync.py b/sdks/sandbox/python/src/opensandbox/config/connection_sync.py index 70d2ddeca..c60199f24 100644 --- a/sdks/sandbox/python/src/opensandbox/config/connection_sync.py +++ b/sdks/sandbox/python/src/opensandbox/config/connection_sync.py @@ -53,7 +53,7 @@ class ConnectionConfigSync(BaseModel): ) debug: bool = Field(default=False, description="Enable debug logging for HTTP requests") user_agent: str = Field( - default="OpenSandbox-Python-SDK/0.1.4", description="User agent string" + default="OpenSandbox-Python-SDK/0.1.5", description="User agent string" ) headers: dict[str, str] = Field(default_factory=dict, description="User defined headers") diff --git a/sdks/sandbox/python/src/opensandbox/exceptions/sandbox.py b/sdks/sandbox/python/src/opensandbox/exceptions/sandbox.py index ef7e40aa7..b77403dac 100644 --- a/sdks/sandbox/python/src/opensandbox/exceptions/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/exceptions/sandbox.py @@ -50,10 +50,12 @@ def __init__( message: str | None = None, cause: Exception | None = None, error: SandboxError | None = None, + request_id: str | None = None, ) -> None: super().__init__(message) self.__cause__ = cause self.error = error or SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR) + self.request_id = request_id class SandboxApiException(SandboxException): @@ -68,9 +70,13 @@ def __init__( cause: Exception | None = None, status_code: int | None = None, error: SandboxError | None = None, + request_id: str | None = None, ) -> None: super().__init__( - message, cause, error or SandboxError(SandboxError.UNEXPECTED_RESPONSE) + message, + cause, + error or SandboxError(SandboxError.UNEXPECTED_RESPONSE), + request_id=request_id, ) self.status_code = status_code diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py index 8bc71ce54..7c5263fd4 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py @@ -29,7 +29,10 @@ from opensandbox.adapters.converter.execution_converter import ( ExecutionConverter, ) -from opensandbox.adapters.converter.response_handler import handle_api_error +from opensandbox.adapters.converter.response_handler import ( + extract_request_id, + handle_api_error, +) from opensandbox.config.connection_sync import ConnectionConfigSync from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.execd import ( @@ -136,6 +139,7 @@ def run( raise SandboxApiException( message=f"Failed to run command. Status code: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) for line in response.iter_lines(): diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py index 24b4c4b97..7db1afabe 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py @@ -32,7 +32,10 @@ from opensandbox.adapters.converter.filesystem_model_converter import ( FilesystemModelConverter, ) -from opensandbox.adapters.converter.response_handler import handle_api_error +from opensandbox.adapters.converter.response_handler import ( + extract_request_id, + handle_api_error, +) from opensandbox.config.connection_sync import ConnectionConfigSync from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( @@ -154,6 +157,7 @@ def read_bytes_stream( raise SandboxApiException( f"Failed to stream file {path}: {response.status_code}", status_code=response.status_code, + request_id=extract_request_id(response.headers), ) def _iter() -> Iterator[bytes]: @@ -311,7 +315,10 @@ def search(self, entry: SearchEntry) -> list[EntryInfo]: return [] if isinstance(parsed, list) and all(isinstance(x, FileInfo) for x in parsed): return FilesystemModelConverter.to_entry_info_list(parsed) - raise SandboxApiException(message="Search files failed: unexpected response type") + raise SandboxApiException( + message="Search files failed: unexpected response type", + request_id=extract_request_id(getattr(response_obj, "headers", None)), + ) except Exception as e: logger.error("Failed to search files", exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/tests/test_converters_and_error_handling.py b/sdks/sandbox/python/tests/test_converters_and_error_handling.py index fd5be5f4c..3d2025253 100644 --- a/sdks/sandbox/python/tests/test_converters_and_error_handling.py +++ b/sdks/sandbox/python/tests/test_converters_and_error_handling.py @@ -33,7 +33,10 @@ from opensandbox.adapters.converter.metrics_model_converter import ( MetricsModelConverter, ) -from opensandbox.adapters.converter.response_handler import handle_api_error +from opensandbox.adapters.converter.response_handler import ( + handle_api_error, + require_parsed, +) from opensandbox.adapters.converter.sandbox_model_converter import ( SandboxModelConverter, ) @@ -76,10 +79,12 @@ class Parsed: class Resp: status_code = 400 parsed = Parsed() + headers = {"X-Request-ID": "req-123"} with pytest.raises(SandboxApiException) as ei: handle_api_error(Resp(), "Op") assert "bad request" in str(ei.value) + assert ei.value.request_id == "req-123" def test_handle_api_error_noop_on_success() -> None: @@ -90,6 +95,17 @@ class Resp: handle_api_error(Resp(), "Op") +def test_require_parsed_includes_request_id_on_invalid_payload() -> None: + class Resp: + status_code = 200 + parsed = None + headers = {"x-request-id": "req-456"} + + with pytest.raises(SandboxApiException) as ei: + require_parsed(Resp(), expected_type=str, operation_name="Op") + assert ei.value.request_id == "req-456" + + def test_exception_converter_maps_common_types() -> None: se = ExceptionConverter.to_sandbox_exception(ValueError("x")) assert isinstance(se, InvalidArgumentException) diff --git a/sdks/sandbox/python/tests/test_filesystem_search_error_handling.py b/sdks/sandbox/python/tests/test_filesystem_search_error_handling.py new file mode 100644 index 000000000..04892ad62 --- /dev/null +++ b/sdks/sandbox/python/tests/test_filesystem_search_error_handling.py @@ -0,0 +1,73 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from types import SimpleNamespace + +import pytest + +from opensandbox.adapters.filesystem_adapter import FilesystemAdapter +from opensandbox.config import ConnectionConfig +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.exceptions import SandboxApiException +from opensandbox.models.filesystem import SearchEntry +from opensandbox.models.sandboxes import SandboxEndpoint +from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync + + +@pytest.mark.asyncio +async def test_async_search_unexpected_response_without_headers_still_raises_api_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_asyncio_detailed(**_: object) -> SimpleNamespace: + return SimpleNamespace(status_code=200, parsed=object()) + + from opensandbox.api.execd.api.filesystem import search_files + + monkeypatch.setattr(search_files, "asyncio_detailed", _fake_asyncio_detailed) + + cfg = ConnectionConfig(protocol="http") + endpoint = SandboxEndpoint(endpoint="localhost:44772", port=44772) + adapter = FilesystemAdapter(cfg, endpoint) + async def _fake_get_client() -> object: + return object() + + monkeypatch.setattr(adapter, "_get_client", _fake_get_client) + + with pytest.raises(SandboxApiException) as ei: + await adapter.search(SearchEntry(path="/tmp", pattern="*.log")) + + assert "unexpected response type" in str(ei.value) + assert ei.value.request_id is None + + +def test_sync_search_unexpected_response_without_headers_still_raises_api_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fake_sync_detailed(**_: object) -> SimpleNamespace: + return SimpleNamespace(status_code=200, parsed=object()) + + from opensandbox.api.execd.api.filesystem import search_files + + monkeypatch.setattr(search_files, "sync_detailed", _fake_sync_detailed) + + cfg = ConnectionConfigSync(protocol="http") + endpoint = SandboxEndpoint(endpoint="localhost:44772", port=44772) + adapter = FilesystemAdapterSync(cfg, endpoint) + + with pytest.raises(SandboxApiException) as ei: + adapter.search(SearchEntry(path="/tmp", pattern="*.log")) + + assert "unexpected response type" in str(ei.value) + assert ei.value.request_id is None diff --git a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs index af4e4221e..ac9ebf62d 100644 --- a/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs +++ b/tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs @@ -84,6 +84,41 @@ public async Task Sandbox_Lifecycle_Health_Endpoint_Metrics_Renew_Connect() } } + [Fact(Timeout = 2 * 60 * 1000)] + public async Task Sandbox_XRequestId_Passthrough_OnServerError() + { + var requestId = $"e2e-csharp-server-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + var missingSandboxId = $"missing-{requestId}"; + var baseConfig = _fixture.ConnectionConfig; + var config = new ConnectionConfig(new ConnectionConfigOptions + { + Domain = baseConfig.Domain, + Protocol = baseConfig.Protocol, + ApiKey = baseConfig.ApiKey, + RequestTimeoutSeconds = baseConfig.RequestTimeoutSeconds, + Headers = new Dictionary { ["X-Request-ID"] = requestId } + }); + + var ex = await Assert.ThrowsAsync(async () => + { + var connected = await Sandbox.ConnectAsync(new SandboxConnectOptions + { + ConnectionConfig = config, + SandboxId = missingSandboxId + }); + try + { + await connected.GetInfoAsync(); + } + finally + { + await connected.DisposeAsync(); + } + }); + + Assert.Equal(requestId, ex.RequestId); + } + [Fact(Timeout = 2 * 60 * 1000)] public async Task Sandbox_Create_With_NetworkPolicy() { diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java index 1225608fb..0ad86bf9a 100644 --- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java @@ -22,9 +22,9 @@ import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext; import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest; import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage; -import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*; import com.alibaba.opensandbox.sandbox.Sandbox; import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.*; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -591,16 +591,15 @@ void testTypeScriptCodeExecution() { } /** - * Run a code request with a per-execution timeout so that a single hanging - * SSE stream cannot block the entire test for the full JUnit timeout. + * Run a code request with a per-execution timeout so that a single hanging SSE stream cannot + * block the entire test for the full JUnit timeout. */ private Execution runWithTimeout(RunCodeRequest request, Duration timeout) { try { return CompletableFuture.supplyAsync(() -> codeInterpreter.codes().run(request)) .get(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - throw new AssertionError( - "Code execution did not complete within " + timeout, e); + throw new AssertionError("Code execution did not complete within " + timeout, e); } catch (ExecutionException e) { Throwable cause = e.getCause(); if (cause instanceof RuntimeException) { @@ -943,8 +942,11 @@ void testCodeExecutionInterrupt() throws InterruptedException, ExecutionExceptio // Verify the interrupt was effective: execution finished much faster // than the full 20 s run. Terminal events (complete/error) may or may // not arrive depending on how quickly the server closed the stream. - assertTrue(elapsed < 90_000, - "Execution should have finished promptly after interrupt (elapsed=" + elapsed + "ms)"); + assertTrue( + elapsed < 90_000, + "Execution should have finished promptly after interrupt (elapsed=" + + elapsed + + "ms)"); // Test 2: Java long-running execution with interrupt logger.info("Testing Java interrupt functionality"); diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java index b2af7c42d..ab17e69fc 100644 --- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java @@ -19,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.*; import com.alibaba.opensandbox.sandbox.Sandbox; +import com.alibaba.opensandbox.sandbox.config.ConnectionConfig; +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException; import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.*; import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.*; import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*; @@ -315,8 +317,7 @@ void testSandboxCreateWithHostVolumeMount() { assertNull(readMarker.getError(), "Failed to read marker file"); assertEquals(1, readMarker.getLogs().getStdout().size()); assertEquals( - "opensandbox-e2e-marker", - readMarker.getLogs().getStdout().get(0).getText()); + "opensandbox-e2e-marker", readMarker.getLogs().getStdout().get(0).getText()); // Step 2: Write a file from inside the sandbox to the mounted path Execution writeResult = @@ -344,8 +345,7 @@ void testSandboxCreateWithHostVolumeMount() { .build()); assertNull(readBack.getError()); assertEquals(1, readBack.getLogs().getStdout().size()); - assertEquals( - "written-from-sandbox", readBack.getLogs().getStdout().get(0).getText()); + assertEquals("written-from-sandbox", readBack.getLogs().getStdout().get(0).getText()); // Step 4: Verify the mount path is a proper directory Execution dirCheck = @@ -407,8 +407,7 @@ void testSandboxCreateWithHostVolumeMountReadOnly() { assertNull(readMarker.getError(), "Failed to read marker file on read-only mount"); assertEquals(1, readMarker.getLogs().getStdout().size()); assertEquals( - "opensandbox-e2e-marker", - readMarker.getLogs().getStdout().get(0).getText()); + "opensandbox-e2e-marker", readMarker.getLogs().getStdout().get(0).getText()); // Step 2: Verify writing is denied on read-only mount Execution writeResult = @@ -421,8 +420,7 @@ void testSandboxCreateWithHostVolumeMountReadOnly() { + containerMountPath + "/should-fail.txt") .build()); - assertNotNull( - writeResult.getError(), "Write should fail on read-only mount"); + assertNotNull(writeResult.getError(), "Write should fail on read-only mount"); } finally { try { roSandbox.kill(); @@ -470,9 +468,7 @@ void testSandboxCreateWithPvcVolumeMount() { .build()); assertNull(readMarker.getError(), "Failed to read marker file from PVC volume"); assertEquals(1, readMarker.getLogs().getStdout().size()); - assertEquals( - "pvc-marker-data", - readMarker.getLogs().getStdout().get(0).getText()); + assertEquals("pvc-marker-data", readMarker.getLogs().getStdout().get(0).getText()); // Step 2: Write a file from inside the sandbox to the named volume Execution writeResult = @@ -494,14 +490,11 @@ void testSandboxCreateWithPvcVolumeMount() { .run( RunCommandRequest.builder() .command( - "cat " - + containerMountPath - + "/pvc-output.txt") + "cat " + containerMountPath + "/pvc-output.txt") .build()); assertNull(readBack.getError()); assertEquals(1, readBack.getLogs().getStdout().size()); - assertEquals( - "written-to-pvc", readBack.getLogs().getStdout().get(0).getText()); + assertEquals("written-to-pvc", readBack.getLogs().getStdout().get(0).getText()); // Step 4: Verify the mount path is a proper directory Execution dirCheck = @@ -562,9 +555,7 @@ void testSandboxCreateWithPvcVolumeMountReadOnly() { .build()); assertNull(readMarker.getError(), "Failed to read marker file on read-only PVC mount"); assertEquals(1, readMarker.getLogs().getStdout().size()); - assertEquals( - "pvc-marker-data", - readMarker.getLogs().getStdout().get(0).getText()); + assertEquals("pvc-marker-data", readMarker.getLogs().getStdout().get(0).getText()); // Step 2: Verify writing is denied on read-only mount Execution writeResult = @@ -577,8 +568,7 @@ void testSandboxCreateWithPvcVolumeMountReadOnly() { + containerMountPath + "/should-fail.txt") .build()); - assertNotNull( - writeResult.getError(), "Write should fail on read-only PVC mount"); + assertNotNull(writeResult.getError(), "Write should fail on read-only PVC mount"); } finally { try { roSandbox.kill(); @@ -627,9 +617,7 @@ void testSandboxCreateWithPvcVolumeMountSubPath() { .build()); assertNull(readMarker.getError(), "Failed to read subpath marker file"); assertEquals(1, readMarker.getLogs().getStdout().size()); - assertEquals( - "pvc-subpath-marker", - readMarker.getLogs().getStdout().get(0).getText()); + assertEquals("pvc-subpath-marker", readMarker.getLogs().getStdout().get(0).getText()); // Step 2: Verify only subPath contents are visible (not the full volume) Execution lsResult = @@ -665,15 +653,11 @@ void testSandboxCreateWithPvcVolumeMountSubPath() { .commands() .run( RunCommandRequest.builder() - .command( - "cat " - + containerMountPath - + "/output.txt") + .command("cat " + containerMountPath + "/output.txt") .build()); assertNull(readBack.getError()); assertEquals(1, readBack.getLogs().getStdout().size()); - assertEquals( - "subpath-write-test", readBack.getLogs().getStdout().get(0).getText()); + assertEquals("subpath-write-test", readBack.getLogs().getStdout().get(0).getText()); } finally { try { subpathSandbox.kill(); @@ -1210,4 +1194,39 @@ void testSandboxResume() throws InterruptedException { } assertTrue(healthy, "Sandbox should be healthy after resume"); } + + @Test + @Order(9) + @DisplayName("X-Request-ID passthrough on server error") + @Timeout(value = 2, unit = TimeUnit.MINUTES) + void testXRequestIdPassthroughOnServerError() { + String requestId = "e2e-java-server-" + System.currentTimeMillis(); + String missingSandboxId = "missing-" + requestId; + + ConnectionConfig cfg = + ConnectionConfig.builder() + .apiKey(sharedConnectionConfig.getApiKey()) + .domain(sharedConnectionConfig.getDomain()) + .protocol(sharedConnectionConfig.getProtocol()) + .requestTimeout(sharedConnectionConfig.getRequestTimeout()) + .headers(Map.of("X-Request-ID", requestId)) + .build(); + + SandboxApiException ex = + assertThrows( + SandboxApiException.class, + () -> { + Sandbox connected = + Sandbox.connector() + .connectionConfig(cfg) + .sandboxId(missingSandboxId) + .connect(); + try { + connected.getInfo(); + } finally { + connected.close(); + } + }); + assertEquals(requestId, ex.getRequestId()); + } } diff --git a/tests/javascript/tests/test_sandbox_e2e.test.ts b/tests/javascript/tests/test_sandbox_e2e.test.ts index d4cc1a2d2..b004ebc53 100644 --- a/tests/javascript/tests/test_sandbox_e2e.test.ts +++ b/tests/javascript/tests/test_sandbox_e2e.test.ts @@ -15,6 +15,8 @@ import { afterAll, beforeAll, expect, test } from "vitest"; import { + ConnectionConfig, + SandboxApiException, Sandbox, DEFAULT_EXECD_PORT, SandboxManager, @@ -27,6 +29,9 @@ import { } from "@alibaba-group/opensandbox"; import { + TEST_API_KEY, + TEST_DOMAIN, + TEST_PROTOCOL, assertEndpointHasPort, assertRecentTimestampMs, createConnectionConfig, @@ -756,3 +761,27 @@ test("05 sandbox pause + resume", async () => { expect(echo.error).toBeUndefined(); expect(echo.logs.stdout[0]?.text).toBe("resume-ok"); }); + +test("06 x-request-id passthrough on server error", async () => { + const requestId = `e2e-js-server-${Date.now()}`; + const missingSandboxId = `missing-${requestId}`; + const connectionConfig = new ConnectionConfig({ + domain: TEST_DOMAIN, + protocol: TEST_PROTOCOL === "https" ? "https" : "http", + apiKey: TEST_API_KEY, + requestTimeoutSeconds: 180, + headers: { "X-Request-ID": requestId }, + }); + + try { + const connected = await Sandbox.connect({ + sandboxId: missingSandboxId, + connectionConfig, + }); + await connected.getInfo(); + throw new Error("expected server call to fail"); + } catch (err) { + expect(err).toBeInstanceOf(SandboxApiException); + expect((err as SandboxApiException).requestId).toBe(requestId); + } +}); diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py index 1b87b924e..89c5d4b13 100644 --- a/tests/python/tests/test_code_interpreter_e2e_sync.py +++ b/tests/python/tests/test_code_interpreter_e2e_sync.py @@ -818,7 +818,8 @@ def test_07_concurrent_code_execution(self): SupportedLanguage.GO, ], ) as (python_c1, java_c1, go_c1): - from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout + from concurrent.futures import ThreadPoolExecutor + from concurrent.futures import TimeoutError as FutureTimeout labels = ["Python", "Java", "Go"] diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py index 3ecccca25..72828c9fc 100644 --- a/tests/python/tests/test_sandbox_e2e.py +++ b/tests/python/tests/test_sandbox_e2e.py @@ -25,6 +25,8 @@ import pytest from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig +from opensandbox.exceptions import SandboxApiException from opensandbox.models.execd import ( ExecutionComplete, ExecutionError, @@ -41,9 +43,22 @@ SetPermissionEntry, WriteEntry, ) -from opensandbox.models.sandboxes import Host, NetworkPolicy, NetworkRule, PVC, SandboxImageSpec, Volume +from opensandbox.models.sandboxes import ( + PVC, + Host, + NetworkPolicy, + NetworkRule, + SandboxImageSpec, + Volume, +) -from tests.base_e2e_test import create_connection_config, get_sandbox_image +from tests.base_e2e_test import ( + TEST_API_KEY, + TEST_DOMAIN, + TEST_PROTOCOL, + create_connection_config, + get_sandbox_image, +) logger = logging.getLogger(__name__) @@ -1217,3 +1232,21 @@ async def test_06_sandbox_resume(self): elapsed_time = (time.time() - start_time) * 1000 logger.info(f"✓ Sandbox resume completed in {elapsed_time:.2f} ms") logger.info("TEST 5 PASSED: Sandbox resume operation test completed successfully") + + @pytest.mark.timeout(120) + @pytest.mark.order(8) + async def test_07_x_request_id_passthrough_on_server_error(self): + request_id = f"e2e-py-server-{int(time.time() * 1000)}" + missing_sandbox_id = f"missing-{request_id}" + cfg = ConnectionConfig( + domain=TEST_DOMAIN, + api_key=TEST_API_KEY, + request_timeout=timedelta(minutes=3), + protocol=TEST_PROTOCOL, + headers={"X-Request-ID": request_id}, + ) + + with pytest.raises(SandboxApiException) as ei: + connected = await Sandbox.connect(sandbox_id=missing_sandbox_id, connection_config=cfg) + await connected.get_info() + assert ei.value.request_id == request_id diff --git a/tests/python/tests/test_sandbox_e2e_sync.py b/tests/python/tests/test_sandbox_e2e_sync.py index 07eb3e22a..ffc86ee38 100644 --- a/tests/python/tests/test_sandbox_e2e_sync.py +++ b/tests/python/tests/test_sandbox_e2e_sync.py @@ -25,8 +25,11 @@ from datetime import timedelta from io import BytesIO +import httpx import pytest from opensandbox import SandboxSync +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.exceptions import SandboxApiException from opensandbox.models.execd import ( ExecutionComplete, ExecutionError, @@ -42,9 +45,22 @@ SetPermissionEntry, WriteEntry, ) -from opensandbox.models.sandboxes import Host, NetworkPolicy, NetworkRule, PVC, SandboxImageSpec, Volume +from opensandbox.models.sandboxes import ( + PVC, + Host, + NetworkPolicy, + NetworkRule, + SandboxImageSpec, + Volume, +) -from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image +from tests.base_e2e_test import ( + TEST_API_KEY, + TEST_DOMAIN, + TEST_PROTOCOL, + create_connection_config_sync, + get_sandbox_image, +) logger = logging.getLogger(__name__) @@ -1105,3 +1121,31 @@ def test_06_sandbox_resume(self) -> None: assert echo.error is None assert len(echo.logs.stdout) == 1 assert echo.logs.stdout[0].text == "resume-ok" + + @pytest.mark.timeout(120) + @pytest.mark.order(8) + def test_07_x_request_id_passthrough_on_server_error(self) -> None: + request_id = f"e2e-py-sync-server-{int(time.time() * 1000)}" + missing_sandbox_id = f"missing-{request_id}" + cfg = ConnectionConfigSync( + domain=TEST_DOMAIN, + api_key=TEST_API_KEY, + request_timeout=timedelta(minutes=3), + protocol=TEST_PROTOCOL, + headers={"X-Request-ID": request_id}, + transport=httpx.HTTPTransport( + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=15, + ) + ), + ) + + try: + with pytest.raises(SandboxApiException) as ei: + connected = SandboxSync.connect(missing_sandbox_id, connection_config=cfg) + connected.get_info() + assert ei.value.request_id == request_id + finally: + cfg.transport.close()