This commit is contained in:
Kelorastus
2026-09-25 10:14:44 +02:00
parent 4d620fc5ad
commit 21d6e6bffb
34 changed files with 615 additions and 321 deletions
+120 -7
View File
@@ -4,32 +4,116 @@ use std::time::Duration;
use reqwest::StatusCode;
/// How long a launch waits for the site before treating the PC as offline.
/// How long a launch waits for the site before treating the PC as offline. Short on
/// purpose: a PC with no DHP at all must not sit through a stall at every start.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// The second chance, for a link that answers slowly rather than not at all. Over a
/// phone's hotspot the first HTTPS round trip to a DHP - name, connection, handshake,
/// answer - runs past two seconds often enough that the DHP read as offline while a
/// browser on the same PC reached it fine. Only a TIMEOUT is retried: a refused
/// connection or a name that does not resolve is what an absent DHP looks like, and
/// waiting ten seconds on those at every start is exactly what the short budget avoids.
const SLOW_LINK_TIMEOUT: Duration = Duration::from_secs(10);
pub struct Probe {
pub online: bool,
/// Why the DHP is not online, in one line. Without it "offline" is all the operator
/// gets, and a URL that a browser on the same PC opens fine leaves nothing to go on.
pub problem: Option<String>,
/// The site's CURRENT version.
pub version: Option<String>,
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
/// online. Absent when the DHP offers none, and this PC then serves its own data.
pub engine_url: Option<String>,
/// Every address the DHP offers for it, best first: the site's own network, then the
/// one that answers from outside. A PC cannot be told which applies to it - a laptop
/// is at the office in the morning and at home in the evening - so it tries them in
/// turn. Older DHPs name one address and this holds just that one.
pub engine_urls: Vec<String>,
}
/// How long one Engine API gets to answer before the next address is tried.
const ENGINE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Does this Engine API answer? `/health` is its open route: no token, no data.
pub async fn engine_answers(http: &reqwest::Client, engine_url: &str) -> bool {
let url = format!("{}/health", engine_url.trim_end_matches('/'));
matches!(http.get(url).timeout(ENGINE_PROBE_TIMEOUT).send().await, Ok(answer) if answer.status().is_success())
}
/// The first of `urls` that answers, or None when this PC can reach none of them - which
/// is not an error: the local API then serves this PC's own data, as it does offline.
pub async fn first_engine(http: &reqwest::Client, urls: &[String]) -> Option<String> {
for url in urls {
if engine_answers(http, url).await {
return Some(url.clone());
}
}
None
}
fn unreachable(url: &str, what: impl std::fmt::Display) -> Probe {
Probe { online: false, problem: Some(format!("{url}: {what}")), version: None, engine_url: None, engine_urls: Vec::new() }
}
/// The reason, short enough for one log line: reqwest nests its causes, and the one
/// that names the fault (a refused connection, an expired certificate, a bad name) is
/// the innermost.
fn cause(error: &reqwest::Error) -> String {
let mut source: &dyn std::error::Error = error;
let mut text = error.to_string();
while let Some(inner) = source.source() {
text = inner.to_string();
source = inner;
}
text
}
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
let offline = Probe { online: false, version: None, engine_url: None };
if site_url.is_empty() {
return offline;
return Probe {
online: false,
problem: Some("no DHP address is set".to_string()),
version: None,
engine_url: None,
engine_urls: Vec::new(),
};
}
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else {
return offline;
let url = format!("{site_url}/v1/health");
let response = match http.get(&url).timeout(PROBE_TIMEOUT).send().await {
Ok(response) => response,
Err(quick) if quick.is_timeout() => match http.get(&url).timeout(SLOW_LINK_TIMEOUT).send().await {
Ok(response) => response,
Err(slow) if slow.is_timeout() => {
return unreachable(&url, format!("no answer within {}s", SLOW_LINK_TIMEOUT.as_secs()))
}
Err(slow) => return unreachable(&url, cause(&slow)),
},
Err(quick) => return unreachable(&url, cause(&quick)),
};
if !response.status().is_success() {
return offline;
return unreachable(&url, format!("answered {}", response.status()));
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
Probe { online: true, version: text("version"), engine_url: text("engine_api_url") }
let engine_url = text("engine_api_url");
let engine_urls = engine_urls_from(&body, engine_url.as_deref());
Probe { online: true, problem: None, version: text("version"), engine_url, engine_urls }
}
/// The addresses a health answer offers for the Engine API, in the order to try them.
/// A DHP from before the list names one, and it is the whole list then.
fn engine_urls_from(body: &serde_json::Value, single: Option<&str>) -> Vec<String> {
let listed: Vec<String> = body
.get("engine_api_urls")
.and_then(|v| v.as_array())
.map(|urls| urls.iter().filter_map(|u| u.as_str()).map(str::trim).filter(|u| !u.is_empty()).map(str::to_string).collect())
.unwrap_or_default();
if listed.is_empty() {
return single.filter(|u| !u.is_empty()).map(str::to_string).into_iter().collect();
}
listed
}
pub enum TokenCheck {
@@ -180,3 +264,32 @@ pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version:
s => Err(format!("the DHP answered HTTP {s}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_addresses_are_taken_in_the_order_the_dhp_gives_them() {
let body = serde_json::json!({
"ok": true,
"engine_api_urls": ["http://192.168.1.41:30684", "https://engine-api.odian.fr"],
"engine_api_url": "https://engine-api.odian.fr"
});
assert_eq!(
engine_urls_from(&body, Some("https://engine-api.odian.fr")),
vec!["http://192.168.1.41:30684".to_string(), "https://engine-api.odian.fr".to_string()],
"the site's own network first, so an office PC does not go out and back"
);
}
#[test]
fn a_dhp_that_names_one_address_still_works() {
// Every DHP in the field before the list: one address, and it is the whole list.
let body = serde_json::json!({"ok": true, "engine_api_url": "http://192.168.1.41:30684"});
assert_eq!(engine_urls_from(&body, Some("http://192.168.1.41:30684")), vec!["http://192.168.1.41:30684".to_string()]);
// And a DHP offering none leaves the PC on its own data.
assert!(engine_urls_from(&serde_json::json!({"ok": true}), None).is_empty());
assert!(engine_urls_from(&serde_json::json!({"engine_api_urls": ["", " "]}), None).is_empty());
}
}