From 0c0e5ecbfb0a199a1c47d224b2808a537ba97b83 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Wed, 20 May 2026 21:12:45 +0800 Subject: [PATCH 1/3] fix(sdk): avoid ERROR-level logs for expected file-not-found on read FilesystemAdapter read operations (readFile/readByteArray/readStream) caught every failure and logged it at ERROR with a full stack trace before rethrowing. A missing file (server returns HTTP 404 with code FILE_NOT_FOUND) is an expected, business-level outcome rather than a fault, so this floods callers' error logs and monitoring with noise for a normal control-flow case (e.g. polling for a not-yet- created stdout file). Distinguish "file not found" from genuine failures and log it at DEBUG instead of ERROR. The exception is still propagated unchanged. - Add SandboxError.FILE_NOT_FOUND constant. - Add Throwable.isFileNotFound() extension (statusCode 404 / code FILE_NOT_FOUND) so callers can also branch on it cleanly. - Route read-failure logging through logReadFailure() which downgrades not-found to DEBUG. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../domain/exceptions/SandboxException.kt | 3 + .../adapters/converter/ExceptionConverter.kt | 11 ++ .../adapters/service/FilesystemAdapter.kt | 27 +++- .../adapters/service/FilesystemAdapterTest.kt | 128 ++++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt 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 c57ce63b1..25092de02 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 @@ -180,6 +180,9 @@ data class SandboxError( const val INVALID_ARGUMENT = "INVALID_ARGUMENT" const val UNEXPECTED_RESPONSE = "UNEXPECTED_RESPONSE" + /** The requested file or directory does not exist (server responds with HTTP 404). */ + const val FILE_NOT_FOUND = "FILE_NOT_FOUND" + /** Pool-specific: no idle sandbox and policy is FAIL_FAST. */ const val POOL_EMPTY = "POOL_EMPTY" 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 5ea52f04b..c6fa9fd2a 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 @@ -39,6 +39,17 @@ import com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ClientException import com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerError as ExecdServerError import com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerException as ExecdServerException +/** + * Returns `true` when this throwable represents an expected "file or directory does not exist" + * outcome rather than a genuine failure. + * + * Callers (and the adapters themselves) use this to avoid treating a missing file as an error, + * e.g. logging it at ERROR level with a full stack trace, which is just noise for a perfectly + * normal control-flow case such as polling for a not-yet-created file. + */ +fun Throwable.isFileNotFound(): Boolean = + this is SandboxApiException && (error.code == SandboxError.FILE_NOT_FOUND || statusCode == 404) + fun Exception.toSandboxException(): SandboxException { return when (this) { is SandboxException -> this 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 7fa5404d7..cad154073 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 @@ -35,6 +35,7 @@ import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.Filesys import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toApiReplaceFileContentMap import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toEntryInfo import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toEntryInfoMap +import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.isFileNotFound import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException import kotlinx.serialization.json.buildJsonObject @@ -108,7 +109,7 @@ internal class FilesystemAdapter( return response.body?.source()?.readString(charset) ?: "" } } catch (e: Exception) { - logger.error("Failed to read file with encoding $encoding: $path", e) + logReadFailure("Failed to read file with encoding $encoding: $path", e) throw e.toSandboxException() } } @@ -134,7 +135,7 @@ internal class FilesystemAdapter( return response.body?.bytes() ?: ByteArray(0) } } catch (e: Exception) { - logger.error("Failed to read file as byte array: $path", e) + logReadFailure("Failed to read file as byte array: $path", e) throw e.toSandboxException() } } @@ -167,7 +168,7 @@ internal class FilesystemAdapter( return response.body?.byteStream() ?: throw IllegalStateException("Response body is null") } catch (e: Exception) { - logger.error("Failed to read file as stream: $path", e) + logReadFailure("Failed to read file as stream: $path", e) throw e.toSandboxException() } } @@ -335,6 +336,26 @@ internal class FilesystemAdapter( } } + /** + * Logs a failed read operation, distinguishing genuine failures from the expected + * "file does not exist" case. + * + * A missing file is a normal control-flow outcome (e.g. polling for a not-yet-created + * file), so it is logged at DEBUG level instead of ERROR to avoid flooding callers' + * error logs and monitoring with stack traces for a non-error condition. The exception + * is still propagated to the caller unchanged. + */ + private fun logReadFailure( + message: String, + e: Exception, + ) { + if (e.isFileNotFound()) { + logger.debug(message, e) + } else { + logger.error(message, e) + } + } + private fun getCharsetFromEncoding(encoding: String): Charset { try { return charset(encoding) diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt new file mode 100644 index 000000000..fb8a8b235 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt @@ -0,0 +1,128 @@ +/* + * 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.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.exceptions.SandboxError +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint +import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.isFileNotFound +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.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class FilesystemAdapterTest { + private lateinit var mockWebServer: MockWebServer + private lateinit var filesystemAdapter: FilesystemAdapter + private lateinit var httpClientProvider: HttpClientProvider + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + + val host = mockWebServer.hostName + val port = mockWebServer.port + val endpoint = SandboxEndpoint("$host:$port") + + val config = + ConnectionConfig.builder() + .domain("$host:$port") + .protocol("http") + .build() + + httpClientProvider = HttpClientProvider(config) + filesystemAdapter = FilesystemAdapter(httpClientProvider, endpoint) + } + + @AfterEach + fun tearDown() { + mockWebServer.shutdown() + httpClientProvider.close() + } + + @Test + fun `readFile surfaces FILE_NOT_FOUND error code on 404 so callers can distinguish it`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(404) + .setBody( + """{"code":"FILE_NOT_FOUND","message":"file not found. open /tmp/missing.txt: no such file or directory"}""", + ), + ) + + val exception = + assertThrows { + filesystemAdapter.readFile("/tmp/missing.txt", "UTF-8", null) + } + + assertEquals(404, exception.statusCode) + assertEquals(SandboxError.FILE_NOT_FOUND, exception.error.code) + // The exception itself is recognised as a "not found" condition, which is what the + // adapter relies on to avoid emitting ERROR-level log noise for an expected outcome. + assertTrue(exception.isFileNotFound()) + } + + @Test + fun `readFile returns content on success`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("hello world"), + ) + + val content = filesystemAdapter.readFile("/tmp/hello.txt", "UTF-8", null) + + assertEquals("hello world", content) + } + + @Test + fun `isFileNotFound is true for FILE_NOT_FOUND error code`() { + val exception = + SandboxApiException( + message = "Failed to read file. Status code: 404", + statusCode = 404, + error = SandboxError(SandboxError.FILE_NOT_FOUND), + ) + + assertTrue(exception.isFileNotFound()) + } + + @Test + fun `isFileNotFound is false for other API errors`() { + val exception = + SandboxApiException( + message = "Internal server error", + statusCode = 500, + error = SandboxError(SandboxError.UNEXPECTED_RESPONSE), + ) + + assertFalse(exception.isFileNotFound()) + } + + @Test + fun `isFileNotFound is false for non-sandbox exceptions`() { + assertFalse(RuntimeException("boom").isFileNotFound()) + } +} From 7d8845546341add5fa752acb5de9572ac940f691 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Wed, 20 May 2026 21:21:39 +0800 Subject: [PATCH 2/3] style(sdk): apply spotless formatting to isFileNotFound Collapse the expression-body function to a single line as required by the project's spotlessCheck (root ./gradlew spotlessCheck). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infrastructure/adapters/converter/ExceptionConverter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 c6fa9fd2a..1dacf1dfa 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 @@ -47,8 +47,7 @@ import com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerException * e.g. logging it at ERROR level with a full stack trace, which is just noise for a perfectly * normal control-flow case such as polling for a not-yet-created file. */ -fun Throwable.isFileNotFound(): Boolean = - this is SandboxApiException && (error.code == SandboxError.FILE_NOT_FOUND || statusCode == 404) +fun Throwable.isFileNotFound(): Boolean = this is SandboxApiException && (error.code == SandboxError.FILE_NOT_FOUND || statusCode == 404) fun Exception.toSandboxException(): SandboxException { return when (this) { From 29fab0b6abcf7df251014816a88bf644f8ff50e8 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Wed, 20 May 2026 21:23:37 +0800 Subject: [PATCH 3/3] fix(sdk): classify not-found only by explicit FILE_NOT_FOUND code Address review feedback: isFileNotFound() previously treated any HTTP 404 as not-found. A 404 whose body cannot be parsed is mapped to UNEXPECTED_RESPONSE and may signal a real endpoint/routing regression; downgrading those to DEBUG would hide genuine failures. Restrict detection to the explicit SandboxError.FILE_NOT_FOUND code (which the execd server returns for missing files) and add a regression test covering a bare 404 + UNEXPECTED_RESPONSE. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/converter/ExceptionConverter.kt | 7 ++++++- .../adapters/service/FilesystemAdapterTest.kt | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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 1dacf1dfa..2c8d287dc 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 @@ -43,11 +43,16 @@ import com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerException * Returns `true` when this throwable represents an expected "file or directory does not exist" * outcome rather than a genuine failure. * + * Detection is intentionally restricted to the explicit [SandboxError.FILE_NOT_FOUND] server + * error code rather than a bare HTTP 404. A 404 whose body cannot be parsed is mapped to + * [SandboxError.UNEXPECTED_RESPONSE] and may indicate a real endpoint/routing/configuration + * regression, which must stay loud (ERROR) instead of being silently downgraded. + * * Callers (and the adapters themselves) use this to avoid treating a missing file as an error, * e.g. logging it at ERROR level with a full stack trace, which is just noise for a perfectly * normal control-flow case such as polling for a not-yet-created file. */ -fun Throwable.isFileNotFound(): Boolean = this is SandboxApiException && (error.code == SandboxError.FILE_NOT_FOUND || statusCode == 404) +fun Throwable.isFileNotFound(): Boolean = this is SandboxApiException && error.code == SandboxError.FILE_NOT_FOUND fun Exception.toSandboxException(): SandboxException { return when (this) { diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt index fb8a8b235..7bbb95754 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt @@ -121,6 +121,20 @@ class FilesystemAdapterTest { assertFalse(exception.isFileNotFound()) } + @Test + fun `isFileNotFound is false for a 404 without an explicit FILE_NOT_FOUND code`() { + // A 404 whose body could not be parsed is mapped to UNEXPECTED_RESPONSE. It may indicate a + // real endpoint/routing regression, so it must NOT be downgraded to a not-found condition. + val exception = + SandboxApiException( + message = "Failed to read file. Status code: 404", + statusCode = 404, + error = SandboxError(SandboxError.UNEXPECTED_RESPONSE), + ) + + assertFalse(exception.isFileNotFound()) + } + @Test fun `isFileNotFound is false for non-sandbox exceptions`() { assertFalse(RuntimeException("boom").isFileNotFound())