Files
arr/crates/arr-api/src/health.rs
T
naps62-yolo 58827da647
ci / web (push) Successful in 6s
ci / rust (push) Successful in 45s
e2e / e2e (push) Successful in 1m31s
feat(api): axum skeleton with health and OpenAPI (#53)
2026-08-22 20:09:52 +01:00

184 lines
5.6 KiB
Rust

//! `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<String>,
}
impl Check {
fn ok() -> Self {
Self {
status: Status::Ok,
detail: None,
}
}
fn unreachable(detail: impl Into<String>) -> Self {
Self {
status: Status::Unreachable,
detail: Some(detail.into()),
}
}
fn unconfigured(detail: impl Into<String>) -> 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<AppState>) -> Json<HealthReport> {
// 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()
}