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))]
#[cfg(feature = "native-http")]
pub struct HttpProbeHealthChecker {
client: reqwest::Client,
probe_urls: std::collections::HashMap<String, String>,
}
#[cfg(feature = "native-http")]
impl HttpProbeHealthChecker {
pub fn new(probe_urls: impl IntoIterator<Item = (String, String)>) -> Result<Self> {
let builder = crate::provider::configure_outbound_client_builder(reqwest::Client::builder(), None);
let client = builder.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(),
})
}
}
#[cfg(feature = "native-http")]
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 {
if let Err(error) = crate::provider::validate_outbound_url(&url).await {
tracing::debug!(upstream = %url, error = %error, "health probe blocked by outbound policy");
return HealthStatus::Unhealthy;
}
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;
}
match tokio::time::timeout(config.timeout, checker.check(upstream.clone())).await {
Ok(status) => state.record(status, &config),
Err(_elapsed) => {
tracing::debug!(
upstream = %upstream,
timeout = ?config.timeout,
"health probe timed out"
);
state.record(HealthStatus::Unhealthy, &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 {
config: HealthCheckConfig,
}
impl HealthCheckLayer {
#[must_use]
pub fn new(interval: Duration) -> Self {
Self::with_config(HealthCheckConfig {
interval,
..Default::default()
})
}
#[must_use]
pub fn with_config(config: HealthCheckConfig) -> Self {
Self { config }
}
}
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 state = ProviderHealthState::new(true);
let probe_svc = inner.clone();
let probe_state = Arc::clone(&state);
let config = self.config.clone();
tokio::spawn(async move {
run_health_probe(probe_svc, probe_state, config).await;
});
HealthCheckService { inner, state }
}
}
async fn run_health_probe<S>(mut svc: S, state: Arc<ProviderHealthState>, config: HealthCheckConfig)
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
loop {
tokio::time::sleep(config.interval).await;
if Arc::strong_count(&state) <= 1 {
break;
}
match tokio::time::timeout(config.timeout, svc.call(LlmRequest::ListModels())).await {
Ok(Ok(_)) => state.record(HealthStatus::Healthy, &config),
Ok(Err(LiterLlmError::EndpointNotSupported { .. })) => {
tracing::debug!("health probe: provider does not implement ListModels; skipping probe result");
}
Ok(Err(e)) => {
tracing::debug!(error = %e, "health probe failed");
state.record(HealthStatus::Unhealthy, &config);
}
Err(_elapsed) => {
tracing::debug!(timeout = ?config.timeout, "health probe timed out");
state.record(HealthStatus::Unhealthy, &config);
}
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct HealthCheckService<S> {
inner: S,
state: Arc<ProviderHealthState>,
}
impl<S: Clone> Clone for HealthCheckService<S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
state: Arc::clone(&self.state),
}
}
}
impl<S> HealthCheckService<S> {
#[must_use]
pub fn is_healthy(&self) -> bool {
self.state.is_healthy()
}
}
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.state.is_healthy() {
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.state.is_healthy() {
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::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use super::*;
use crate::tower::service::LlmService;
use crate::tower::tests_common::{MockClient, chat_req};
use crate::tower::types::LlmRequest;
use crate::types::ModelsListResponse;
fn list_models_ok() -> LlmResponse {
LlmResponse::ListModels(ModelsListResponse {
object: "list".into(),
data: vec![],
})
}
#[derive(Clone)]
enum ScriptedOutcome {
Ok,
Unavailable,
EndpointNotSupported,
Stall(Duration),
}
#[derive(Clone)]
struct ScriptedProbeService {
script: Arc<Mutex<VecDeque<ScriptedOutcome>>>,
calls: Arc<AtomicUsize>,
}
impl ScriptedProbeService {
fn new(script: Vec<ScriptedOutcome>) -> Self {
Self {
script: Arc::new(Mutex::new(script.into_iter().collect())),
calls: Arc::new(AtomicUsize::new(0)),
}
}
}
impl Service<LlmRequest> for ScriptedProbeService {
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<()>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: LlmRequest) -> Self::Future {
self.calls.fetch_add(1, Ordering::SeqCst);
let outcome = self
.script
.lock()
.expect("script mutex poisoned")
.pop_front()
.unwrap_or(ScriptedOutcome::Ok);
Box::pin(async move {
match outcome {
ScriptedOutcome::Ok => Ok(list_models_ok()),
ScriptedOutcome::Unavailable => Err(LiterLlmError::ServiceUnavailable {
message: "probe failed".into(),
status: 503,
}),
ScriptedOutcome::EndpointNotSupported => Err(LiterLlmError::EndpointNotSupported {
endpoint: "list_models".into(),
provider: "scripted".into(),
}),
ScriptedOutcome::Stall(d) => {
tokio::time::sleep(d).await;
Ok(list_models_ok())
}
}
})
}
}
#[derive(Clone)]
enum ScriptedHealthOutcome {
Healthy,
Unhealthy,
Stall(Duration),
}
struct ScriptedChecker {
script: Mutex<VecDeque<ScriptedHealthOutcome>>,
calls: Arc<AtomicUsize>,
}
impl ScriptedChecker {
fn new(script: Vec<ScriptedHealthOutcome>) -> (Arc<Self>, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let checker = Arc::new(Self {
script: Mutex::new(script.into_iter().collect()),
calls: Arc::clone(&calls),
});
(checker, calls)
}
}
impl HealthChecker for ScriptedChecker {
fn check(&self, _upstream: String) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + 'static>> {
self.calls.fetch_add(1, Ordering::SeqCst);
let outcome = self
.script
.lock()
.expect("script mutex poisoned")
.pop_front()
.unwrap_or(ScriptedHealthOutcome::Healthy);
Box::pin(async move {
match outcome {
ScriptedHealthOutcome::Healthy => HealthStatus::Healthy,
ScriptedHealthOutcome::Unhealthy => HealthStatus::Unhealthy,
ScriptedHealthOutcome::Stall(d) => {
tokio::time::sleep(d).await;
HealthStatus::Healthy
}
}
})
}
}
async fn advance(d: Duration) {
tokio::task::yield_now().await;
tokio::time::advance(d).await;
for _ in 0..16 {
tokio::task::yield_now().await;
}
}
#[tokio::test]
async fn healthy_service_passes_through() {
let inner = LlmService::new(MockClient::ok());
let mut svc = HealthCheckService {
inner,
state: ProviderHealthState::new(true),
};
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 mut svc = HealthCheckService {
inner,
state: ProviderHealthState::new(false),
};
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 state = ProviderHealthState::new(true);
let svc = HealthCheckService {
inner,
state: Arc::clone(&state),
};
assert!(svc.is_healthy());
state.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 state = ProviderHealthState::new(false);
let mut svc = HealthCheckService {
inner,
state: Arc::clone(&state),
};
assert!(svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.is_err());
state.healthy.store(true, Ordering::Release);
assert!(svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.is_ok());
}
#[tokio::test(start_paused = true)]
async fn single_failure_does_not_open_global_gate() {
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 3,
healthy_threshold: 1,
};
let svc = ScriptedProbeService::new(vec![ScriptedOutcome::Unavailable]);
let calls = Arc::clone(&svc.calls);
let health_svc = HealthCheckLayer::with_config(config.clone()).layer(svc);
advance(config.interval * 2).await;
assert!(
health_svc.is_healthy(),
"one failure below unhealthy_threshold must not open the gate"
);
assert!(calls.load(Ordering::SeqCst) >= 1, "probe must actually have run");
}
#[tokio::test(start_paused = true)]
async fn n_consecutive_failures_open_global_gate() {
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 3,
healthy_threshold: 1,
};
let svc = ScriptedProbeService::new(vec![
ScriptedOutcome::Unavailable,
ScriptedOutcome::Unavailable,
ScriptedOutcome::Unavailable,
]);
let calls = Arc::clone(&svc.calls);
let health_svc = HealthCheckLayer::with_config(config.clone()).layer(svc);
advance(config.interval).await;
assert!(health_svc.is_healthy(), "1 of 3 failures must not open the gate yet");
advance(config.interval).await;
assert!(health_svc.is_healthy(), "2 of 3 failures must not open the gate yet");
advance(config.interval).await;
assert!(!health_svc.is_healthy(), "3 consecutive failures must open the gate");
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test(start_paused = true)]
async fn recovery_closes_global_gate() {
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 1,
healthy_threshold: 2,
};
let svc = ScriptedProbeService::new(vec![
ScriptedOutcome::Unavailable,
ScriptedOutcome::Ok,
ScriptedOutcome::Ok,
]);
let health_svc = HealthCheckLayer::with_config(config.clone()).layer(svc);
advance(config.interval).await;
assert!(
!health_svc.is_healthy(),
"first failure must open the gate (threshold 1)"
);
advance(config.interval).await;
assert!(
!health_svc.is_healthy(),
"one success below healthy_threshold must not close the gate yet"
);
advance(config.interval).await;
assert!(
health_svc.is_healthy(),
"second consecutive success must close the gate"
);
}
#[tokio::test(start_paused = true)]
async fn endpoint_not_supported_does_not_open_global_gate() {
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 1,
healthy_threshold: 1,
};
let svc = ScriptedProbeService::new(vec![
ScriptedOutcome::EndpointNotSupported,
ScriptedOutcome::EndpointNotSupported,
ScriptedOutcome::EndpointNotSupported,
]);
let calls = Arc::clone(&svc.calls);
let health_svc = HealthCheckLayer::with_config(config.clone()).layer(svc);
advance(config.interval * 3).await;
assert!(
health_svc.is_healthy(),
"EndpointNotSupported is not an availability signal and must not open the gate"
);
assert!(calls.load(Ordering::SeqCst) >= 1, "probe must actually have run");
}
#[tokio::test(start_paused = true)]
async fn stalled_global_probe_bounded_by_timeout() {
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(20),
unhealthy_threshold: 1,
healthy_threshold: 1,
};
let svc = ScriptedProbeService::new(vec![ScriptedOutcome::Stall(Duration::from_millis(500))]);
let health_svc = HealthCheckLayer::with_config(config.clone()).layer(svc);
advance(config.interval).await;
advance(config.timeout + Duration::from_millis(10)).await;
assert!(
!health_svc.is_healthy(),
"a probe stuck past the configured timeout must be treated as a failure, not left pending forever"
);
}
#[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(start_paused = true)]
async fn per_provider_healthy_checker_is_actually_invoked() {
let (checker, calls) = ScriptedChecker::new(vec![]);
let inner = LlmService::new(MockClient::ok());
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 3,
healthy_threshold: 1,
};
let mut svc = PerProviderHealthCheck::new(inner, checker, "test-provider".into(), config.clone());
advance(config.interval * 2).await;
assert!(svc.is_healthy());
assert!(
calls.load(Ordering::SeqCst) >= 1,
"checker must actually be invoked by the probe loop, not just assumed healthy"
);
let resp = svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await;
assert!(resp.is_ok());
}
#[tokio::test(start_paused = true)]
async fn per_provider_unhealthy_rejects_after_threshold() {
let (checker, calls) = ScriptedChecker::new(vec![
ScriptedHealthOutcome::Unhealthy,
ScriptedHealthOutcome::Unhealthy,
ScriptedHealthOutcome::Unhealthy,
]);
let inner = LlmService::new(MockClient::ok());
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(50),
unhealthy_threshold: 3,
healthy_threshold: 1,
};
let mut svc = PerProviderHealthCheck::new(inner, checker, "test-provider".into(), config.clone());
advance(config.interval).await;
assert!(svc.is_healthy(), "one failure below threshold must not reject");
advance(config.interval).await;
advance(config.interval).await;
assert!(!svc.is_healthy());
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"checker must be called once per interval tick"
);
let err = svc
.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect_err("unhealthy provider should reject");
assert!(matches!(err, LiterLlmError::ServiceUnavailable { .. }));
}
#[tokio::test(start_paused = true)]
async fn per_provider_stalled_checker_bounded_by_timeout() {
let (checker, _calls) = ScriptedChecker::new(vec![ScriptedHealthOutcome::Stall(Duration::from_millis(500))]);
let inner = LlmService::new(MockClient::ok());
let config = HealthCheckConfig {
interval: Duration::from_millis(10),
timeout: Duration::from_millis(20),
unhealthy_threshold: 1,
healthy_threshold: 1,
};
let svc = PerProviderHealthCheck::new(inner, checker, "test-provider".into(), config.clone());
advance(config.interval).await;
advance(config.timeout + Duration::from_millis(10)).await;
assert!(
!svc.is_healthy(),
"a checker stuck past the configured timeout must be treated as a failure, not left pending forever"
);
}
}