The runner tries hub requests again after server errors 1b2229dab1a4
A hub that answers 5xx committed nothing, so log appends, claims and finishes are tried again after 1, 2 and 4 seconds instead of dropping the batch; a refusal (4xx) is final. Seen on Earth: "database is locked" answers while a release build saturated the disk lost log lines. closes #8
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 00:03 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1b2229dab1a4459b7c3c889b9b1a1322699bd3b47b89e508dbba7e9ca04bd820- Depends on
- bb643c252515 e0918b887850
- Depended on by
- 0d663e91c598 cedd12342afa 06a4c472b385 35f7a849bcc5 89171fca079c f53e6d47b56a 280ef951d193 3007d8f011fa f98a8f7881e0 f3b4fdeb2bc2
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 6 atoms, 0 tree operations, 1 file
Browse the tree at this change
modified crates/flow/src/runner.rs +66 −5
| … | 25 unchanged lines |
||
| 26 | 26 | Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent }) |
|
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | + | /// POST to the hub. A server error (5xx) means nothing was committed, |
|
| 30 | + | /// so it is tried again after 1, 2 and 4 seconds before giving up: a |
|
| 31 | + | /// busy hub costs a job a few seconds, not its log lines or its claim. |
|
| 29 | 32 | fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> { |
|
| 33 | + | self.post_with(path, body, &RETRY_DELAYS) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> { |
|
| 30 | 37 | let url = format!("{}/api/v1{path}", self.base); |
|
| 31 | - | match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) { |
|
| 32 | - | Ok(r) if r.status() == 204 => Ok(None), |
|
| 33 | - | Ok(r) => Ok(Some(r)), |
|
| 34 | - | Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
|
| 35 | - | Err(e) => bail!("{url}: {e}"), |
|
| 38 | + | let mut delays = delays.iter(); |
|
| 39 | + | loop { |
|
| 40 | + | match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) { |
|
| 41 | + | Ok(r) if r.status() == 204 => return Ok(None), |
|
| 42 | + | Ok(r) => return Ok(Some(r)), |
|
| 43 | + | Err(ureq::Error::Status(code, r)) => { |
|
| 44 | + | let text = r.into_string().unwrap_or_default(); |
|
| 45 | + | match delays.next() { |
|
| 46 | + | Some(d) if code >= 500 => std::thread::sleep(*d), |
|
| 47 | + | _ => bail!("{url}: HTTP {code}: {text}"), |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | Err(e) => bail!("{url}: {e}"), |
|
| 51 | + | } |
|
| 36 | 52 | } |
|
| 37 | 53 | } |
|
| 38 | 54 | ||
| … | 14 unchanged lines |
||
| 53 | 69 | Ok(()) |
|
| 54 | 70 | } |
|
| 55 | 71 | } |
|
| 72 | + | ||
| 73 | + | /// Waits before trying a hub request again after a server error. |
|
| 74 | + | const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)]; |
|
| 56 | 75 | ||
| 57 | 76 | /// What the runner may run at once (flow.toml `[runner]`): at most |
|
| 58 | 77 | /// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`. |
|
| … | 239 unchanged lines |
||
| 298 | 317 | #[cfg(test)] |
|
| 299 | 318 | mod tests { |
|
| 300 | 319 | use super::*; |
|
| 320 | + | ||
| 321 | + | /// A hub that answers each request with the next of `codes`, counting them. |
|
| 322 | + | fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
|
| 323 | + | use std::io::{Read, Write}; |
|
| 324 | + | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
|
| 325 | + | let addr = listener.local_addr().unwrap(); |
|
| 326 | + | let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
|
| 327 | + | let count = seen.clone(); |
|
| 328 | + | std::thread::spawn(move || { |
|
| 329 | + | for code in codes { |
|
| 330 | + | let (mut s, _) = listener.accept().unwrap(); |
|
| 331 | + | let mut buf = [0u8; 8192]; |
|
| 332 | + | let _ = s.read(&mut buf); |
|
| 333 | + | count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
|
| 334 | + | let reason = if code == 204 { "No Content" } else { "Error" }; |
|
| 335 | + | let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); |
|
| 336 | + | } |
|
| 337 | + | }); |
|
| 338 | + | (format!("http://{addr}"), seen) |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | fn hub_at(base: String) -> Hub { |
|
| 342 | + | Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() } |
|
| 343 | + | } |
|
| 344 | + | ||
| 345 | + | #[test] |
|
| 346 | + | fn server_errors_are_tried_again_and_client_errors_are_not() { |
|
| 347 | + | let quick = [Duration::from_millis(10); 3]; |
|
| 348 | + | let (base, seen) = fake_hub(vec![500, 503, 204]); |
|
| 349 | + | let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap(); |
|
| 350 | + | assert!(r.is_none()); |
|
| 351 | + | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted"); |
|
| 352 | + | ||
| 353 | + | let (base, seen) = fake_hub(vec![500, 500, 500, 500]); |
|
| 354 | + | let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string(); |
|
| 355 | + | assert!(e.contains("HTTP 500"), "{e}"); |
|
| 356 | + | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay"); |
|
| 357 | + | ||
| 358 | + | let (base, seen) = fake_hub(vec![401]); |
|
| 359 | + | assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err()); |
|
| 360 | + | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
|
| 361 | + | } |
|
| 301 | 362 | ||
| 302 | 363 | #[test] |
|
| 303 | 364 | fn the_budget_admits_jobs_that_fit() { |
|
| … | 13 unchanged lines |