| 71 | 71 | | CANCEL.take(); |
| 72 | 72 | | } |
| 73 | 73 | | |
| 74 | + | /// How many times a spec is asked for before the job fails. |
| 75 | + | const SPEC_TRIES: u32 = 4; |
| 76 | + | |
| 77 | + | /// How long to wait before asking for a spec again, after `attempt` tries: |
| 78 | + | /// 1, 2, then 4 seconds. |
| 79 | + | fn spec_backoff(attempt: u32) -> Duration { |
| 80 | + | Duration::from_secs(1 << (attempt - 1).min(2)) |
| 81 | + | } |
| 82 | + | |
| 83 | + | /// Why a spec did not arrive. |
| 84 | + | #[derive(Debug)] |
| 85 | + | enum SpecError { |
| 86 | + | /// g1t answered with this status. |
| 87 | + | Status(u16), |
| 88 | + | /// The request could not be made or answered (connection, timeout). |
| 89 | + | Transport(String), |
| 90 | + | /// The answer's body did not read as JSON: cut short, usually. |
| 91 | + | Body(std::io::Error), |
| 92 | + | } |
| 93 | + | |
| 94 | + | impl SpecError { |
| 95 | + | /// Whether asking again may get the spec: not when g1t said no. |
| 96 | + | fn worth_retrying(&self) -> bool { |
| 97 | + | match self { |
| 98 | + | SpecError::Status(code) => *code >= 500, |
| 99 | + | SpecError::Transport(_) | SpecError::Body(_) => true, |
| 100 | + | } |
| 101 | + | } |
| 102 | + | } |
| 103 | + | |
| 104 | + | impl From<ureq::Error> for SpecError { |
| 105 | + | fn from(error: ureq::Error) -> Self { |
| 106 | + | match error { |
| 107 | + | ureq::Error::Status(code, _) => SpecError::Status(code), |
| 108 | + | ureq::Error::Transport(transport) => SpecError::Transport(transport.to_string()), |
| 109 | + | } |
| 110 | + | } |
| 111 | + | } |
| 112 | + | |
| 113 | + | impl std::fmt::Display for SpecError { |
| 114 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 115 | + | match self { |
| 116 | + | SpecError::Status(code) => write!(f, "g1t answered {code}"), |
| 117 | + | SpecError::Transport(error) => write!(f, "{error}"), |
| 118 | + | SpecError::Body(error) => write!(f, "Failed to read JSON: {error}"), |
| 119 | + | } |
| 120 | + | } |
| 121 | + | } |
| 122 | + | |
| 123 | + | impl std::error::Error for SpecError {} |
| 124 | + | |
| 74 | 125 | | #[derive(Clone)] |
| 75 | 126 | | pub(crate) struct Api { |
| 76 | 127 | | pub(crate) base: String, |
| ⋯ |
| 88 | 139 | | /// With `broker`, this harness passes its job's requests to brokered |
| 89 | 140 | | /// secrets' hosts through g1t (broker.rs), and the spec lists each |
| 90 | 141 | | /// stand-in's hosts (`brokered`). |
| 142 | + | /// |
| 143 | + | /// A spec that does not arrive whole (the connection drops, or the body |
| 144 | + | /// ends early) or that g1t could not make (5xx) is asked for again, up |
| 145 | + | /// to [`SPEC_TRIES`] times: Deploy #449's job failed on one truncated |
| 146 | + | /// answer. Not with `rotate`: g1t spends the token on the first ask, |
| 147 | + | /// whether or not the answer arrived, so a second would be refused. |
| 91 | 148 | | pub(crate) fn spec(&mut self, rotate: bool, broker: bool) -> Result<Value> { |
| 92 | 149 | | let mut body = json!({ "token": self.token }); |
| 93 | 150 | | if rotate { |
| ⋯ |
| 96 | 153 | | if broker { |
| 97 | 154 | | body["broker"] = json!(true); |
| 98 | 155 | | } |
| 99 | | − | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?; |
| 100 | | − | let mut spec: Value = response.into_json()?; |
| 156 | + | let url = format!("{}/actions/jobs/{}/spec", self.base, self.job); |
| 157 | + | let tries = if rotate { 1 } else { SPEC_TRIES }; |
| 158 | + | let mut attempt = 1; |
| 159 | + | let mut spec: Value = loop { |
| 160 | + | let fetched = ureq::post(&url) |
| 161 | + | .timeout(Duration::from_secs(60)) |
| 162 | + | .send_json(body.clone()) |
| 163 | + | .map_err(SpecError::from) |
| 164 | + | .and_then(|response| response.into_json::<Value>().map_err(SpecError::Body)); |
| 165 | + | match fetched { |
| 166 | + | Ok(spec) => break spec, |
| 167 | + | Err(error) if attempt < tries && error.worth_retrying() => { |
| 168 | + | eprintln!("g1t-runner: the job's spec did not arrive whole ({error}); asking again"); |
| 169 | + | std::thread::sleep(spec_backoff(attempt)); |
| 170 | + | attempt += 1; |
| 171 | + | } |
| 172 | + | Err(error) => return Err(error.into()), |
| 173 | + | } |
| 174 | + | }; |
| 101 | 175 | | self.take_next_token(&mut spec); |
| 102 | 176 | | Ok(spec) |
| 103 | 177 | | } |
| ⋯ |
| 469 | 543 | | mod tests { |
| 470 | 544 | | use super::*; |
| 471 | 545 | | |
| 546 | + | /// A g1t on 127.0.0.1 that answers each spec request with the next of |
| 547 | + | /// `answers` (a body, and whether to cut it short), counting the asks. |
| 548 | + | fn serve(answers: Vec<(&'static str, bool)>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) { |
| 549 | + | use std::io::{Read, Write}; |
| 550 | + | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 551 | + | let base = format!("http://{}", listener.local_addr().unwrap()); |
| 552 | + | let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| 553 | + | let count = asked.clone(); |
| 554 | + | std::thread::spawn(move || { |
| 555 | + | for (body, cut) in answers { |
| 556 | + | let Ok((mut stream, _)) = listener.accept() else { return }; |
| 557 | + | let mut request = [0u8; 4096]; |
| 558 | + | let _ = stream.read(&mut request); |
| 559 | + | count.fetch_add(1, Ordering::SeqCst); |
| 560 | + | // Cut short: says the whole length, sends half, and hangs up. |
| 561 | + | let sent = if cut { &body[..body.len() / 2] } else { body }; |
| 562 | + | let head = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); |
| 563 | + | let _ = stream.write_all(head.as_bytes()); |
| 564 | + | let _ = stream.write_all(sent.as_bytes()); |
| 565 | + | } |
| 566 | + | }); |
| 567 | + | (base, asked) |
| 568 | + | } |
| 569 | + | |
| 570 | + | #[test] |
| 571 | + | fn a_spec_cut_short_is_asked_for_again() { |
| 572 | + | let body = r#"{"job":"job_1","masks":[],"steps":[{"run":"cargo test"}]}"#; |
| 573 | + | let (base, asked) = serve(vec![(body, true), (body, false)]); |
| 574 | + | let mut api = Api { base, job: "job_1".into(), token: "t".into() }; |
| 575 | + | let spec = api.spec(false, false).unwrap(); |
| 576 | + | assert_eq!(spec["steps"][0]["run"], "cargo test"); |
| 577 | + | assert_eq!(asked.load(Ordering::SeqCst), 2); |
| 578 | + | } |
| 579 | + | |
| 580 | + | #[test] |
| 581 | + | fn a_spec_that_spends_its_token_is_asked_for_once() { |
| 582 | + | let body = r#"{"job":"job_1","masks":[],"next_token":"fresh"}"#; |
| 583 | + | let (base, asked) = serve(vec![(body, true), (body, false)]); |
| 584 | + | let mut api = Api { base, job: "job_1".into(), token: "t".into() }; |
| 585 | + | let error = api.spec(true, false).unwrap_err().to_string(); |
| 586 | + | assert!(error.starts_with("Failed to read JSON"), "{error}"); |
| 587 | + | assert_eq!(asked.load(Ordering::SeqCst), 1); |
| 588 | + | } |
| 589 | + | |
| 590 | + | #[test] |
| 591 | + | fn only_what_may_arrive_next_time_is_asked_again() { |
| 592 | + | assert!(SpecError::Status(502).worth_retrying()); |
| 593 | + | assert!(!SpecError::Status(403).worth_retrying()); |
| 594 | + | assert!(!SpecError::Status(404).worth_retrying()); |
| 595 | + | assert!(SpecError::Transport("connection reset".into()).worth_retrying()); |
| 596 | + | assert_eq!((spec_backoff(1), spec_backoff(2), spec_backoff(3)), (Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4))); |
| 597 | + | } |
| 598 | + | |
| 472 | 599 | | #[test] |
| 473 | 600 | | fn the_token_a_spec_carries_replaces_the_one_it_was_fetched_with() { |
| 474 | 601 | | let mut api = Api::offline(); |