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()