feat(sandbox): add retry logic for transient container failures (#1232)

* feat(sandbox): add retry logic for transient container failures (#1224)

SandboxManager::execute_with_policy() had no retry logic. Transient Docker
errors (daemon temporarily unavailable, container creation race conditions,
container start failures) caused immediate job failure.

Adds up to 2 retries (3 total attempts) with exponential backoff (2s, 4s)
for transient error types only:
- DockerNotAvailable
- ContainerCreationFailed
- ContainerStartFailed

Non-transient errors (Timeout, ExecutionFailed, NetworkBlocked, Config)
are returned immediately without retry.

Container cleanup on retry is safe: ContainerRunner::execute() always
force-removes the container before returning.

Closes #1224

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-16 07:53:45 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 596d17f04b
commit 0c31da46e7
+100 -3
View File
@@ -236,14 +236,59 @@ impl SandboxManager {
self.initialize().await?; self.initialize().await?;
} }
// Get proxy port if running // Retry transient container failures (Docker daemon glitches, container
// creation races) up to MAX_SANDBOX_RETRIES times with exponential backoff.
const MAX_SANDBOX_RETRIES: u32 = 2;
let mut last_err: Option<SandboxError> = None;
for attempt in 0..=MAX_SANDBOX_RETRIES {
if attempt > 0 {
let delay = std::time::Duration::from_secs(1 << attempt); // 2s, 4s
tracing::warn!(
attempt = attempt + 1,
max_attempts = MAX_SANDBOX_RETRIES + 1,
delay_secs = delay.as_secs(),
"Retrying sandbox execution after transient failure"
);
tokio::time::sleep(delay).await;
}
match self
.try_execute_in_container(command, cwd, policy, env.clone())
.await
{
Ok(output) => return Ok(output),
Err(e) if is_transient_sandbox_error(&e) => {
tracing::warn!(
attempt = attempt + 1,
error = %e,
"Transient sandbox error, will retry"
);
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| SandboxError::ExecutionFailed {
reason: "all retry attempts exhausted".to_string(),
}))
}
/// Single attempt at container execution (no retry logic).
async fn try_execute_in_container(
&self,
command: &str,
cwd: &Path,
policy: SandboxPolicy,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
let proxy_port = if let Some(proxy) = self.proxy.read().await.as_ref() { let proxy_port = if let Some(proxy) = self.proxy.read().await.as_ref() {
proxy.addr().await.map(|a| a.port()).unwrap_or(0) proxy.addr().await.map(|a| a.port()).unwrap_or(0)
} else { } else {
0 0
}; };
// Reuse the stored Docker connection, create a runner with the current proxy port
let docker = let docker =
self.docker self.docker
.read() .read()
@@ -262,7 +307,6 @@ impl SandboxManager {
}; };
let container_output = runner.execute(command, cwd, policy, &limits, env).await?; let container_output = runner.execute(command, cwd, policy, &limits, env).await?;
Ok(container_output.into()) Ok(container_output.into())
} }
@@ -373,6 +417,20 @@ impl Drop for SandboxManager {
} }
} }
/// Check whether a sandbox error is transient and worth retrying.
///
/// Transient errors are those caused by Docker daemon glitches, container
/// creation race conditions, or container start failures — not by command
/// execution failures, timeouts, or policy violations.
fn is_transient_sandbox_error(err: &SandboxError) -> bool {
matches!(
err,
SandboxError::DockerNotAvailable { .. }
| SandboxError::ContainerCreationFailed { .. }
| SandboxError::ContainerStartFailed { .. }
)
}
/// Builder for creating a sandbox manager. /// Builder for creating a sandbox manager.
pub struct SandboxManagerBuilder { pub struct SandboxManagerBuilder {
config: SandboxConfig, config: SandboxConfig,
@@ -597,4 +655,43 @@ mod tests {
assert!(output.truncated); assert!(output.truncated);
assert!(output.stdout.len() <= 32 * 1024); assert!(output.stdout.len() <= 32 * 1024);
} }
#[test]
fn transient_errors_are_retryable() {
assert!(super::is_transient_sandbox_error(
&SandboxError::DockerNotAvailable {
reason: "daemon restarting".to_string()
}
));
assert!(super::is_transient_sandbox_error(
&SandboxError::ContainerCreationFailed {
reason: "image pull glitch".to_string()
}
));
assert!(super::is_transient_sandbox_error(
&SandboxError::ContainerStartFailed {
reason: "cgroup race".to_string()
}
));
}
#[test]
fn non_transient_errors_are_not_retryable() {
assert!(!super::is_transient_sandbox_error(&SandboxError::Timeout(
std::time::Duration::from_secs(30)
)));
assert!(!super::is_transient_sandbox_error(
&SandboxError::ExecutionFailed {
reason: "exit code 1".to_string()
}
));
assert!(!super::is_transient_sandbox_error(
&SandboxError::NetworkBlocked {
reason: "policy violation".to_string()
}
));
assert!(!super::is_transient_sandbox_error(&SandboxError::Config {
reason: "bad config".to_string()
}));
}
} }