use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::task::{Context, Poll};
use std::time::Duration;
use tower::{Layer, Service};
use super::types::{LlmRequest, LlmResponse};
use crate::client::BoxFuture;
use crate::error::{LiterLlmError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthStatus {
Healthy,
Unhealthy,
}
#[derive(Debug, Clone)]
#[cfg_attr(alef, alef(skip))]
pub struct HealthCheckConfig {
pub interval: Duration,
pub timeout: Duration,
pub unhealthy_threshold: u32,
pub healthy_threshold: u32,
}
impl Default for HealthCheckConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(30),
timeout: Duration::from_secs(5),
unhealthy_threshold: 3,
healthy_threshold: 2,
}
}
}
pub trait HealthChecker: Send + Sync + 'static {
fn check(
&self,
upstream: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send + 'static>>;
}
#[derive(Debug, Clone)]
#[cfg_attr(alef, alef(skip))]
pub struct HttpProbeHealthChecker {
client: reqwest::Client,
probe_urls: std::collections::HashMap<String, String>,
}
impl HttpProbeHealthChecker {
pub fn new(timeout: Duration, probe_urls: impl IntoIterator<Item = (String, String)>) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| LiterLlmError::BadRequest {
message: format!("failed to build HTTP client for health checker: {e}"),
status: 500,
})?;
Ok(Self {
client,
probe_urls: probe_urls.into_iter().collect(),
})
}
}
impl HealthChecker for HttpProbeHealthChecker {
fn check(
&self,
upstream: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send + 'static>> {
let url = self.probe_urls.get(&upstream).cloned().unwrap_or(upstream);
let client = self.client.clone();
Box::pin(async move {
let result = client.get(&url).send().await;
match result {
Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => HealthStatus::Healthy,
Ok(resp) => {
tracing::debug!(
upstream = %url,
status = resp.status().as_u16(),
"health probe returned non-success status"
);
HealthStatus::Unhealthy
}
Err(e) => {
tracing::debug!(
upstream = %url,
error = %e,
"health probe failed"
);
HealthStatus::Unhealthy
}
}
})
}
}
#[derive(Debug)]
struct ProviderHealthState {
healthy: AtomicBool,
consecutive_failures: AtomicU32,
consecutive_successes: AtomicU32,
}
impl ProviderHealthState {
fn new(initially_healthy: bool) -> Arc<Self> {
Arc::new(Self {
healthy: AtomicBool::new(initially_healthy),
consecutive_failures: AtomicU32::new(0),
consecutive_successes: AtomicU32::new(0),
})
}
fn is_healthy(&self) -> bool {
self.healthy.load(Ordering::Acquire)
}
fn record(&self, status: HealthStatus, config: &HealthCheckConfig) {
match status {
HealthStatus::Healthy => {
self.consecutive_failures.store(0, Ordering::Release);
let successes = self.consecutive_successes.fetch_add(1, Ordering::AcqRel) + 1;
if successes >= config.healthy_threshold {
let was_unhealthy = !self.healthy.load(Ordering::Acquire);
self.healthy.store(true, Ordering::Release);
if was_unhealthy {
tracing::info!(
consecutive_successes = successes,
"health probe: upstream marked healthy"
);
}
}
}
HealthStatus::Unhealthy => {
self.consecutive_successes.store(0, Ordering::Release);
let failures = self.consecutive_failures.fetch_add(1, Ordering::AcqRel) + 1;
if failures >= config.unhealthy_threshold {
let was_healthy = self.healthy.load(Ordering::Acquire);
self.healthy.store(false, Ordering::Release);
if was_healthy {
tracing::warn!(
consecutive_failures = failures,
"health probe: upstream marked unhealthy"
);
}
}
}
}
}
}
async fn run_provider_health_probe<C: HealthChecker>(
checker: Arc<C>,
upstream: String,
state: Arc<ProviderHealthState>,
config: HealthCheckConfig,
) {
loop {
tokio::time::sleep(config.interval).await;
if Arc::strong_count(&state) <= 1 {
break;
}
let status = checker.check(upstream.clone()).await;
state.record(status, &config);
}
}
#[cfg_attr(alef, alef(skip))]
pub struct PerProviderHealthCheck<S> {
inner: S,
state: Arc<ProviderHealthState>,
}
impl<S: Clone> Clone for PerProviderHealthCheck<S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
state: Arc::clone(&self.state),
}
}
}
impl<S> PerProviderHealthCheck<S>
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Clone + Send + 'static,
S::Future: Send + 'static,
{
pub fn new<C: HealthChecker>(inner: S, checker: Arc<C>, upstream: String, config: HealthCheckConfig) -> Self {
let state = ProviderHealthState::new(true);
let probe_state = Arc::clone(&state);
let probe_checker = Arc::clone(&checker);
tokio::spawn(async move {
run_provider_health_probe(probe_checker, upstream, probe_state, config).await;
});
Self { inner, state }
}
#[must_use]
pub fn is_healthy(&self) -> bool {
self.state.is_healthy()
}
}
impl<S> Service<LlmRequest> for PerProviderHealthCheck<S>
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
if !self.state.is_healthy() {
return Poll::Ready(Err(LiterLlmError::ServiceUnavailable {
message: "provider is unhealthy (health check failed)".into(),
status: 503,
}));
}
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
if !self.state.is_healthy() {
return Box::pin(async {
Err(LiterLlmError::ServiceUnavailable {
message: "provider is unhealthy (health check failed)".into(),
status: 503,
})
});
}
let fut = self.inner.call(req);
Box::pin(fut)
}
}
#[cfg_attr(alef, alef(skip))]
pub struct HealthCheckLayer {
interval: Duration,
}
impl HealthCheckLayer {
#[must_use]
pub fn new(interval: Duration) -> Self {
Self { interval }
}
}
impl<S> Layer<S> for HealthCheckLayer
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Service = HealthCheckService<S>;
fn layer(&self, inner: S) -> Self::Service {
let healthy = Arc::new(AtomicBool::new(true));
let probe_svc = inner.clone();
let probe_healthy = Arc::clone(&healthy);
let interval = self.interval;
tokio::spawn(async move {
run_health_probe(probe_svc, probe_healthy, interval).await;
});
HealthCheckService { inner, healthy }
}
}
async fn run_health_probe<S>(mut svc: S, healthy: Arc<AtomicBool>, interval: Duration)
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
loop {
tokio::time::sleep(interval).await;
if Arc::strong_count(&healthy) <= 1 {
break;
}
let result = svc.call(LlmRequest::ListModels()).await;
let is_healthy = result.is_ok();
healthy.store(is_healthy, Ordering::Release);
if !is_healthy {
tracing::warn!("health check failed; marking service as unhealthy");
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct HealthCheckService<S> {
inner: S,
healthy: Arc<AtomicBool>,
}
impl<S: Clone> Clone for HealthCheckService<S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
healthy: Arc::clone(&self.healthy),
}
}
}
impl<S> HealthCheckService<S> {
#[must_use]
pub fn is_healthy(&self) -> bool {
self.healthy.load(Ordering::Acquire)
}
}
impl<S> Service<LlmRequest> for HealthCheckService<S>
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
if !self.healthy.load(Ordering::Acquire) {
return Poll::Ready(Err(LiterLlmError::ServiceUnavailable {
message: "service is unhealthy (health check failed)".into(),
status: 503,
}));
}
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
if !self.healthy.load(Ordering::Acquire) {
return Box::pin(async {
Err(LiterLlmError::ServiceUnavailable {
message: "service is unhealthy (health check failed)".into(),
status: 503,
})
});
}
let fut = self.inner.call(req);
Box::pin(fut)
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use tower::Service as _;
use super::*;
use crate::tower::service::LlmService;
use crate::tower::tests_common::{MockClient, chat_req};
use crate::tower::types::LlmRequest;
#[tokio::test]
async fn healthy_service_passes_through() {
let inner = LlmService::new(MockClient::ok());
let healthy = Arc::new(AtomicBool::new(true));
let mut svc = HealthCheckService {
inner,
healthy: Arc::clone(&healthy),
};
let resp = svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await;
assert!(resp.is_ok());
}
#[tokio::test]
async fn unhealthy_service_rejects_requests() {
let inner = LlmService::new(MockClient::ok());
let healthy = Arc::new(AtomicBool::new(false));
let mut svc = HealthCheckService {
inner,
healthy: Arc::clone(&healthy),
};
let err = svc
.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect_err("unhealthy service should reject");
assert!(matches!(err, LiterLlmError::ServiceUnavailable { .. }));
}
#[tokio::test]
async fn is_healthy_reflects_flag() {
let inner = LlmService::new(MockClient::ok());
let healthy = Arc::new(AtomicBool::new(true));
let svc = HealthCheckService {
inner,
healthy: Arc::clone(&healthy),
};
assert!(svc.is_healthy());
healthy.store(false, Ordering::Release);
assert!(!svc.is_healthy());
}
#[tokio::test]
async fn recovery_after_becoming_healthy_again() {
let inner = LlmService::new(MockClient::ok());
let healthy = Arc::new(AtomicBool::new(false));
let mut svc = HealthCheckService {
inner,
healthy: Arc::clone(&healthy),
};
assert!(svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.is_err());
healthy.store(true, Ordering::Release);
assert!(svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.is_ok());
}
#[test]
fn health_check_config_default_values() {
let config = HealthCheckConfig::default();
assert_eq!(config.interval, Duration::from_secs(30));
assert_eq!(config.timeout, Duration::from_secs(5));
assert_eq!(config.unhealthy_threshold, 3);
assert_eq!(config.healthy_threshold, 2);
}
#[test]
fn health_checker_marks_down_after_threshold() {
let config = HealthCheckConfig {
unhealthy_threshold: 3,
healthy_threshold: 1,
..Default::default()
};
let state = ProviderHealthState::new(true);
state.record(HealthStatus::Unhealthy, &config);
assert!(state.is_healthy(), "should still be healthy after 1 failure");
state.record(HealthStatus::Unhealthy, &config);
assert!(state.is_healthy(), "should still be healthy after 2 failures");
state.record(HealthStatus::Unhealthy, &config);
assert!(!state.is_healthy(), "should be unhealthy after 3 consecutive failures");
}
#[test]
fn health_checker_marks_up_after_threshold() {
let config = HealthCheckConfig {
unhealthy_threshold: 1,
healthy_threshold: 2,
..Default::default()
};
let state = ProviderHealthState::new(false);
state.record(HealthStatus::Healthy, &config);
assert!(!state.is_healthy(), "should still be unhealthy after 1 success");
state.record(HealthStatus::Healthy, &config);
assert!(state.is_healthy(), "should be healthy after 2 consecutive successes");
}
#[test]
fn health_checker_resets_counters_on_state_change() {
let config = HealthCheckConfig {
unhealthy_threshold: 2,
healthy_threshold: 2,
..Default::default()
};
let state = ProviderHealthState::new(true);
state.record(HealthStatus::Unhealthy, &config);
state.record(HealthStatus::Healthy, &config);
state.record(HealthStatus::Unhealthy, &config);
assert!(state.is_healthy(), "one failure after reset should not mark unhealthy");
state.record(HealthStatus::Unhealthy, &config);
assert!(!state.is_healthy(), "second failure after reset should mark unhealthy");
}
#[tokio::test]
async fn per_provider_healthy_passes_through() {
struct AlwaysHealthy;
impl HealthChecker for AlwaysHealthy {
fn check(
&self,
_upstream: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send + 'static>> {
Box::pin(async { HealthStatus::Healthy })
}
}
let inner = LlmService::new(MockClient::ok());
let config = HealthCheckConfig::default();
let checker = Arc::new(AlwaysHealthy);
let mut svc = PerProviderHealthCheck::new(inner, checker, "test-provider".into(), config);
assert!(svc.is_healthy());
let resp = svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await;
assert!(resp.is_ok());
}
#[tokio::test]
async fn per_provider_unhealthy_rejects() {
struct AlwaysUnhealthy;
impl HealthChecker for AlwaysUnhealthy {
fn check(
&self,
_upstream: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send + 'static>> {
Box::pin(async { HealthStatus::Unhealthy })
}
}
let inner = LlmService::new(MockClient::ok());
let config = HealthCheckConfig {
unhealthy_threshold: 1,
healthy_threshold: 1,
..Default::default()
};
let checker = Arc::new(AlwaysUnhealthy);
let mut svc = PerProviderHealthCheck::new(inner, checker, "test-provider".into(), config);
svc.state.record(
HealthStatus::Unhealthy,
&HealthCheckConfig {
unhealthy_threshold: 1,
healthy_threshold: 1,
..Default::default()
},
);
assert!(!svc.is_healthy());
let err = svc
.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect_err("unhealthy provider should reject");
assert!(matches!(err, LiterLlmError::ServiceUnavailable { .. }));
}
}