autumn_plugin_slack/
health.rs1use std::collections::HashMap;
4use std::sync::{Mutex, OnceLock};
5use std::time::Duration;
6
7use autumn_web::actuator::{HealthCheckOutput, HealthIndicator, HealthStatus, IndicatorGroup};
8use futures::future::BoxFuture;
9use serde_json::json;
10use tokio::time::Instant;
11
12use crate::client::SlackClient;
13use crate::error::SlackError;
14
15const CHECK_TIMEOUT: Duration = Duration::from_millis(1_500);
18pub const DOWN_TTL: Duration = Duration::from_secs(5);
20
21#[derive(Clone)]
23struct Entry {
24 stored: Instant,
25 until: Instant,
26 out: HealthCheckOutput,
27}
28
29pub struct SlackHealth {
39 client: OnceLock<SlackClient>,
40 readiness: bool,
41 cache: Mutex<Option<Entry>>,
42 cache_for: OnceLock<Duration>,
43 refresh: tokio::sync::Mutex<()>,
45}
46
47impl std::fmt::Debug for SlackHealth {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("SlackHealth")
50 .field("started", &self.client.get().is_some())
51 .field("readiness", &self.readiness)
52 .finish_non_exhaustive()
53 }
54}
55
56impl SlackHealth {
57 pub(crate) const fn new(readiness: bool) -> Self {
58 Self {
59 client: OnceLock::new(),
60 readiness,
61 cache: Mutex::new(None),
62 cache_for: OnceLock::new(),
63 refresh: tokio::sync::Mutex::const_new(()),
64 }
65 }
66
67 pub(crate) fn set(&self, client: SlackClient, cache_for: Duration) {
69 let _ = self.cache_for.set(cache_for);
70 let _ = self.client.set(client);
71 }
72
73 fn entry(&self) -> Option<Entry> {
74 self.cache
75 .lock()
76 .unwrap_or_else(std::sync::PoisonError::into_inner)
77 .clone()
78 }
79
80 fn cached(&self) -> Option<HealthCheckOutput> {
81 let entry = self.entry()?;
82 (Instant::now() < entry.until).then_some(entry.out)
83 }
84
85 fn stored_since(&self, since: Instant) -> Option<HealthCheckOutput> {
88 let entry = self.entry()?;
89 (entry.stored >= since).then_some(entry.out)
90 }
91
92 fn store(&self, out: &HealthCheckOutput) {
93 let up_ttl = self.cache_for.get().copied().unwrap_or_default();
94 let ttl = if out.status == HealthStatus::Up {
95 up_ttl
96 } else {
97 up_ttl.min(DOWN_TTL)
98 };
99 *self
100 .cache
101 .lock()
102 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Entry {
103 stored: Instant::now(),
104 until: Instant::now() + ttl,
105 out: out.clone(),
106 });
107 }
108
109 async fn run_check(client: &SlackClient) -> HealthCheckOutput {
110 let down = |code: String| {
111 HealthCheckOutput::down()
112 .with_details(HashMap::from([("error".to_owned(), json!(code))]))
113 };
114 match tokio::time::timeout(CHECK_TIMEOUT, client.auth_test_once()).await {
115 Ok(Ok(())) => HealthCheckOutput::up(),
116 Ok(Err(e)) => {
117 tracing::warn!(error = %e, "slack health check failed");
118 down(error_code(&e))
119 }
120 Err(_) => down("timeout".to_owned()),
121 }
122 }
123}
124
125fn error_code(err: &SlackError) -> String {
127 match err {
128 SlackError::Api { code, .. } => code
130 .chars()
131 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
132 .take(64)
133 .collect(),
134 SlackError::RateLimited { .. } => "rate_limited".to_owned(),
135 SlackError::Http { status, .. } => format!("http_{status}"),
136 SlackError::Transport(_) => "network".to_owned(),
137 _ => "error".to_owned(),
138 }
139}
140
141fn unknown(reason: &str) -> HealthCheckOutput {
142 HealthCheckOutput {
143 status: HealthStatus::Unknown,
144 details: HashMap::from([("reason".to_owned(), json!(reason))]),
145 }
146}
147
148impl HealthIndicator for SlackHealth {
149 fn check(&self) -> BoxFuture<'_, HealthCheckOutput> {
150 Box::pin(async move {
151 let Some(client) = self.client.get() else {
152 return unknown("not started");
153 };
154 if !client.has_token() {
155 return unknown("no bot token");
156 }
157 if let Some(out) = self.cached() {
158 return out;
159 }
160 let since = Instant::now();
163 let _turn = self.refresh.lock().await;
164 if let Some(out) = self.cached().or_else(|| self.stored_since(since)) {
165 return out;
166 }
167 let out = Self::run_check(client).await;
168 self.store(&out);
169 out
170 })
171 }
172
173 fn group(&self) -> IndicatorGroup {
174 if self.readiness {
175 IndicatorGroup::Readiness
176 } else {
177 IndicatorGroup::HealthOnly
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[tokio::test]
187 async fn unknown_before_start() {
188 let h = SlackHealth::new(false);
189 let out = h.check().await;
190 assert_eq!(out.status, HealthStatus::Unknown);
191 assert_eq!(out.details["reason"], json!("not started"));
192 assert!(format!("{h:?}").contains("started: false"));
193 }
194
195 #[test]
196 fn error_codes_are_short() {
197 assert_eq!(
198 error_code(&SlackError::Api {
199 method: "auth.test".into(),
200 code: "invalid_auth".into(),
201 needed: None
202 }),
203 "invalid_auth"
204 );
205 assert_eq!(
206 error_code(&SlackError::RateLimited {
207 retry_after_secs: None
208 }),
209 "rate_limited"
210 );
211 assert_eq!(
212 error_code(&SlackError::Http {
213 status: 503,
214 body: "x".into()
215 }),
216 "http_503"
217 );
218 assert_eq!(error_code(&SlackError::Transport("dns".into())), "network");
219 assert_eq!(error_code(&SlackError::Decode("x".into())), "error");
220 }
221}