//! `GET /api/health` — is each of the three upstreams answering. //! //! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without //! Prowlarr nothing is found, without Transmission nothing is fetched, and //! without TMDB nothing is identified. The endpoint always answers `200` — //! the body carries the verdict, so a degraded service can still explain //! itself to the UI instead of looking like a fourth outage. use axum::extract::State; use axum::Json; use serde::Serialize; use utoipa::ToSchema; use crate::state::AppState; /// Whether the service as a whole can do its job. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum Health { /// Every upstream answered. Ok, /// At least one upstream is unreachable or unconfigured. Degraded, } /// The verdict for a single upstream. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum Status { /// Answered as expected. Ok, /// Did not answer, or answered with an unexpected status. Unreachable, /// No API key configured, so it was not probed. Unconfigured, } /// One upstream's result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] pub struct Check { pub status: Status, /// Why, when the status is not `ok`. Never contains the probed URL: it /// can carry an API key in the query string. #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, } impl Check { fn ok() -> Self { Self { status: Status::Ok, detail: None, } } fn unreachable(detail: impl Into) -> Self { Self { status: Status::Unreachable, detail: Some(detail.into()), } } fn unconfigured(detail: impl Into) -> Self { Self { status: Status::Unconfigured, detail: Some(detail.into()), } } } /// The body of `GET /api/health`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] pub struct HealthReport { pub status: Health, /// The running binary's version. #[schema(example = "0.1.0")] pub version: String, pub prowlarr: Check, pub transmission: Check, pub tmdb: Check, } /// Report reachability of Prowlarr, Transmission and TMDB. #[utoipa::path( get, path = "/api/health", tag = "system", responses( (status = 200, description = "Per-upstream reachability", body = HealthReport), ), )] pub async fn health(State(state): State) -> Json { // Three independent network probes; serialising them would make the // endpoint as slow as the sum of the timeouts. let (prowlarr, transmission, tmdb) = tokio::join!( probe_prowlarr(&state), probe_transmission(&state), probe_tmdb(&state), ); let status = if [prowlarr.status, transmission.status, tmdb.status] .iter() .all(|s| *s == Status::Ok) { Health::Ok } else { Health::Degraded }; Json(HealthReport { status, version: env!("CARGO_PKG_VERSION").to_string(), prowlarr, transmission, tmdb, }) } /// Prowlarr answers `/ping` without a key; the key is sent anyway so a /// misconfigured one shows up here rather than at the first search. async fn probe_prowlarr(state: &AppState) -> Check { let url = format!( "{}/ping", state.upstreams().prowlarr_url.trim_end_matches('/') ); let mut request = state.http().get(url); if let Some(key) = &state.upstreams().prowlarr_api_key { request = request.header("X-Api-Key", key); } match request.send().await { Ok(response) if response.status().is_success() => Check::ok(), Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), Err(err) => Check::unreachable(describe(err)), } } /// Transmission answers an RPC call without a session id with `409` plus the /// id to retry with. That is a live daemon, so it counts as reachable. async fn probe_transmission(state: &AppState) -> Check { let request = state .http() .post(&state.upstreams().transmission_url) .json(&serde_json::json!({ "method": "session-get" })); match request.send().await { Ok(response) if response.status().is_success() || response.status() == reqwest::StatusCode::CONFLICT => { Check::ok() } Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), Err(err) => Check::unreachable(describe(err)), } } /// TMDB is the only upstream that cannot be probed at all without a key, so /// a missing key is reported as its own state rather than as an outage. async fn probe_tmdb(state: &AppState) -> Check { let Some(key) = &state.upstreams().tmdb_api_key else { return Check::unconfigured("no ARR_TMDB_API_KEY set"); }; let url = format!( "{}/configuration", state.upstreams().tmdb_url.trim_end_matches('/') ); match state .http() .get(url) .query(&[("api_key", key)]) .send() .await { Ok(response) if response.status().is_success() => Check::ok(), Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), Err(err) => Check::unreachable(describe(err)), } } /// `reqwest`'s own `Display` includes the URL, and the TMDB URL carries the /// API key. `without_url` is what keeps the key out of the response body. fn describe(err: reqwest::Error) -> String { err.without_url().to_string() }