Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149//! 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<serde_json::Value>,}
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<String> { 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<Answer> { 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()); }}