use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::{debug, info, instrument, warn};
use crate::{
error::SandboxError,
traits::{ExecutionResult, Sandbox, SandboxArtifact},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteRequest {
pub code: String,
pub language: Option<String>,
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteResponse {
pub success: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub execution_time_ms: u64,
#[serde(default)]
pub artifacts: Vec<ArtifactResponse>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactResponse {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub firecracker_available: bool,
pub kvm_available: bool,
pub version: String,
}
#[derive(Debug, Clone)]
pub struct RemoteSandboxConfig {
pub base_url: String,
pub api_key: Option<String>,
pub timeout_ms: u64,
pub default_language: Option<String>,
pub max_retries: u32,
}
impl Default for RemoteSandboxConfig {
fn default() -> Self {
Self {
base_url: "http://localhost:8080".into(),
api_key: None,
timeout_ms: 30000,
default_language: None,
max_retries: 2,
}
}
}
#[derive(Clone)]
pub struct RemoteSandbox {
config: RemoteSandboxConfig,
client: Client,
}
impl RemoteSandbox {
#[must_use]
pub fn builder(base_url: impl Into<String>) -> RemoteSandboxBuilder {
RemoteSandboxBuilder::new(base_url.into())
}
#[must_use]
pub fn with_config(config: RemoteSandboxConfig) -> Self {
let http_timeout = config.timeout_ms + 30_000; let client = Client::builder()
.timeout(Duration::from_millis(http_timeout))
.build()
.expect("Failed to create HTTP client");
Self { config, client }
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.config.base_url
}
pub async fn health(&self) -> Result<HealthResponse, SandboxError> {
let url = format!("{}/health", self.config.base_url);
let mut request = self.client.get(&url);
if let Some(ref key) = self.config.api_key {
request = request.header("X-API-Key", key);
}
let response = request
.send()
.await
.map_err(|e| SandboxError::ConnectionFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(SandboxError::ConnectionFailed(format!(
"Health check failed: {}",
response.status()
)));
}
response
.json::<HealthResponse>()
.await
.map_err(|e| SandboxError::ConnectionFailed(e.to_string()))
}
async fn execute_with_retry(&self, code: &str) -> Result<ExecutionResult, SandboxError> {
let mut last_error = None;
for attempt in 0..=self.config.max_retries {
if attempt > 0 {
debug!("Retry attempt {}/{}", attempt, self.config.max_retries);
tokio::time::sleep(Duration::from_millis(500 * u64::from(attempt))).await;
}
match self.execute_once(code).await {
Ok(result) => return Ok(result),
Err(e) => {
warn!("Execution attempt {} failed: {}", attempt + 1, e);
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| SandboxError::ExecutionFailed("Unknown error".into())))
}
async fn execute_once(&self, code: &str) -> Result<ExecutionResult, SandboxError> {
let url = format!("{}/execute", self.config.base_url);
let request_body = ExecuteRequest {
code: code.to_string(),
language: self.config.default_language.clone(),
timeout_ms: self.config.timeout_ms,
};
let mut request = self.client.post(&url).json(&request_body);
if let Some(ref key) = self.config.api_key {
request = request.header("X-API-Key", key);
}
let response = request
.send()
.await
.map_err(|e| SandboxError::ConnectionFailed(e.to_string()))?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(SandboxError::ConnectionFailed("Invalid API key".into()));
}
if response.status() == reqwest::StatusCode::REQUEST_TIMEOUT {
return Err(SandboxError::Timeout);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SandboxError::ExecutionFailed(format!(
"Server error {status}: {body}"
)));
}
let exec_response: ExecuteResponse = response
.json()
.await
.map_err(|e| SandboxError::ExecutionFailed(e.to_string()))?;
if let Some(error) = exec_response.error {
return Err(SandboxError::ExecutionFailed(error));
}
let artifacts = exec_response
.artifacts
.into_iter()
.map(|a| SandboxArtifact {
path: a.path,
content: a.content,
})
.collect();
Ok(ExecutionResult {
exit_code: exec_response.exit_code,
stdout: exec_response.stdout,
stderr: exec_response.stderr,
execution_time_ms: exec_response.execution_time_ms,
artifacts,
})
}
}
impl std::fmt::Debug for RemoteSandbox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteSandbox")
.field("base_url", &self.config.base_url)
.field("timeout_ms", &self.config.timeout_ms)
.finish_non_exhaustive()
}
}
#[async_trait]
impl Sandbox for RemoteSandbox {
#[instrument(skip(self, code), fields(sandbox = "remote", url = %self.config.base_url))]
async fn execute(&self, code: &str) -> Result<ExecutionResult, SandboxError> {
info!("Executing code on remote Firecracker server");
debug!("Code: {}...", &code[..code.len().min(100)]);
self.execute_with_retry(code).await
}
async fn is_ready(&self) -> Result<bool, SandboxError> {
match self.health().await {
Ok(health) => Ok(health.firecracker_available),
Err(_) => Ok(false),
}
}
async fn stop(&self) -> Result<(), SandboxError> {
Ok(())
}
}
pub struct RemoteSandboxBuilder {
config: RemoteSandboxConfig,
}
impl RemoteSandboxBuilder {
fn new(base_url: String) -> Self {
Self {
config: RemoteSandboxConfig {
base_url,
..Default::default()
},
}
}
#[must_use]
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.config.api_key = Some(key.into());
self
}
#[must_use]
pub const fn timeout_ms(mut self, ms: u64) -> Self {
self.config.timeout_ms = ms;
self
}
#[must_use]
pub fn language(mut self, lang: impl Into<String>) -> Self {
self.config.default_language = Some(lang.into());
self
}
#[must_use]
pub const fn max_retries(mut self, n: u32) -> Self {
self.config.max_retries = n;
self
}
#[must_use]
pub fn build(self) -> RemoteSandbox {
RemoteSandbox::with_config(self.config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder() {
let sandbox = RemoteSandbox::builder("http://localhost:8080")
.api_key("test-key")
.timeout_ms(5000)
.language("rust")
.max_retries(3)
.build();
assert_eq!(sandbox.config.base_url, "http://localhost:8080");
assert_eq!(sandbox.config.api_key, Some("test-key".into()));
assert_eq!(sandbox.config.timeout_ms, 5000);
assert_eq!(sandbox.config.default_language, Some("rust".into()));
assert_eq!(sandbox.config.max_retries, 3);
}
#[test]
fn test_default_config() {
let config = RemoteSandboxConfig::default();
assert_eq!(config.base_url, "http://localhost:8080");
assert!(config.api_key.is_none());
assert_eq!(config.timeout_ms, 30000);
}
}