//! One request whose only question is whether the host answered. //! //! Every other client here asks a service for something and fails if it does //! not get it. This one asks nothing: it sends a GET, times how long the //! headers took to come back, and reports what arrived — a status code that //! may be a refusal, a body that may not be JSON, and neither of those being //! a failure. Only a host that never answered at all is an `Err`. //! //! That inversion is the whole module. `atgc doctor remote` exists to tell //! "the service is down" apart from "the service is up and said no", and a //! client that turns the second into an error has thrown the answer away //! before the command can read it. //! //! What is asked *of* each service is the command's decision and is written //! down there, beside the reason. This knows nothing about Tangled. use anyhow::{Context, Result}; use std::time::{Duration, Instant}; /// What a service said when it was asked whether it was there. #[derive(Debug)] pub struct Answer { /// Whatever came back, including a refusal. A 404 from a router is an /// answer, and a service that routes is a service that is running. pub status: reqwest::StatusCode, /// Time to the response headers, not to the last byte. The question is /// how quickly the service began to answer; a front page that is 200 KB /// of HTML would otherwise read as a slower service than one that /// answers `{}`. pub elapsed: Duration, /// The response body, when it parsed as JSON. `None` for a rendered page /// or an empty body, which is not a fault — see the module docs. pub body: Option, } impl Answer { /// The version the service reports, when it reports one. /// /// `version` is the field both halves of the convention agree on: a /// PDS's `_health`, Tangled's appview `_health`, and a knot's /// `sh.tangled.knot.version` all answer with one. Read here rather than /// at three call sites because the field name is the shared part. pub fn version(&self) -> Option<&str> { self.body.as_ref()?["version"].as_str() } /// A string list under `key`, for the answers that carry one — a knot's /// `capabilities`, so far. Empty when the field is absent or is not a /// list of strings, which is how a knot too old to declare them reads. pub fn strings(&self, key: &str) -> Vec { let Some(body) = self.body.as_ref() else { return Vec::new(); }; body[key] .as_array() .map(|items| { items .iter() .filter_map(|item| item.as_str().map(str::to_string)) .collect() }) .unwrap_or_default() } } /// GET `url` and report how it went, treating any answer as an answer. /// /// `Err` means the host never answered — DNS, connect, TLS, or a stall past /// [`crate::clients::http`]'s deadline — which is the one outcome that is /// genuinely about reachability. [`crate::exit::classify`] reads the right /// exit status straight off it. pub async fn get(url: &str) -> Result { crate::logging::debug::log(format!(">> GET {url}")); let started = Instant::now(); let resp = crate::clients::http::get(url) .await .with_context(|| format!("could not reach {url}"))?; let elapsed = started.elapsed(); let status = resp.status(); // Bounded like every other body atgc reads from a host it does not own. // A probe that answered with a gigabyte would otherwise be a probe that // hung, which is the failure this module exists to report rather than // suffer. let text = crate::clients::http::text_bounded(resp, url).await?; crate::logging::debug::log(format!( "<< {status} ({} bytes) in {}ms", text.len(), elapsed.as_millis() )); Ok(Answer { status, elapsed, body: serde_json::from_str(&text).ok(), }) } #[cfg(test)] mod tests { use super::*; fn answer(body: serde_json::Value) -> Answer { Answer { status: reqwest::StatusCode::OK, elapsed: Duration::from_millis(1), body: Some(body), } } /// The two fields read off a body, and the shapes that must not panic. /// /// Both readers index into a `serde_json::Value` that came off the wire, /// so every one of these is a real possibility rather than a hypothetical /// — a service can answer `{}`, answer with an array, or answer with /// `version` as a number, and none of those may take the report down. #[test] fn a_missing_or_wrongly_typed_field_reads_as_absent() { let full = answer(serde_json::json!({ "version": "v1.15.0", "capabilities": ["knot-acl", "repo-did-input"], })); assert_eq!(full.version(), Some("v1.15.0")); assert_eq!(full.strings("capabilities"), ["knot-acl", "repo-did-input"]); // A knot too old to declare capabilities, which the lexicon says to // treat as legacy rather than as broken. let bare = answer(serde_json::json!({"version": "v1.0.0"})); assert!(bare.strings("capabilities").is_empty()); // Nothing recognisable, in the four ways it comes. let empty = answer(serde_json::json!({})); assert_eq!(empty.version(), None); assert!(empty.strings("capabilities").is_empty()); let wrong_type = answer(serde_json::json!({"version": 15, "capabilities": "yes"})); assert_eq!(wrong_type.version(), None); assert!(wrong_type.strings("capabilities").is_empty()); let not_an_object = answer(serde_json::json!([1, 2, 3])); assert_eq!(not_an_object.version(), None); // A rendered page, which is what the appview answers with. let html = Answer { status: reqwest::StatusCode::OK, elapsed: Duration::from_millis(1), body: None, }; assert_eq!(html.version(), None); assert!(html.strings("capabilities").is_empty()); } }