1use 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
13const 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
40pub 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 _ => 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}