Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ 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.
*
* 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

fun Exception.toSandboxException(): SandboxException {
return when (this) {
is SandboxException -> this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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<SandboxApiException> {
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 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())
}
}
Loading