1use std::time::{Duration, Instant};
16
17use anyhow::Result;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21enum ServerState {
22 Up,
23 Degraded(u16),
24 Down,
25}
26
27impl std::fmt::Display for ServerState {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 ServerState::Up => write!(f, "UP"),
31 ServerState::Degraded(code) => write!(f, "DEGRADED(HTTP {code})"),
32 ServerState::Down => write!(f, "DOWN"),
33 }
34 }
35}
36
37pub async fn run_watch(base_url: &str, interval_secs: u64) -> Result<()> {
43 if interval_secs == 0 {
44 return Err(anyhow::anyhow!("interval must be at least 1 second"));
45 }
46 let health_url = format!("{}/health", base_url.trim_end_matches('/'));
47 let interval = Duration::from_secs(interval_secs);
48
49 let client = reqwest::Client::builder()
50 .timeout(Duration::from_secs(5))
51 .build()?;
52
53 eprintln!(
54 "[example watch] polling {} every {}s — emitting stdout on state change",
55 health_url, interval_secs
56 );
57
58 let mut last_state: Option<ServerState> = None;
59 let mut state_since = Instant::now();
60
61 loop {
62 let current = probe(&client, &health_url).await;
63 let changed = last_state.map(|s| s != current).unwrap_or(true);
64
65 if changed {
66 let line = format_event(
67 base_url,
68 ¤t,
69 last_state,
70 state_since.elapsed(),
71 interval_secs,
72 );
73 println!("{line}");
74 state_since = Instant::now();
75 last_state = Some(current);
76 }
77
78 tokio::time::sleep(interval).await;
79 }
80}
81
82async fn probe(client: &reqwest::Client, url: &str) -> ServerState {
84 match client.get(url).send().await {
85 Ok(r) if r.status().is_success() => ServerState::Up,
86 Ok(r) => ServerState::Degraded(r.status().as_u16()),
87 Err(e) => {
88 if !e.is_timeout() && !e.is_connect() {
92 eprintln!("[example watch] probe error (may indicate config issue): {e}");
93 }
94 ServerState::Down
95 }
96 }
97}
98
99#[cfg(test)]
100#[path = "watch_tests.rs"]
101mod tests;
102
103fn format_event(
105 base_url: &str,
106 current: &ServerState,
107 prev: Option<ServerState>,
108 prev_duration: Duration,
109 interval_secs: u64,
110) -> String {
111 match (current, prev) {
112 (ServerState::Up, Some(prev_state @ (ServerState::Down | ServerState::Degraded(_)))) => {
114 format!(
115 "[soma] UP — {} recovered after {}s (was {})",
116 base_url,
117 prev_duration.as_secs(),
118 prev_state,
119 )
120 }
121 (ServerState::Up, _) => format!("[soma] UP — {} is healthy", base_url),
123 (ServerState::Down, _) => format!(
125 "[soma] DOWN — {} is unreachable (retrying every {}s)",
126 base_url, interval_secs
127 ),
128 (ServerState::Degraded(code), _) => {
130 format!("[soma] DEGRADED — {} returned HTTP {code}", base_url)
131 }
132 }
133}