Skip to main content

forge_agent/cloud/
service_status.rs

1//! Anthropic statuspage poller. Pulls the public summary endpoint and
2//! classifies whether Claude Code or the underlying Claude API is in
3//! a degraded state.
4
5use serde::Deserialize;
6use std::time::Duration;
7
8pub use forge_primitives::cloud::service_status::{ServiceIssue, ServiceSeverity};
9
10const SERVICE_STATUS_TIMEOUT: Duration = Duration::from_secs(4);
11const STATUSPAGE_SUMMARY_URL: &str = "https://status.claude.com/api/v2/summary.json";
12
13/// Component names we care about. "Claude Code" is the primary
14/// component; "Claude API" is included because Claude Code depends on it.
15const RELEVANT_COMPONENTS: &[&str] = &["Claude Code", "Claude API (api.anthropic.com)"];
16
17#[derive(Debug, Deserialize)]
18struct SummaryResponse {
19    components: Vec<Component>,
20    incidents: Vec<Incident>,
21}
22
23#[derive(Debug, Deserialize)]
24struct Component {
25    name: String,
26    status: String,
27}
28
29#[derive(Debug, Deserialize)]
30struct Incident {
31    name: String,
32    components: Vec<IncidentComponent>,
33}
34
35#[derive(Debug, Deserialize)]
36struct IncidentComponent {
37    name: String,
38}
39
40/// Fetch + classify the public statuspage summary. Returns `None` if
41/// every relevant component is operational, or if the request fails.
42///
43/// Each error branch logs so "couldn't reach statuspage" stops being
44/// indistinguishable from "everything is operational" in postmortems.
45/// Levels: routine network/HTTP failures (offline, captive portal,
46/// 5xx, transient 429) at `info` to keep noise down on the default
47/// filter; deserialise failures at `warn` since they signal a contract
48/// violation worth surfacing immediately. (Full Result restructuring
49/// would cascade through the UI's banner code; the structured logs
50/// give the same triage signal at lower cost.)
51pub async fn fetch_service_status() -> Option<ServiceIssue> {
52    let client = match crate::http_trust::with_extra_roots(
53        reqwest::Client::builder().timeout(SERVICE_STATUS_TIMEOUT),
54    )
55    .build()
56    {
57        Ok(c) => c,
58        Err(e) => {
59            tracing::info!(
60                target: crate::logging::targets::SERVICE_STATUS,
61                event_name = "service_check_failed",
62                message = "service status http client build failed",
63                outcome = "failure",
64                error = %e,
65                url = STATUSPAGE_SUMMARY_URL,
66            );
67            return None;
68        }
69    };
70    let response = match client.get(STATUSPAGE_SUMMARY_URL).send().await {
71        Ok(r) => r,
72        Err(e) => {
73            tracing::info!(
74                target: crate::logging::targets::SERVICE_STATUS,
75                event_name = "service_check_failed",
76                message = "service status request failed",
77                outcome = "failure",
78                error = %e,
79                url = STATUSPAGE_SUMMARY_URL,
80            );
81            return None;
82        }
83    };
84    if !response.status().is_success() {
85        tracing::info!(
86            target: crate::logging::targets::SERVICE_STATUS,
87            event_name = "service_check_failed",
88            message = "service status request returned non-success",
89            outcome = "failure",
90            status = %response.status(),
91            url = STATUSPAGE_SUMMARY_URL,
92        );
93        return None;
94    }
95    let payload = match response.json::<SummaryResponse>().await {
96        Ok(p) => p,
97        Err(e) => {
98            tracing::warn!(
99                target: crate::logging::targets::SERVICE_STATUS,
100                event_name = "service_check_failed",
101                message = "service status payload deserialise failed (contract violation)",
102                outcome = "failure",
103                error = %e,
104                url = STATUSPAGE_SUMMARY_URL,
105            );
106            return None;
107        }
108    };
109    classify_summary(&payload)
110}
111
112fn is_relevant_component(name: &str) -> bool {
113    RELEVANT_COMPONENTS.contains(&name)
114}
115
116fn classify_component_status(status: &str) -> Option<ServiceSeverity> {
117    match status {
118        "operational" | "under_maintenance" => None,
119        "major_outage" => Some(ServiceSeverity::Error),
120        // degraded_performance, partial_outage, or unknown
121        _ => Some(ServiceSeverity::Warning),
122    }
123}
124
125fn classify_summary(summary: &SummaryResponse) -> Option<ServiceIssue> {
126    let worst_severity = summary
127        .components
128        .iter()
129        .filter(|c| is_relevant_component(&c.name))
130        .filter_map(|c| classify_component_status(&c.status))
131        .max_by_key(|s| match s {
132            ServiceSeverity::Warning => 0,
133            ServiceSeverity::Error => 1,
134        });
135
136    let severity = worst_severity?;
137
138    let relevant_incident = summary
139        .incidents
140        .iter()
141        .find(|incident| incident.components.iter().any(|c| is_relevant_component(&c.name)));
142
143    let message = if let Some(incident) = relevant_incident {
144        format!("Claude Code status: {}.", incident.name.trim())
145    } else {
146        "Claude Code status indicates a service disruption.".to_owned()
147    };
148
149    Some(ServiceIssue { severity, message })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn component(name: &str, status: &str) -> Component {
157        Component { name: name.to_owned(), status: status.to_owned() }
158    }
159
160    fn incident(name: &str, component_names: &[&str]) -> Incident {
161        Incident {
162            name: name.to_owned(),
163            components: component_names
164                .iter()
165                .map(|n| IncidentComponent { name: (*n).to_owned() })
166                .collect(),
167        }
168    }
169
170    fn summary(components: Vec<Component>, incidents: Vec<Incident>) -> SummaryResponse {
171        SummaryResponse { components, incidents }
172    }
173
174    #[test]
175    fn all_operational_is_healthy() {
176        let s = summary(
177            vec![component("Claude Code", "operational"), component("claude.ai", "operational")],
178            vec![],
179        );
180        assert!(classify_summary(&s).is_none());
181    }
182
183    #[test]
184    fn only_unrelated_component_degraded_is_healthy() {
185        let s = summary(
186            vec![
187                component("Claude Code", "operational"),
188                component("claude.ai", "degraded_performance"),
189                component("Claude for Government", "major_outage"),
190            ],
191            vec![],
192        );
193        assert!(classify_summary(&s).is_none());
194    }
195
196    #[test]
197    fn claude_code_degraded_is_warning() {
198        let s = summary(vec![component("Claude Code", "degraded_performance")], vec![]);
199        let issue = classify_summary(&s).expect("expected issue");
200        assert_eq!(issue.severity, ServiceSeverity::Warning);
201    }
202
203    #[test]
204    fn claude_code_major_outage_is_error() {
205        let s = summary(vec![component("Claude Code", "major_outage")], vec![]);
206        let issue = classify_summary(&s).expect("expected issue");
207        assert_eq!(issue.severity, ServiceSeverity::Error);
208    }
209
210    #[test]
211    fn claude_api_degraded_triggers_warning() {
212        let s = summary(
213            vec![
214                component("Claude Code", "operational"),
215                component("Claude API (api.anthropic.com)", "partial_outage"),
216            ],
217            vec![],
218        );
219        let issue = classify_summary(&s).expect("expected issue");
220        assert_eq!(issue.severity, ServiceSeverity::Warning);
221    }
222
223    #[test]
224    fn worst_severity_wins() {
225        let s = summary(
226            vec![
227                component("Claude Code", "degraded_performance"),
228                component("Claude API (api.anthropic.com)", "major_outage"),
229            ],
230            vec![],
231        );
232        let issue = classify_summary(&s).expect("expected issue");
233        assert_eq!(issue.severity, ServiceSeverity::Error);
234    }
235
236    #[test]
237    fn uses_incident_name_in_message() {
238        let s = summary(
239            vec![component("Claude Code", "degraded_performance")],
240            vec![incident("Elevated errors on Claude Opus 4", &["Claude Code", "claude.ai"])],
241        );
242        let issue = classify_summary(&s).expect("expected issue");
243        assert_eq!(issue.message, "Claude Code status: Elevated errors on Claude Opus 4.");
244    }
245
246    #[test]
247    fn fallback_message_without_relevant_incident() {
248        let s = summary(
249            vec![component("Claude Code", "partial_outage")],
250            vec![incident("API issue", &["claude.ai"])],
251        );
252        let issue = classify_summary(&s).expect("expected issue");
253        assert_eq!(issue.message, "Claude Code status indicates a service disruption.");
254    }
255
256    #[test]
257    fn ignores_irrelevant_incident() {
258        let s = summary(
259            vec![
260                component("Claude Code", "operational"),
261                component("claude.ai", "degraded_performance"),
262            ],
263            vec![incident("claude.ai degraded", &["claude.ai"])],
264        );
265        assert!(classify_summary(&s).is_none());
266    }
267}