1#![allow(dead_code)]
2use std::collections::HashMap;
40use std::sync::{Arc, Mutex};
41use std::time::{Duration, Instant};
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45use tracing::{debug, info, warn};
46
47#[derive(Debug, Error)]
51pub enum WebhookError {
52 #[error("invalid webhook URL: {0}")]
54 InvalidUrl(String),
55 #[error("delivery failed to {url}: {reason}")]
57 DeliveryFailed { url: String, reason: String },
58 #[error("serialisation error: {0}")]
60 Serialisation(String),
61 #[error("internal lock poisoned")]
63 LockPoisoned,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum CircuitBreakerState {
71 Closed,
73 HalfOpen,
75 Open,
77}
78
79impl std::fmt::Display for CircuitBreakerState {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 CircuitBreakerState::Closed => write!(f, "CLOSED"),
83 CircuitBreakerState::HalfOpen => write!(f, "HALF-OPEN"),
84 CircuitBreakerState::Open => write!(f, "OPEN"),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "event_type", rename_all = "snake_case")]
94pub enum WebhookEvent {
95 CircuitBreakerStateChange {
97 worker: String,
99 from: CircuitBreakerState,
101 to: CircuitBreakerState,
103 failure_count: u32,
105 },
106 BudgetThresholdExceeded {
108 budget_name: String,
110 threshold_usd: f64,
112 current_spend_usd: f64,
114 percent_used: f64,
116 },
117 AnomalyDetected {
119 metric: String,
121 value: f64,
123 severity: f64,
125 description: String,
127 },
128 ThroughputDrop {
130 current_rps: f64,
132 baseline_rps: f64,
134 drop_percent: f64,
136 },
137}
138
139impl WebhookEvent {
140 #[must_use]
142 pub fn name(&self) -> &'static str {
143 match self {
144 WebhookEvent::CircuitBreakerStateChange { .. } => "circuit_breaker_state_change",
145 WebhookEvent::BudgetThresholdExceeded { .. } => "budget_threshold_exceeded",
146 WebhookEvent::AnomalyDetected { .. } => "anomaly_detected",
147 WebhookEvent::ThroughputDrop { .. } => "throughput_drop",
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct WebhookPayload {
157 pub timestamp: String,
159 pub service: String,
161 pub event: WebhookEvent,
163 pub context: HashMap<String, serde_json::Value>,
165}
166
167impl WebhookPayload {
168 #[must_use]
170 pub fn new(event: WebhookEvent, service: &str) -> Self {
171 Self {
172 timestamp: chrono::Utc::now().to_rfc3339(),
173 service: service.to_string(),
174 event,
175 context: HashMap::new(),
176 }
177 }
178
179 #[must_use]
181 pub fn with_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
182 self.context.insert(key.into(), value);
183 self
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct WebhookConfig {
192 pub url: String,
194 pub bearer_token: Option<String>,
196 pub extra_headers: HashMap<String, String>,
198 pub timeout: Duration,
200 pub filter_events: Vec<String>,
202}
203
204impl Default for WebhookConfig {
205 fn default() -> Self {
206 Self {
207 url: String::new(),
208 bearer_token: None,
209 extra_headers: HashMap::new(),
210 timeout: Duration::from_secs(5),
211 filter_events: vec![],
212 }
213 }
214}
215
216#[derive(Debug)]
219struct WebhookState {
220 config: WebhookConfig,
221 consecutive_failures: u32,
222 backoff_until: Option<Instant>,
223}
224
225impl WebhookState {
226 fn new(config: WebhookConfig) -> Self {
227 Self {
228 config,
229 consecutive_failures: 0,
230 backoff_until: None,
231 }
232 }
233
234 fn is_backing_off(&self) -> bool {
235 match self.backoff_until {
236 Some(until) => Instant::now() < until,
237 None => false,
238 }
239 }
240
241 fn record_failure(&mut self) {
242 self.consecutive_failures += 1;
243 if self.consecutive_failures >= 5 {
244 let backoff = Duration::from_secs(60);
245 self.backoff_until = Some(Instant::now() + backoff);
246 warn!(
247 url = %self.config.url,
248 failures = self.consecutive_failures,
249 "webhook backing off for 60s after consecutive failures"
250 );
251 }
252 }
253
254 fn record_success(&mut self) {
255 self.consecutive_failures = 0;
256 self.backoff_until = None;
257 }
258
259 fn passes_filter(&self, event: &WebhookEvent) -> bool {
260 if self.config.filter_events.is_empty() {
261 return true;
262 }
263 self.config.filter_events.iter().any(|f| f == event.name())
264 }
265}
266
267pub struct WebhookDispatcher {
271 service_name: String,
272 states: Arc<Mutex<Vec<WebhookState>>>,
273 http_client: reqwest::Client,
274}
275
276impl WebhookDispatcher {
277 pub fn new(configs: Vec<WebhookConfig>) -> Result<Arc<Self>, WebhookError> {
282 Self::with_service_name(configs, "tokio-prompt-orchestrator")
283 }
284
285 pub fn with_service_name(
290 configs: Vec<WebhookConfig>,
291 service_name: &str,
292 ) -> Result<Arc<Self>, WebhookError> {
293 for cfg in &configs {
294 if cfg.url.is_empty() {
295 return Err(WebhookError::InvalidUrl("URL must not be empty".to_string()));
296 }
297 }
298
299 let http_client = reqwest::Client::builder()
300 .timeout(Duration::from_secs(10))
301 .build()
302 .map_err(|e| WebhookError::InvalidUrl(e.to_string()))?;
303
304 let states = configs
305 .into_iter()
306 .map(WebhookState::new)
307 .collect::<Vec<_>>();
308
309 Ok(Arc::new(Self {
310 service_name: service_name.to_string(),
311 states: Arc::new(Mutex::new(states)),
312 http_client,
313 }))
314 }
315
316 pub async fn dispatch(&self, event: WebhookEvent) {
321 let payload = WebhookPayload::new(event, &self.service_name);
322 let body = match serde_json::to_string(&payload) {
323 Ok(b) => b,
324 Err(e) => {
325 warn!(error = %e, "failed to serialise webhook payload; skipping dispatch");
326 return;
327 }
328 };
329
330 type Target = (usize, String, Option<String>, HashMap<String, String>, Duration);
333 let targets: Vec<Target> = {
334 let Ok(states) = self.states.lock() else {
335 warn!("webhook dispatcher lock poisoned");
336 return;
337 };
338 states
339 .iter()
340 .enumerate()
341 .filter(|(_, s)| !s.is_backing_off() && s.passes_filter(&payload.event))
342 .map(|(i, s)| {
343 (
344 i,
345 s.config.url.clone(),
346 s.config.bearer_token.clone(),
347 s.config.extra_headers.clone(),
348 s.config.timeout,
349 )
350 })
351 .collect()
352 };
353
354 for (idx, url, bearer, extra_headers, timeout) in targets {
355 let result = self
356 .deliver(&url, &bearer, &extra_headers, timeout, &body)
357 .await;
358 let Ok(mut states) = self.states.lock() else {
359 continue;
360 };
361 match result {
362 Ok(()) => {
363 debug!(url = %url, event = %payload.event.name(), "webhook delivered");
364 if let Some(s) = states.get_mut(idx) {
365 s.record_success();
366 }
367 }
368 Err(e) => {
369 warn!(url = %url, error = %e, "webhook delivery failed");
370 if let Some(s) = states.get_mut(idx) {
371 s.record_failure();
372 }
373 }
374 }
375 }
376
377 info!(
378 event = %payload.event.name(),
379 service = %self.service_name,
380 "webhook dispatch complete"
381 );
382 }
383
384 async fn deliver(
385 &self,
386 url: &str,
387 bearer: &Option<String>,
388 extra_headers: &HashMap<String, String>,
389 timeout: Duration,
390 body: &str,
391 ) -> Result<(), WebhookError> {
392 let mut req = self
393 .http_client
394 .post(url)
395 .timeout(timeout)
396 .header("Content-Type", "application/json")
397 .header("User-Agent", "tokio-prompt-orchestrator/1.0")
398 .body(body.to_string());
399
400 if let Some(token) = bearer {
401 req = req.header("Authorization", format!("Bearer {token}"));
402 }
403 for (k, v) in extra_headers {
404 req = req.header(k.as_str(), v.as_str());
405 }
406
407 let resp = req.send().await.map_err(|e| WebhookError::DeliveryFailed {
408 url: url.to_string(),
409 reason: e.to_string(),
410 })?;
411
412 if !resp.status().is_success() {
413 return Err(WebhookError::DeliveryFailed {
414 url: url.to_string(),
415 reason: format!("HTTP {}", resp.status()),
416 });
417 }
418 Ok(())
419 }
420
421 pub fn backed_off_count(&self) -> usize {
423 let Ok(states) = self.states.lock() else {
424 return 0;
425 };
426 states.iter().filter(|s| s.is_backing_off()).count()
427 }
428
429 pub fn webhook_count(&self) -> usize {
431 let Ok(states) = self.states.lock() else {
432 return 0;
433 };
434 states.len()
435 }
436}
437
438#[must_use]
442pub fn circuit_breaker_event(
443 worker: impl Into<String>,
444 from: CircuitBreakerState,
445 to: CircuitBreakerState,
446 failure_count: u32,
447) -> WebhookEvent {
448 WebhookEvent::CircuitBreakerStateChange {
449 worker: worker.into(),
450 from,
451 to,
452 failure_count,
453 }
454}
455
456#[must_use]
458pub fn budget_exceeded_event(
459 budget_name: impl Into<String>,
460 threshold_usd: f64,
461 current_spend_usd: f64,
462) -> WebhookEvent {
463 let percent_used = if threshold_usd > 0.0 {
464 (current_spend_usd / threshold_usd * 100.0).min(999.9)
465 } else {
466 0.0
467 };
468 WebhookEvent::BudgetThresholdExceeded {
469 budget_name: budget_name.into(),
470 threshold_usd,
471 current_spend_usd,
472 percent_used,
473 }
474}
475
476#[must_use]
480pub fn throughput_drop_event(
481 current_rps: f64,
482 baseline_rps: f64,
483 min_drop_percent: f64,
484) -> Option<WebhookEvent> {
485 if baseline_rps <= 0.0 {
486 return None;
487 }
488 let drop_percent = ((baseline_rps - current_rps) / baseline_rps * 100.0).max(0.0);
489 if drop_percent < min_drop_percent {
490 return None;
491 }
492 Some(WebhookEvent::ThroughputDrop {
493 current_rps,
494 baseline_rps,
495 drop_percent,
496 })
497}
498
499#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn webhook_payload_serialises() {
507 let event = circuit_breaker_event(
508 "openai",
509 CircuitBreakerState::Closed,
510 CircuitBreakerState::Open,
511 5,
512 );
513 let payload = WebhookPayload::new(event, "test-service");
514 let json = serde_json::to_string(&payload).expect("should serialise");
515 assert!(json.contains("circuit_breaker_state_change"));
516 assert!(json.contains("openai"));
517 }
518
519 #[test]
520 fn event_name_helpers() {
521 assert_eq!(
522 circuit_breaker_event(
523 "w",
524 CircuitBreakerState::Open,
525 CircuitBreakerState::HalfOpen,
526 3
527 )
528 .name(),
529 "circuit_breaker_state_change"
530 );
531 assert_eq!(
532 budget_exceeded_event("monthly", 100.0, 110.0).name(),
533 "budget_threshold_exceeded"
534 );
535 }
536
537 #[test]
538 fn throughput_drop_event_threshold() {
539 assert!(throughput_drop_event(100.0, 100.0, 50.0).is_none());
540 assert!(throughput_drop_event(40.0, 100.0, 50.0).is_some());
541 assert!(throughput_drop_event(51.0, 100.0, 50.0).is_none());
542 let evt = throughput_drop_event(20.0, 100.0, 50.0).unwrap();
543 if let WebhookEvent::ThroughputDrop { drop_percent, .. } = evt {
544 assert!((drop_percent - 80.0).abs() < 0.01);
545 } else {
546 panic!("wrong variant");
547 }
548 }
549
550 #[test]
551 fn dispatcher_invalid_url_rejected() {
552 let result = WebhookDispatcher::new(vec![WebhookConfig {
553 url: String::new(),
554 ..Default::default()
555 }]);
556 assert!(result.is_err());
557 }
558
559 #[test]
560 fn dispatcher_no_webhooks() {
561 let dispatcher = WebhookDispatcher::new(vec![]).expect("empty ok");
562 assert_eq!(dispatcher.webhook_count(), 0);
563 assert_eq!(dispatcher.backed_off_count(), 0);
564 }
565
566 #[test]
567 fn webhook_state_backoff_after_5_failures() {
568 let mut state = WebhookState::new(WebhookConfig {
569 url: "http://example.com".into(),
570 ..Default::default()
571 });
572 assert!(!state.is_backing_off());
573 for _ in 0..5 {
574 state.record_failure();
575 }
576 assert!(state.is_backing_off());
577 state.record_success();
578 assert!(!state.is_backing_off());
579 }
580
581 #[test]
582 fn webhook_filter_passes_matching() {
583 let state = WebhookState::new(WebhookConfig {
584 url: "http://example.com".into(),
585 filter_events: vec!["anomaly_detected".into()],
586 ..Default::default()
587 });
588 let anomaly_event = WebhookEvent::AnomalyDetected {
589 metric: "latency".into(),
590 value: 99.9,
591 severity: 4.5,
592 description: "high latency".into(),
593 };
594 assert!(state.passes_filter(&anomaly_event));
595
596 let cb_event = circuit_breaker_event(
597 "w",
598 CircuitBreakerState::Closed,
599 CircuitBreakerState::Open,
600 1,
601 );
602 assert!(!state.passes_filter(&cb_event));
603 }
604
605 #[test]
606 fn budget_exceeded_percent_computed() {
607 let evt = budget_exceeded_event("monthly", 100.0, 75.0);
608 if let WebhookEvent::BudgetThresholdExceeded { percent_used, .. } = evt {
609 assert!((percent_used - 75.0).abs() < 0.01);
610 } else {
611 panic!("wrong variant");
612 }
613 }
614}