use crate::RuntimeError;
use crate::resource::{MIN_HEALTH_INTERVAL, Resource};
use std::ops::ControlFlow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, thiserror::Error)]
enum ProbeError {
#[error("{0}")]
Transport(#[from] reqwest::Error),
#[error("backend returned status {0}")]
Status(reqwest::StatusCode),
}
async fn probe_url(client: &reqwest::Client, url: &str) -> Result<(), ProbeError> {
let response = client.get(url).timeout(HEALTH_CHECK_TIMEOUT).send().await?;
let status = response.status();
match status.is_success() {
true => Ok(()),
false => Err(ProbeError::Status(status)),
}
}
fn health_url(backend: &str, path: &str) -> Box<str> {
format!("{backend}{path}").into_boxed_str()
}
#[derive(Debug)]
pub struct ProxyHealthResource {
name: Box<str>,
url: Box<str>,
routing_flag: Arc<AtomicBool>,
}
impl ProxyHealthResource {
pub fn new(backend: &str, path: &str) -> Self {
Self {
name: Box::from(backend),
url: health_url(backend, path),
routing_flag: Arc::new(AtomicBool::new(true)),
}
}
pub fn routing_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.routing_flag)
}
}
impl Resource for ProxyHealthResource {
fn name(&self) -> &str {
&self.name
}
fn health_check(&self) -> Result<(), RuntimeError> {
let client = super::async_proxy::proxy_client()?;
let handle = tokio::runtime::Handle::try_current().map_err(|_| RuntimeError::NoRuntime)?;
let url: &str = &self.url;
let result = match handle.runtime_flavor() {
tokio::runtime::RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(|| handle.block_on(probe_url(client, url)))
}
_ => {
return Err(RuntimeError::Http(
"backend health check requires a multi-thread runtime".into(),
));
}
};
self.routing_flag.store(result.is_ok(), Ordering::Release);
result.map_err(|e| RuntimeError::Http(format!("backend health check failed: {e}").into()))
}
fn shutdown(&self) -> Result<(), RuntimeError> {
Ok(())
}
}
pub async fn spawn_health_checker(
backend: &str,
path: &str,
interval: Duration,
) -> Result<Arc<AtomicBool>, RuntimeError> {
if interval < MIN_HEALTH_INTERVAL {
return Err(RuntimeError::InvalidArgument(
format!("health check interval must be at least {MIN_HEALTH_INTERVAL:?}")
.into_boxed_str(),
));
}
let runtime = crate::runtime::runtime_context()?;
let url = health_url(backend, path);
let client = super::async_proxy::proxy_client()?;
let healthy = Arc::new(AtomicBool::new(true));
record_probe(&url, &healthy, probe_url(client, &url).await);
let loop_healthy = Arc::clone(&healthy);
crate::task::admit_signalled_on(&runtime, move |signals| {
run_health_checker(client, url, interval, loop_healthy, signals)
})?;
Ok(healthy)
}
async fn run_health_checker(
client: &'static reqwest::Client,
url: Box<str>,
interval: Duration,
healthy: Arc<AtomicBool>,
signals: crate::runtime_state::LifecycleSignals,
) {
while let ControlFlow::Continue(()) = signals.tick(interval).await {
match signals.guard(probe_url(client, &url)).await {
ControlFlow::Break(()) => return,
ControlFlow::Continue(result) => record_probe(&url, &healthy, result),
}
}
}
fn record_probe(url: &str, healthy: &AtomicBool, result: Result<(), ProbeError>) {
let was_healthy = healthy.swap(result.is_ok(), Ordering::Release);
if let (true, Err(e)) = (was_healthy, result) {
tracing::warn!(url = %url, error = %e, "backend health probe failed");
}
}