OXYGENHUB

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