1use std::time::Duration;
17
18use serde::Serialize;
19use thiserror::Error;
20use tokio::time::sleep;
21
22pub const VERSION: &str = "0.4.0";
24
25pub const DEFAULT_BASE_URL: &str = "https://api.rustbox.sh";
27
28const DEFAULT_TIMEOUT: Duration = Duration::from_secs(65);
29const DEFAULT_MAX_RETRIES: u32 = 2;
30
31#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
37#[serde(rename_all = "lowercase")]
38pub enum Profile {
39 Judge,
40 Agent,
41}
42
43#[derive(Serialize, Debug, Clone, Default)]
44pub struct SubmitRequest {
45 pub language: String,
46 pub code: String,
47 pub stdin: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub profile: Option<Profile>,
51}
52
53#[derive(Debug, Error)]
55pub enum RustboxError {
56 #[error("api_key required")]
57 MissingApiKey,
58 #[error("invalid base_url")]
59 InvalidBaseUrl,
60 #[error("invalid or missing API key (HTTP {0})")]
61 Auth(u16),
62 #[error("rate limit exceeded (HTTP 429)")]
63 RateLimit,
64 #[error("server error (HTTP {0})")]
65 Server(u16),
66 #[error("API error (HTTP {status}): {body}")]
67 Api { status: u16, body: String },
68 #[error("request timed out")]
69 Timeout,
70 #[error(transparent)]
71 Transport(#[from] reqwest::Error),
72 #[error("response decode failed: {0}")]
73 Decode(String),
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct SubmitOptions {
79 pub idempotency_key: Option<String>,
81}
82
83pub struct Rustbox {
84 api_key: String,
85 base_url: String,
86 client: reqwest::Client,
87 max_retries: u32,
88}
89
90impl Rustbox {
91 pub fn new(api_key: &str) -> Result<Self, RustboxError> {
94 if api_key.is_empty() {
95 return Err(RustboxError::MissingApiKey);
96 }
97 let client = reqwest::Client::builder()
98 .timeout(DEFAULT_TIMEOUT)
99 .build()
100 .map_err(RustboxError::Transport)?;
101 Ok(Self {
102 api_key: api_key.to_string(),
103 base_url: DEFAULT_BASE_URL.to_string(),
104 client,
105 max_retries: DEFAULT_MAX_RETRIES,
106 })
107 }
108
109 pub fn with_base_url(mut self, base_url: &str) -> Result<Self, RustboxError> {
112 if base_url.is_empty() {
113 return Err(RustboxError::InvalidBaseUrl);
114 }
115 self.base_url = base_url.trim_end_matches('/').to_string();
116 Ok(self)
117 }
118
119 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, RustboxError> {
121 let mut builder = reqwest::Client::builder();
122 if !timeout.is_zero() {
123 builder = builder.timeout(timeout);
124 }
125 self.client = builder.build().map_err(RustboxError::Transport)?;
126 Ok(self)
127 }
128
129 pub fn with_max_retries(mut self, n: u32) -> Self {
131 self.max_retries = n;
132 self
133 }
134
135 pub fn base_url(&self) -> &str {
136 &self.base_url
137 }
138
139 fn backoff_delay(&self, attempt: u32) -> Duration {
140 Duration::from_millis((100u64 * (1u64 << attempt.min(8))).min(5_000))
141 }
142
143 async fn send_with_retry(
144 &self,
145 build: impl Fn() -> reqwest::RequestBuilder,
146 ) -> Result<reqwest::Response, RustboxError> {
147 let mut last_err: Option<RustboxError> = None;
148 for attempt in 0..=self.max_retries {
149 let req = build()
150 .header("X-API-Key", &self.api_key)
151 .header("User-Agent", format!("rustbox-sdk-rust/{VERSION}"));
152 match req.send().await {
153 Ok(resp) => {
154 if resp.status().as_u16() >= 500 && attempt < self.max_retries {
155 sleep(self.backoff_delay(attempt)).await;
156 continue;
157 }
158 return Ok(resp);
159 }
160 Err(e) => {
161 let is_timeout = e.is_timeout();
162 last_err = Some(if is_timeout {
163 RustboxError::Timeout
164 } else {
165 RustboxError::Transport(e)
166 });
167 if attempt >= self.max_retries {
168 return Err(last_err.unwrap());
169 }
170 sleep(self.backoff_delay(attempt)).await;
171 }
172 }
173 }
174 Err(last_err.unwrap_or(RustboxError::Decode("retry exhausted".into())))
175 }
176
177 async fn handle(&self, resp: reqwest::Response) -> Result<serde_json::Value, RustboxError> {
178 let status = resp.status();
179 let code = status.as_u16();
180 if status.is_success() || code == 408 {
181 return resp
182 .json()
183 .await
184 .map_err(|e| RustboxError::Decode(e.to_string()));
185 }
186 match code {
187 401 | 403 => Err(RustboxError::Auth(code)),
188 429 => Err(RustboxError::RateLimit),
189 500..=599 => Err(RustboxError::Server(code)),
190 _ => {
191 let body = resp.text().await.unwrap_or_default();
192 Err(RustboxError::Api { status: code, body })
193 }
194 }
195 }
196
197 pub async fn submit(
198 &self,
199 req: &SubmitRequest,
200 wait: bool,
201 opts: SubmitOptions,
202 ) -> Result<serde_json::Value, RustboxError> {
203 let url = format!("{}/api/submit?wait={}", self.base_url, wait);
204 let body = serde_json::to_vec(req).map_err(|e| RustboxError::Decode(e.to_string()))?;
205
206 let resp = self
207 .send_with_retry(|| {
208 let mut rb = self
209 .client
210 .post(&url)
211 .header("Content-Type", "application/json")
212 .body(body.clone());
213 if let Some(ref key) = opts.idempotency_key {
214 rb = rb.header("Idempotency-Key", key);
215 }
216 rb
217 })
218 .await?;
219 self.handle(resp).await
220 }
221
222 pub async fn get_result(&self, id: &str) -> Result<serde_json::Value, RustboxError> {
223 let url = format!("{}/api/result/{}", self.base_url, id);
224 let resp = self.send_with_retry(|| self.client.get(&url)).await?;
225 self.handle(resp).await
226 }
227
228 pub async fn get_languages(&self) -> Result<Vec<String>, RustboxError> {
229 let url = format!("{}/api/languages", self.base_url);
230 let resp = self.send_with_retry(|| self.client.get(&url)).await?;
231 let val = self.handle(resp).await?;
232 serde_json::from_value(val).map_err(|e| RustboxError::Decode(e.to_string()))
233 }
234
235 pub async fn get_health(&self) -> Result<serde_json::Value, RustboxError> {
236 let url = format!("{}/api/health", self.base_url);
237 let resp = self.send_with_retry(|| self.client.get(&url)).await?;
238 self.handle(resp).await
239 }
240
241 pub async fn get_ready(&self) -> Result<serde_json::Value, RustboxError> {
242 let url = format!("{}/api/health/ready", self.base_url);
243 let resp = self.send_with_retry(|| self.client.get(&url)).await?;
244 self.handle(resp).await
245 }
246
247 pub async fn run(&self, req: &SubmitRequest) -> Result<serde_json::Value, RustboxError> {
250 let opts = SubmitOptions {
251 idempotency_key: Some(idempotency_id()),
252 };
253 let mut res = self.submit(req, true, opts).await?;
254 if res
255 .pointer("/result/verdict")
256 .and_then(|v| v.as_str())
257 .is_some()
258 {
259 return Ok(res);
260 }
261
262 let id = match res.get("id").and_then(|v| v.as_str()) {
263 Some(i) => i.to_string(),
264 None => return Ok(res),
265 };
266
267 for i in 0..45 {
268 let delay_ms = (40.0 * (1.5_f64).powi(i)).min(600.0) as u64;
269 sleep(Duration::from_millis(delay_ms)).await;
270
271 res = self.get_result(&id).await?;
272 if res
273 .pointer("/result/verdict")
274 .and_then(|v| v.as_str())
275 .is_some()
276 {
277 return Ok(res);
278 }
279 }
280 Ok(res)
281 }
282}
283
284fn idempotency_id() -> String {
285 uuid::Uuid::new_v4().to_string()
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use wiremock::matchers::{method, path};
292 use wiremock::{Mock, MockServer, ResponseTemplate};
293
294 fn req() -> SubmitRequest {
295 SubmitRequest {
296 language: "python".into(),
297 code: "print(1)".into(),
298 stdin: "".into(),
299 profile: None,
300 }
301 }
302
303 #[tokio::test]
304 async fn new_should_default_base_url_to_production() {
305 let client = Rustbox::new("k").unwrap();
306 assert_eq!(client.base_url(), DEFAULT_BASE_URL);
307 }
308
309 #[tokio::test]
310 async fn new_should_return_err_when_api_key_empty() {
311 let r = Rustbox::new("");
312 assert!(matches!(r, Err(RustboxError::MissingApiKey)));
313 }
314
315 #[tokio::test]
316 async fn with_base_url_should_override_default_and_trim_slash() {
317 let client = Rustbox::new("k")
318 .unwrap()
319 .with_base_url("https://custom.example.com/")
320 .unwrap();
321 assert_eq!(client.base_url(), "https://custom.example.com");
322 }
323
324 #[tokio::test]
325 async fn with_base_url_should_return_err_when_empty() {
326 let r = Rustbox::new("k").unwrap().with_base_url("");
327 assert!(matches!(r, Err(RustboxError::InvalidBaseUrl)));
328 }
329
330 #[tokio::test]
331 async fn run_should_return_verdict_on_first_response_when_complete() {
332 let mock_server = MockServer::start().await;
333 Mock::given(method("POST"))
334 .and(path("/api/submit"))
335 .respond_with(
336 ResponseTemplate::new(200)
337 .set_body_json(serde_json::json!({"id": "1", "result": {"verdict": "AC"}})),
338 )
339 .mount(&mock_server)
340 .await;
341
342 let client = Rustbox::new("test")
343 .unwrap()
344 .with_base_url(&mock_server.uri())
345 .unwrap();
346 let res = client.run(&req()).await.unwrap();
347 assert_eq!(
348 res.pointer("/result/verdict").unwrap().as_str().unwrap(),
349 "AC"
350 );
351 }
352
353 #[tokio::test]
354 async fn run_should_poll_until_verdict_when_initial_returns_408() {
355 let mock_server = MockServer::start().await;
356 Mock::given(method("POST"))
357 .and(path("/api/submit"))
358 .respond_with(ResponseTemplate::new(408).set_body_json(serde_json::json!({"id": "1"})))
359 .mount(&mock_server)
360 .await;
361
362 Mock::given(method("GET"))
363 .and(path("/api/result/1"))
364 .respond_with(
365 ResponseTemplate::new(200)
366 .set_body_json(serde_json::json!({"id": "1", "result": {"verdict": "TLE"}})),
367 )
368 .mount(&mock_server)
369 .await;
370
371 let client = Rustbox::new("test")
372 .unwrap()
373 .with_base_url(&mock_server.uri())
374 .unwrap();
375 let res = client.run(&req()).await.unwrap();
376 assert_eq!(
377 res.pointer("/result/verdict").unwrap().as_str().unwrap(),
378 "TLE"
379 );
380 }
381
382 #[tokio::test]
383 async fn submit_should_return_auth_err_on_401() {
384 let mock_server = MockServer::start().await;
385 Mock::given(method("POST"))
386 .and(path("/api/submit"))
387 .respond_with(ResponseTemplate::new(401))
388 .mount(&mock_server)
389 .await;
390
391 let client = Rustbox::new("test")
392 .unwrap()
393 .with_base_url(&mock_server.uri())
394 .unwrap();
395 let err = client
396 .submit(&req(), false, SubmitOptions::default())
397 .await
398 .unwrap_err();
399 assert!(matches!(err, RustboxError::Auth(401)));
400 }
401
402 #[tokio::test]
403 async fn submit_should_return_rate_limit_on_429() {
404 let mock_server = MockServer::start().await;
405 Mock::given(method("POST"))
406 .and(path("/api/submit"))
407 .respond_with(ResponseTemplate::new(429))
408 .mount(&mock_server)
409 .await;
410
411 let client = Rustbox::new("test")
412 .unwrap()
413 .with_base_url(&mock_server.uri())
414 .unwrap();
415 let err = client
416 .submit(&req(), false, SubmitOptions::default())
417 .await
418 .unwrap_err();
419 assert!(matches!(err, RustboxError::RateLimit));
420 }
421
422 #[tokio::test]
423 async fn submit_should_return_server_err_on_503_after_retries() {
424 let mock_server = MockServer::start().await;
425 Mock::given(method("POST"))
426 .and(path("/api/submit"))
427 .respond_with(ResponseTemplate::new(503))
428 .mount(&mock_server)
429 .await;
430
431 let client = Rustbox::new("test")
432 .unwrap()
433 .with_base_url(&mock_server.uri())
434 .unwrap()
435 .with_max_retries(1);
436 let err = client
437 .submit(&req(), false, SubmitOptions::default())
438 .await
439 .unwrap_err();
440 assert!(matches!(err, RustboxError::Server(503)));
441 }
442
443 #[tokio::test]
444 async fn submit_should_send_user_agent_header() {
445 let mock_server = MockServer::start().await;
446 Mock::given(method("POST"))
447 .and(path("/api/submit"))
448 .and(wiremock::matchers::header_regex(
449 "user-agent",
450 r"^rustbox-sdk-rust/",
451 ))
452 .respond_with(
453 ResponseTemplate::new(200)
454 .set_body_json(serde_json::json!({"id": "1", "result": {"verdict": "AC"}})),
455 )
456 .mount(&mock_server)
457 .await;
458
459 let client = Rustbox::new("test")
460 .unwrap()
461 .with_base_url(&mock_server.uri())
462 .unwrap();
463 let res = client
464 .submit(&req(), false, SubmitOptions::default())
465 .await
466 .unwrap();
467 assert_eq!(
468 res.pointer("/result/verdict").unwrap().as_str().unwrap(),
469 "AC"
470 );
471 }
472}