agentic_core/
readiness.rs1use std::time::Duration;
2
3use tracing::info;
4
5use crate::config::Config;
6use crate::error::Error;
7
8pub const LLM_READINESS_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
10
11fn checked_duration_seconds(name: &str, value: f64) -> Result<Duration, Error> {
12 if !value.is_finite() || value <= 0.0 {
13 return Err(Error::Config(format!(
14 "{name} must be a finite number > 0 (got {value})"
15 )));
16 }
17 Duration::try_from_secs_f64(value)
18 .map_err(|_| Error::Config(format!("{name} must be representable as a Duration (got {value})")))
19}
20
21fn timeout_error(url: &str, timeout_s: f64) -> Error {
22 Error::LlmTimeout {
23 url: url.to_owned(),
24 timeout_s,
25 }
26}
27
28#[derive(Debug)]
30#[non_exhaustive]
31pub enum LlmReadiness {
32 Ready,
33 Rejected(reqwest::StatusCode),
34 Unreachable(reqwest::Error),
35 TimedOut,
36}
37
38pub fn llm_readiness_client() -> Result<reqwest::Client, Error> {
47 reqwest::Client::builder()
48 .redirect(reqwest::redirect::Policy::none())
49 .build()
50 .map_err(Error::HttpClient)
51}
52
53pub async fn probe_llm_readiness(
60 client: &reqwest::Client,
61 llm_api_base: &str,
62 openai_api_key: Option<&str>,
63 timeout: Duration,
64) -> Result<LlmReadiness, Error> {
65 let base = llm_api_base.trim_end_matches('/');
66 let url = format!("{base}/health");
67 let mut request = client.get(url);
68 if let Some(key) = openai_api_key.map(str::trim).filter(|key| !key.is_empty()) {
69 let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))?;
70 request = request.header(reqwest::header::AUTHORIZATION, value);
71 }
72
73 Ok(match tokio::time::timeout(timeout, request.send()).await {
74 Ok(Ok(response)) if response.status().is_success() => LlmReadiness::Ready,
75 Ok(Ok(response)) => LlmReadiness::Rejected(response.status()),
76 Ok(Err(error)) => LlmReadiness::Unreachable(error),
77 Err(_) => LlmReadiness::TimedOut,
78 })
79}
80
81pub async fn wait_llm_ready(config: &Config) -> Result<(), Error> {
87 let base = config.llm_api_base.trim_end_matches('/');
88 let url = format!("{base}/health");
89
90 let client = llm_readiness_client()?;
91
92 let timeout = checked_duration_seconds("llm_ready_timeout_s", config.llm_ready_timeout_s)?;
93 let interval = checked_duration_seconds("llm_ready_interval_s", config.llm_ready_interval_s)?;
94 let start = tokio::time::Instant::now();
95 let mut last_notice = Duration::ZERO;
96
97 loop {
98 let remaining = timeout
99 .checked_sub(start.elapsed())
100 .ok_or_else(|| timeout_error(&url, config.llm_ready_timeout_s))?;
101 if remaining.is_zero() {
102 return Err(timeout_error(&url, config.llm_ready_timeout_s));
103 }
104
105 if matches!(
106 probe_llm_readiness(
107 &client,
108 &config.llm_api_base,
109 config.openai_api_key.as_deref(),
110 LLM_READINESS_PROBE_TIMEOUT.min(remaining),
111 )
112 .await?,
113 LlmReadiness::Ready
114 ) {
115 return Ok(());
116 }
117
118 let elapsed = start.elapsed();
119 if elapsed.saturating_sub(last_notice) >= interval {
120 last_notice = elapsed;
121 info!("waiting for LLM ({}s elapsed): {url}", elapsed.as_secs());
122 }
123
124 let remaining = timeout
125 .checked_sub(start.elapsed())
126 .ok_or_else(|| timeout_error(&url, config.llm_ready_timeout_s))?;
127 if remaining.is_zero() {
128 return Err(timeout_error(&url, config.llm_ready_timeout_s));
129 }
130
131 tokio::time::sleep(interval.min(remaining)).await;
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use std::sync::Arc;
138 use std::sync::atomic::{AtomicUsize, Ordering};
139 use std::time::Duration;
140
141 use axum::Router;
142 use axum::http::HeaderMap;
143 use axum::response::{IntoResponse, Redirect};
144 use axum::routing::get;
145 use http::StatusCode;
146 use tokio::net::TcpListener;
147
148 use super::{checked_duration_seconds, probe_llm_readiness, timeout_error, wait_llm_ready};
149
150 fn test_config(llm_api_base: String) -> crate::config::Config {
151 crate::config::Config {
152 llm_api_base,
153 openai_api_key: Some("test-key".to_owned()),
154 llm_ready_timeout_s: 0.5,
155 llm_ready_interval_s: 0.01,
156 skip_llm_ready_check: false,
157 db_url: None,
158 postgres: crate::config::PostgresConfig::default(),
159 sqlite: crate::config::SqliteConfig::default(),
160 }
161 }
162
163 async fn spawn_upstream(app: Router) -> (String, tokio::task::JoinHandle<()>) {
164 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
165 let addr = listener.local_addr().unwrap();
166 let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
167 (format!("http://{addr}"), handle)
168 }
169
170 #[test]
171 fn checked_duration_rejects_non_positive() {
172 assert!(checked_duration_seconds("v", 0.0).is_err());
173 assert!(checked_duration_seconds("v", -1.0).is_err());
174 }
175
176 #[test]
177 fn checked_duration_rejects_nan() {
178 assert!(checked_duration_seconds("v", f64::NAN).is_err());
179 }
180
181 #[test]
182 fn checked_duration_rejects_infinite() {
183 assert!(checked_duration_seconds("v", f64::INFINITY).is_err());
184 }
185
186 #[test]
187 fn checked_duration_rejects_too_large_finite() {
188 assert!(checked_duration_seconds("v", 1e50).is_err());
189 }
190
191 #[test]
192 fn checked_duration_accepts_positive_finite() {
193 let duration = checked_duration_seconds("v", 0.25).unwrap();
194 assert_eq!(duration.as_millis(), 250);
195 }
196
197 #[test]
198 fn timeout_error_preserves_inputs() {
199 let err = timeout_error("http://127.0.0.1:8000/health", 0.5);
200 match err {
201 crate::error::Error::LlmTimeout { url, timeout_s } => {
202 assert_eq!(url, "http://127.0.0.1:8000/health");
203 assert!((timeout_s - 0.5).abs() < f64::EPSILON);
204 }
205 other => panic!("expected timeout error, got {other:?}"),
206 }
207 }
208
209 #[test]
210 fn interval_sleep_is_capped_by_remaining_timeout() {
211 let interval = Duration::from_secs(2);
212 let remaining = Duration::from_millis(100);
213 assert_eq!(interval.min(remaining), Duration::from_millis(100));
214 }
215
216 #[tokio::test]
217 async fn probe_rejects_invalid_bearer_header_before_network_io() {
218 let error = probe_llm_readiness(
219 &reqwest::Client::new(),
220 "http://127.0.0.1:1",
221 Some("invalid\nkey"),
222 Duration::from_secs(1),
223 )
224 .await
225 .unwrap_err();
226
227 assert!(matches!(error, crate::error::Error::InvalidHeader(_)));
228 }
229
230 #[tokio::test]
231 async fn wait_llm_ready_retries_with_authentication_until_success() {
232 let requests = Arc::new(AtomicUsize::new(0));
233 let app = Router::new().route(
234 "/health",
235 get({
236 let requests = Arc::clone(&requests);
237 move |headers: HeaderMap| {
238 let requests = Arc::clone(&requests);
239 async move {
240 if headers.get("authorization").and_then(|value| value.to_str().ok()) != Some("Bearer test-key")
241 {
242 return StatusCode::UNAUTHORIZED;
243 }
244 if requests.fetch_add(1, Ordering::SeqCst) == 0 {
245 StatusCode::SERVICE_UNAVAILABLE
246 } else {
247 StatusCode::NO_CONTENT
248 }
249 }
250 }
251 }),
252 );
253 let (url, upstream) = spawn_upstream(app).await;
254
255 wait_llm_ready(&test_config(url)).await.unwrap();
256
257 assert!(requests.load(Ordering::SeqCst) >= 2);
258 upstream.abort();
259 }
260
261 #[tokio::test]
262 async fn wait_llm_ready_rejects_redirects_until_final_timeout() {
263 let app = Router::new()
264 .route("/health", get(|| async { Redirect::temporary("/login") }))
265 .route("/login", get(|| async { StatusCode::OK.into_response() }));
266 let (url, upstream) = spawn_upstream(app).await;
267 let mut config = test_config(url.clone());
268 config.llm_ready_timeout_s = 0.05;
269
270 let error = wait_llm_ready(&config).await.unwrap_err();
271
272 match error {
273 crate::error::Error::LlmTimeout {
274 url: timed_out_url,
275 timeout_s,
276 } => {
277 assert_eq!(timed_out_url, format!("{url}/health"));
278 assert!((timeout_s - 0.05).abs() < f64::EPSILON);
279 }
280 other => panic!("expected timeout error, got {other:?}"),
281 }
282 upstream.abort();
283 }
284}