Skip to main content

autumn_plugin_slack/
health.rs

1//! Health indicator for `/actuator/health`.
2
3use 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
15/// Time limit for one check. 1.5 s is less than the 2 s autumn indicator
16/// timeout.
17const CHECK_TIMEOUT: Duration = Duration::from_millis(1_500);
18/// Largest time to keep a `Down` result. A short outage clears fast.
19pub const DOWN_TTL: Duration = Duration::from_secs(5);
20
21/// A stored result and its expiry.
22#[derive(Clone)]
23struct Entry {
24    stored: Instant,
25    until: Instant,
26    out: HealthCheckOutput,
27}
28
29/// Checks the bot token with `auth.test`.
30///
31/// - `Up`: Slack accepts the token.
32/// - `Down`: Slack rejects it, or the call fails.
33/// - `Unknown`: before start, or with no bot token.
34///
35/// It keeps `Up` for `[slack.health] cache_secs` and `Down` for 5 s or less.
36/// One check runs at a time. Other probes get the result of that check. Details give a
37/// short error code only.
38pub struct SlackHealth {
39    client: OnceLock<SlackClient>,
40    readiness: bool,
41    cache: Mutex<Option<Entry>>,
42    cache_for: OnceLock<Duration>,
43    /// Lets one check run at a time.
44    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    /// Sets the client once. Later calls do nothing.
68    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    /// A result stored at or after `since`, whatever its TTL. A probe that
86    /// waited for another check uses that check's result.
87    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
125/// A short error code for public health output.
126fn error_code(err: &SlackError) -> String {
127    match err {
128        // Slack codes are short snake_case words. Keep only those characters.
129        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            // Single flight: a probe that waits here gets the new result,
161            // also with `cache_secs = 0`.
162            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}