1use chrono::TimeZone;
19use regex::Regex;
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock};
22
23pub const AGENT_COOLDOWN_SECS: i64 = 30 * 60;
29
30pub const BACKOFF_BASE_SECS: i64 = 5 * 60;
32
33pub const BACKOFF_MAX_SECS: i64 = 4 * 60 * 60;
35
36pub const PERSISTENT_MODEL_BACKOFF_BASE_SECS: i64 = 4 * 60 * 60;
38
39pub const PERSISTENT_MODEL_BACKOFF_MAX_SECS: i64 = 7 * 24 * 60 * 60;
41
42pub const TRANSIENT_MODEL_BACKOFF_BASE_SECS: i64 = 5 * 60;
44
45pub const TRANSIENT_MODEL_BACKOFF_MAX_SECS: i64 = 15 * 60;
47
48pub const CREDIT_BACKOFF_BASE_SECS: i64 = 60 * 60;
50
51pub const ORG_BACKOFF_BASE_SECS: i64 = 2 * 60 * 60;
53
54pub const CREDIT_BACKOFF_MAX_SECS: i64 = 8 * 60 * 60;
57
58pub const BILLING_CYCLE_COOLDOWN_SECS: i64 = 24 * 60 * 60;
66
67pub const BILLING_CYCLE_MAX_SECS: i64 = 7 * 24 * 60 * 60;
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum CreditExhaustionReason {
77 OutOfCredits,
79 OrgLevelDisabled,
81 BillingCycleExhausted,
83}
84
85impl CreditExhaustionReason {
86 pub fn outcome_str(self) -> &'static str {
90 match self {
91 CreditExhaustionReason::OutOfCredits => "credit_exhausted",
92 CreditExhaustionReason::OrgLevelDisabled => "org_level_disabled",
93 CreditExhaustionReason::BillingCycleExhausted => "billing_cycle_exhausted",
94 }
95 }
96}
97
98pub const SILENCE_AGENT_COOLDOWN_SECS: u64 = 120;
105
106pub const SILENCE_COUNT_THRESHOLD: usize = 5;
108
109pub const SILENCE_COUNT_WINDOW_SECS: i64 = 24 * 60 * 60;
111
112pub const SILENCE_EXTENDED_COOLDOWN_SECS: u64 = 4 * 60 * 60;
114
115const KV_PREFIX: &str = "cooldown:";
117
118const SILENCE_COUNT_PREFIX: &str = "silence_count:";
120
121const FAILURE_COUNT_PREFIX: &str = "failure_count:";
123
124const CREDIT_FAILURE_COUNT_PREFIX: &str = "credit_failure_count:";
127
128const PERSISTENT_FAILURE_COUNT_PREFIX: &str = "persistent_failure_count:";
132
133const RATE_LIMITS_CLEARED_AT_PREFIX: &str = "rate_limits_cleared_at:";
135
136fn agent_from_key(key: &str) -> &str {
138 key.split_once(':').map_or(key, |(agent, _)| agent)
139}
140
141struct CooldownEntry {
142 cooldown_until: i64,
144 reason: String,
145}
146
147#[derive(Debug)]
148pub struct SilenceCountResult {
149 pub count: usize,
150 pub extended_cooldown_applied: bool,
151}
152
153fn cooldowns() -> &'static Mutex<HashMap<String, CooldownEntry>> {
162 static COOLDOWNS: OnceLock<Mutex<HashMap<String, CooldownEntry>>> = OnceLock::new();
163 COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()))
164}
165
166fn cooldown_store() -> &'static tokio::sync::Mutex<Option<Arc<crate::store::TaskStore>>> {
172 static STORE: OnceLock<tokio::sync::Mutex<Option<Arc<crate::store::TaskStore>>>> =
173 OnceLock::new();
174 STORE.get_or_init(|| tokio::sync::Mutex::new(None))
175}
176
177fn parse_cooldown_value(value: &str) -> Option<(i64, String)> {
185 if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(value) {
186 let until = map.get("until")?.as_i64()?;
187 let reason = map
188 .get("reason")
189 .and_then(|r| r.as_str())
190 .unwrap_or("persisted")
191 .to_string();
192 return Some((until, reason));
193 }
194 value
195 .parse::<i64>()
196 .ok()
197 .map(|until| (until, "persisted".to_string()))
198}
199
200pub async fn init_cooldown_store(store: Arc<crate::store::TaskStore>) {
201 match store.kv_list_prefix(KV_PREFIX).await {
202 Ok(rows) => {
203 let now = chrono::Utc::now().timestamp();
204 let mut map = cooldown_lock();
205 let mut loaded = 0;
206 for (key, value) in rows {
207 let cooldown_key = key.trim_start_matches(KV_PREFIX);
208 if let Some((cooldown_until, reason)) = parse_cooldown_value(&value)
209 && now < cooldown_until
210 {
211 map.insert(
212 cooldown_key.to_string(),
213 CooldownEntry {
214 cooldown_until,
215 reason,
216 },
217 );
218 loaded += 1;
219 }
220 }
223 if loaded > 0 {
224 tracing::info!(loaded, "loaded persisted cooldowns from KV store");
225 }
226 }
227 Err(e) => {
228 tracing::warn!(err = %e, "failed to load cooldowns from KV store");
229 }
230 }
231
232 if let Ok(Some(raw)) = store.kv_get("cooldown:github:5xx").await
234 && let Ok(cooldown_until) = raw.parse::<i64>()
235 {
236 let now = chrono::Utc::now().timestamp();
237 if now < cooldown_until {
238 let mut open = github_5xx_circuit_open_lock();
239 *open = Some(cooldown_until);
240 let remaining = cooldown_until - now;
241 tracing::warn!(
242 remaining_secs = remaining,
243 "GitHub 5xx circuit breaker restored from KV (still open)"
244 );
245 }
246 }
247
248 *cooldown_store().lock().await = Some(store);
249}
250
251pub async fn sync_from_kv(store: &Arc<crate::store::TaskStore>) {
268 let rows = match store.kv_list_prefix(KV_PREFIX).await {
269 Ok(rows) => rows,
270 Err(e) => {
271 tracing::warn!(err = %e, "cooldown sync_from_kv: KV list failed");
272 return;
273 }
274 };
275 let now = chrono::Utc::now().timestamp();
276
277 let mut explicit_kv: HashMap<String, Option<(i64, String)>> =
280 HashMap::with_capacity(rows.len());
281 for (kv_key, value) in &rows {
282 let cooldown_key = kv_key.trim_start_matches(KV_PREFIX);
283 if let Some((ts, reason)) = parse_cooldown_value(value) {
284 let normalized = if ts > now { Some((ts, reason)) } else { None };
285 explicit_kv.insert(cooldown_key.to_string(), normalized);
286 }
287 }
288
289 match explicit_kv.get("github:5xx") {
291 Some(None) => {
292 let mut open = github_5xx_circuit_open_lock();
293 if open.take().is_some() {
294 tracing::info!("GitHub 5xx circuit breaker closed by KV clear");
295 github_5xx_timestamps_lock().clear();
296 }
297 }
298 Some(Some((ts, _))) => {
299 *github_5xx_circuit_open_lock() = Some(*ts);
300 }
301 None => {}
302 }
303
304 let mut map = cooldown_lock();
305 let mut dropped = 0usize;
306 let mut updated = 0usize;
307
308 map.retain(|key, _| {
310 if matches!(explicit_kv.get(key), Some(None)) {
311 dropped += 1;
312 false
313 } else {
314 true
315 }
316 });
317
318 for (key, normalized) in explicit_kv {
320 if let Some((ts, reason)) = normalized {
321 if let Some(entry) = map.get_mut(&key) {
322 if entry.cooldown_until != ts {
323 entry.cooldown_until = ts;
324 updated += 1;
325 }
326 } else {
327 map.insert(
328 key,
329 CooldownEntry {
330 cooldown_until: ts,
331 reason,
332 },
333 );
334 updated += 1;
335 }
336 }
337 }
338
339 if dropped > 0 || updated > 0 {
340 tracing::info!(
341 dropped,
342 updated,
343 "cooldown sync_from_kv: reconciled in-memory map with KV"
344 );
345 }
346}
347
348const EXTENDED_TIER_1_THRESHOLD: u64 = 2;
352
353const EXTENDED_TIER_1_MULTIPLIER: i64 = 6;
355
356const EXTENDED_TIER_2_THRESHOLD: u64 = 5;
360
361const EXTENDED_TIER_2_MULTIPLIER: i64 = 12;
363
364pub fn compute_backoff(count: u64, base: i64, max: i64, allow_extended: bool) -> i64 {
383 if count <= 1 {
384 return base;
385 }
386 let exp = u32::try_from(count.saturating_sub(1)).unwrap_or(u32::MAX);
387 let factor = 3_i64.saturating_pow(exp);
388 let standard = base.saturating_mul(factor).min(max);
389
390 let first_max = if base <= 0 {
399 0
400 } else {
401 let mut accumulated = base;
403 let mut first_max_count = 1u64;
404 while accumulated < max && first_max_count < count {
405 accumulated = accumulated.saturating_mul(3);
406 first_max_count += 1;
407 }
408 first_max_count
409 };
410
411 let times_at_max = count.saturating_sub(first_max);
413
414 if !allow_extended {
415 return standard;
416 }
417
418 if standard == max && times_at_max >= EXTENDED_TIER_2_THRESHOLD {
419 max.saturating_mul(EXTENDED_TIER_2_MULTIPLIER)
420 } else if standard == max && times_at_max >= EXTENDED_TIER_1_THRESHOLD {
421 max.saturating_mul(EXTENDED_TIER_1_MULTIPLIER)
422 } else {
423 standard
424 }
425}
426
427async fn read_and_increment_failure_count(
436 store_opt: Option<&Arc<crate::store::TaskStore>>,
437 key: &str,
438) -> u64 {
439 read_and_increment_failure_count_with_prefix(store_opt, FAILURE_COUNT_PREFIX, key).await
440}
441
442async fn read_and_increment_failure_count_with_prefix(
447 store_opt: Option<&Arc<crate::store::TaskStore>>,
448 prefix: &str,
449 key: &str,
450) -> u64 {
451 let kv_key = format!("{prefix}{key}");
452 if let Some(store) = store_opt {
453 match store.kv_increment(&kv_key).await {
454 Ok(n) => n,
455 Err(e) => {
456 tracing::warn!(key = %kv_key, err = %e, "failed to increment failure count — treating as high count for safe backoff");
457 u64::MAX
458 }
459 }
460 } else {
461 1
462 }
463}
464
465pub async fn record_agent_success(agent_name: &str, model: &str) {
471 let store_opt = cooldown_store().lock().await.clone();
472 if let Some(store) = store_opt {
473 let agent_key = format!("{FAILURE_COUNT_PREFIX}{agent_name}");
474 let credit_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{agent_name}");
475 if let Err(e) = store.kv_set(&agent_key, "0").await {
476 tracing::warn!(key = agent_key, err = %e, "failed to reset failure count");
477 }
478 if !model.is_empty() {
479 let model_key = format!("{FAILURE_COUNT_PREFIX}{agent_name}:{model}");
480 if let Err(e) = store.kv_set(&model_key, "0").await {
481 tracing::warn!(key = model_key, err = %e, "failed to reset failure count");
482 }
483 }
484 if let Err(e) = store.kv_set(&credit_key, "0").await {
485 tracing::warn!(key = credit_key, err = %e, "failed to reset failure count");
486 }
487 }
488}
489
490pub async fn record_agent_failure_with_message(agent_name: &str, error_message: &str) {
496 if let Some(cooldown_until) = parse_retry_at(error_message) {
501 let now = chrono::Utc::now().timestamp();
502 if cooldown_until > now {
503 set_cooldown_async(agent_name, cooldown_until, "agent_error").await;
504 return;
505 }
506 tracing::debug!(
507 agent = agent_name,
508 cooldown_until,
509 "vendor retry_at is in the past — using backoff instead"
510 );
511 }
512
513 let store_opt = cooldown_store().lock().await.clone();
514 let count = read_and_increment_failure_count(store_opt.as_ref(), agent_name).await;
515 let base = crate::engine::router::config::get_agent_backoff_base(agent_name);
516 let cooldown_until =
517 chrono::Utc::now().timestamp() + compute_backoff(count, base, BACKOFF_MAX_SECS, true);
518 set_cooldown_async(agent_name, cooldown_until, "agent_error").await;
519}
520
521pub fn detect_credit_exhaustion_outcome(error_message: &str) -> Option<&'static str> {
530 detect_credit_exhaustion(error_message).map(CreditExhaustionReason::outcome_str)
531}
532
533pub fn detect_credit_exhaustion(error_message: &str) -> Option<CreditExhaustionReason> {
538 let lower = error_message.to_lowercase();
539
540 let overage_patterns = [
541 "overagedisabledreason",
542 "overage_disabled_reason",
543 "overage disabled reason",
544 ];
545
546 if overage_patterns.iter().any(|p| lower.contains(p)) {
547 if lower.contains("out_of_credits") || lower.contains("outofcredits") {
548 return Some(CreditExhaustionReason::OutOfCredits);
549 }
550 if lower.contains("org_level_disabled") || lower.contains("orgdisabled") {
551 return Some(CreditExhaustionReason::OrgLevelDisabled);
552 }
553 }
554
555 let billing_patterns = [
556 "credit balance too low",
557 "credit balance insufficient",
558 "insufficient credit balance",
559 "billing quota exceeded",
560 "billing limit exceeded",
561 "organization has been disabled",
562 "org disabled",
563 ];
564
565 if billing_patterns.iter().any(|p| lower.contains(p)) {
566 return Some(CreditExhaustionReason::OrgLevelDisabled);
567 }
568
569 let billing_cycle_patterns = [
572 "billing cycle",
573 "quota refreshed next cycle",
574 "monthly quota",
575 "quota will be refreshed",
576 "next billing cycle",
577 "refreshed in the next cycle",
578 "weekly/monthly limit exhausted",
579 "limit will reset at",
580 "upgrade to pro", "upgrade your token plan", "token plan usage limit", "purchase credits for more", ];
585
586 if billing_cycle_patterns.iter().any(|p| lower.contains(p)) {
587 return Some(CreditExhaustionReason::BillingCycleExhausted);
588 }
589
590 if lower.contains("out of credits")
591 || lower.contains("outofcredits")
592 || lower.contains("insufficient funds")
593 || lower.contains("no credits remaining")
594 || lower.contains("insufficient_quota")
595 || lower.contains("quota exceeded")
596 || lower.contains("insufficient balance")
597 || lower.contains("please recharge")
598 || lower.contains("no resource package")
599 || lower.contains("balance exhausted")
600 || lower.contains("402 payment required")
601 {
602 return Some(CreditExhaustionReason::OutOfCredits);
603 }
604
605 None
606}
607
608pub async fn record_credit_exhaustion(agent_name: &str, reason: CreditExhaustionReason) {
621 let reason_str = match reason {
622 CreditExhaustionReason::OutOfCredits => "credit_exhaustion_out_of_credits",
623 CreditExhaustionReason::OrgLevelDisabled => "credit_exhaustion_org_level_disabled",
624 CreditExhaustionReason::BillingCycleExhausted => "billing_cycle_exhausted",
625 };
626
627 if is_in_cooldown_with_reason(agent_name, reason_str) {
629 tracing::debug!(
630 agent = agent_name,
631 reason = reason_str,
632 "credit exhaustion already cooled, not escalating"
633 );
634 return;
635 }
636
637 let store_opt = cooldown_store().lock().await.clone();
638 let count = read_and_increment_failure_count_with_prefix(
643 store_opt.as_ref(),
644 CREDIT_FAILURE_COUNT_PREFIX,
645 agent_name,
646 )
647 .await;
648
649 let (base, max) = match reason {
650 CreditExhaustionReason::OutOfCredits => (CREDIT_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS),
651 CreditExhaustionReason::OrgLevelDisabled => {
652 (ORG_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS)
653 }
654 CreditExhaustionReason::BillingCycleExhausted => {
655 (BILLING_CYCLE_COOLDOWN_SECS, BILLING_CYCLE_MAX_SECS)
656 }
657 };
658 let cooldown_secs = compute_backoff(
659 count,
660 base,
661 max,
662 !matches!(reason, CreditExhaustionReason::BillingCycleExhausted),
663 );
664 let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
665 set_cooldown_async(agent_name, cooldown_until, reason_str).await;
666 tracing::warn!(
667 agent = agent_name,
668 reason = reason_str,
669 attempt = count,
670 cooldown_secs,
671 "credit exhaustion detected: applying exponential agent-wide cooldown"
672 );
673}
674
675pub async fn record_credit_exhaustion_from_message(
677 agent_name: &str,
678 model: Option<&str>,
679 message: &str,
680) -> bool {
681 let Some(reason) = detect_credit_exhaustion(message) else {
682 return false;
683 };
684 match (reason, model) {
685 (CreditExhaustionReason::BillingCycleExhausted, Some(model)) => {
686 record_persistent_model_failure(agent_name, model).await;
687 }
688 _ => record_credit_exhaustion(agent_name, reason).await,
689 }
690 true
691}
692
693pub async fn record_model_failure(agent_name: &str, model: &str) {
697 let key = format!("{agent_name}:{model}");
698 let store_opt = cooldown_store().lock().await.clone();
699 let count = read_and_increment_failure_count(store_opt.as_ref(), &key).await;
700 let base = crate::engine::router::config::get_agent_backoff_base(agent_name);
701 let cooldown_until =
702 chrono::Utc::now().timestamp() + compute_backoff(count, base, BACKOFF_MAX_SECS, true);
703 set_cooldown_async(&key, cooldown_until, "model_error").await;
704}
705
706pub async fn record_rate_limit(agent_name: &str, model: Option<&str>, error_message: &str) {
714 if let Some(window_secs) = parse_relative_usage_window(error_message)
715 && let Some(m) = model
716 {
717 let cooldown_until = chrono::Utc::now()
718 .timestamp()
719 .saturating_add(i64::try_from(window_secs).unwrap_or(i64::MAX));
720 set_model_cooldown(agent_name, m, window_secs, "rate_limit_usage_window").await;
721 tracing::info!(
722 agent = agent_name,
723 model = m,
724 window_secs,
725 cooldown_until,
726 "relative usage window detected: applying model-specific cooldown"
727 );
728 return;
729 }
730
731 record_agent_failure_with_message(agent_name, error_message).await;
732 if let Some(m) = model {
733 record_model_failure(agent_name, m).await;
734 }
735}
736
737pub async fn record_persistent_model_failure(agent_name: &str, model: &str) {
749 let key = format!("{agent_name}:{model}");
750 let store_opt = cooldown_store().lock().await.clone();
751 let count = read_and_increment_failure_count_with_prefix(
752 store_opt.as_ref(),
753 PERSISTENT_FAILURE_COUNT_PREFIX,
754 &key,
755 )
756 .await;
757 let cooldown_secs = compute_backoff(
758 count,
759 PERSISTENT_MODEL_BACKOFF_BASE_SECS,
760 PERSISTENT_MODEL_BACKOFF_MAX_SECS,
761 false,
762 );
763 let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
764 set_cooldown_async(&key, cooldown_until, "persistent_model_error").await;
765}
766
767pub async fn record_model_unavailable(agent_name: &str, model: &str, message: &str) {
772 if is_model_permanently_gone(message) {
773 record_persistent_model_failure(agent_name, model).await;
774 } else {
775 record_model_failure(agent_name, model).await;
776 }
777}
778
779fn is_model_permanently_gone(message: &str) -> bool {
780 let lower = message.to_lowercase();
781 lower.contains("not found")
782 || lower.contains("not supported")
783 || lower.contains("not available in your country")
784 || lower.contains("not available in your region")
785}
786
787pub async fn record_transient_model_failure(agent_name: &str, model: &str) {
793 let key = format!("{agent_name}:{model}");
794 let store_opt = cooldown_store().lock().await.clone();
795 let count = read_and_increment_failure_count(store_opt.as_ref(), &key).await;
796 let cooldown_secs = compute_backoff(
797 count,
798 TRANSIENT_MODEL_BACKOFF_BASE_SECS,
799 TRANSIENT_MODEL_BACKOFF_MAX_SECS,
800 false,
801 );
802 let cooldown_until = chrono::Utc::now().timestamp() + cooldown_secs;
803 set_cooldown_async(&key, cooldown_until, "transient_model_error").await;
804}
805
806pub async fn set_model_cooldown(agent_name: &str, model: &str, duration_secs: u64, reason: &str) {
813 let key = format!("{agent_name}:{model}");
814 let cooldown_until = chrono::Utc::now()
815 .timestamp()
816 .saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX));
817 set_cooldown_async(&key, cooldown_until, reason).await;
818}
819
820pub async fn set_model_cooldown_until(agent_name: &str, model: &str, until: i64) {
822 let key = format!("{agent_name}:{model}");
823 set_cooldown_async(&key, until, "vendor_reset_at").await;
824}
825
826pub async fn set_agent_cooldown(agent_name: &str, duration_secs: u64) {
833 let cooldown_until = chrono::Utc::now()
834 .timestamp()
835 .saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX));
836 set_cooldown_async(agent_name, cooldown_until, "silence_agent_cooldown").await;
837}
838
839pub async fn record_silence_detection(agent_name: &str, model: &str) -> Option<SilenceCountResult> {
842 let store_opt = cooldown_store().lock().await.clone();
843 let Some(store) = store_opt else {
844 tracing::debug!(
845 agent = agent_name,
846 model,
847 "skipping silence count record (no KV store)"
848 );
849 return None;
850 };
851
852 let key = format!("{SILENCE_COUNT_PREFIX}{agent_name}:{model}");
853 let now = chrono::Utc::now().timestamp();
854 let window_start = now - SILENCE_COUNT_WINDOW_SECS;
855
856 let mut timestamps = match store.kv_get(&key).await {
857 Ok(Some(raw)) => match serde_json::from_str::<Vec<i64>>(&raw) {
858 Ok(ts) => ts,
859 Err(e) => {
860 tracing::warn!(
861 kv_key = %key,
862 err = %e,
863 "failed to parse silence timestamps from KV — resetting window"
864 );
865 Vec::new()
866 }
867 },
868 Ok(None) => Vec::new(),
869 Err(err) => {
870 tracing::warn!(
871 kv_key = %key,
872 err = %err,
873 "failed to load silence count from KV"
874 );
875 Vec::new()
876 }
877 };
878
879 timestamps.retain(|ts| *ts >= window_start);
880 timestamps.push(now);
881
882 let count = timestamps.len();
883 let mut extended_cooldown_applied = false;
884 if count >= SILENCE_COUNT_THRESHOLD {
885 set_model_cooldown(
886 agent_name,
887 model,
888 SILENCE_EXTENDED_COOLDOWN_SECS,
889 "silence_detected",
890 )
891 .await;
892 extended_cooldown_applied = true;
893 timestamps.clear();
894 }
895
896 let value = serde_json::to_string(×tamps).unwrap_or_else(|_| "[]".to_string());
897 if let Err(err) = store.kv_set(&key, &value).await {
898 tracing::warn!(kv_key = key, err = %err, "failed to persist silence count");
899 }
900
901 Some(SilenceCountResult {
902 count,
903 extended_cooldown_applied,
904 })
905}
906
907pub fn is_model_in_cooldown(agent_name: &str, model: &str) -> bool {
909 let key = format!("{agent_name}:{model}");
910 is_in_cooldown(&key)
911}
912
913pub fn is_agent_in_cooldown(agent_name: &str) -> bool {
915 is_in_cooldown(agent_name)
916}
917
918pub fn cooldown_until(key: &str) -> Option<i64> {
920 let map = cooldown_lock();
921 map.get(key).and_then(|entry| {
922 let now = chrono::Utc::now().timestamp();
923 if now < entry.cooldown_until {
924 Some(entry.cooldown_until)
925 } else {
926 None
927 }
928 })
929}
930
931pub fn cooldown_reason(key: &str) -> Option<String> {
933 let map = cooldown_lock();
934 map.get(key).and_then(|entry| {
935 let now = chrono::Utc::now().timestamp();
936 if now < entry.cooldown_until {
937 Some(entry.reason.clone())
938 } else {
939 None
940 }
941 })
942}
943
944pub fn list_all_cooldowns() -> Vec<(String, i64, String)> {
949 let now = chrono::Utc::now().timestamp();
950 let map = cooldown_lock();
951 let mut result: Vec<(String, i64, String)> = map
952 .iter()
953 .filter(|(_, entry)| now < entry.cooldown_until)
954 .map(|(key, entry)| (key.clone(), entry.cooldown_until, entry.reason.clone()))
955 .collect();
956 result.sort_by(|a, b| a.0.cmp(&b.0));
957 result
958}
959
960pub async fn mark_rate_limits_cleared(store: &Arc<crate::store::TaskStore>, agents: &[String]) {
963 let now = chrono::Utc::now().timestamp().to_string();
964 let mut seen = std::collections::HashSet::new();
965 for key in agents {
966 let agent = agent_from_key(key);
967 if agent.is_empty() || !seen.insert(agent.to_string()) {
968 continue;
969 }
970 let kv_key = format!("{RATE_LIMITS_CLEARED_AT_PREFIX}{agent}");
971 if let Err(e) = store.kv_set(&kv_key, &now).await {
972 tracing::warn!(agent, err = %e, "failed to mark rate limits cleared");
973 }
974 }
975}
976
977pub async fn clear_cooldown(key: &str, store: &Arc<crate::store::TaskStore>) {
984 let mut agents_to_mark: Vec<String> = Vec::new();
985 if key == "*" {
986 let keys: Vec<String> = {
987 let mut map = cooldown_lock();
988 let keys: Vec<String> = map.keys().cloned().collect();
989 map.clear();
990 keys
991 };
992 for k in &keys {
993 let kv_key = format!("{KV_PREFIX}{k}");
994 if let Err(e) = store.kv_set(&kv_key, "0").await {
995 tracing::warn!(key = kv_key, err = %e, "failed to clear cooldown");
996 }
997 let fc_key = format!("{FAILURE_COUNT_PREFIX}{k}");
999 if let Err(e) = store.kv_set(&fc_key, "0").await {
1000 tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1001 }
1002 let credit_fc_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{k}");
1004 if let Err(e) = store.kv_set(&credit_fc_key, "0").await {
1005 tracing::warn!(key = credit_fc_key, err = %e, "failed to reset credit failure count");
1006 }
1007 let persistent_fc_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{k}");
1009 if let Err(e) = store.kv_set(&persistent_fc_key, "0").await {
1010 tracing::warn!(key = persistent_fc_key, err = %e, "failed to reset persistent failure count");
1011 }
1012 }
1013 if let Ok(fc_entries) = store.kv_list_prefix(FAILURE_COUNT_PREFIX).await {
1017 for (fc_key, _) in fc_entries {
1018 if let Err(e) = store.kv_set(&fc_key, "0").await {
1019 tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1020 }
1021 }
1022 }
1023 if let Ok(cfc_entries) = store.kv_list_prefix(CREDIT_FAILURE_COUNT_PREFIX).await {
1024 for (cfc_key, _) in cfc_entries {
1025 if let Err(e) = store.kv_set(&cfc_key, "0").await {
1026 tracing::warn!(key = cfc_key, err = %e, "failed to reset credit failure count");
1027 }
1028 }
1029 }
1030 if let Ok(pfc_entries) = store.kv_list_prefix(PERSISTENT_FAILURE_COUNT_PREFIX).await {
1031 for (pfc_key, _) in pfc_entries {
1032 if let Err(e) = store.kv_set(&pfc_key, "0").await {
1033 tracing::warn!(key = pfc_key, err = %e, "failed to reset persistent failure count");
1034 }
1035 }
1036 }
1037 tracing::info!(
1038 count = keys.len(),
1039 "cleared all cooldowns and failure counts"
1040 );
1041 agents_to_mark.extend(keys.iter().map(|k| agent_from_key(k).to_string()));
1042 match store.rate_limit_agents().await {
1043 Ok(rl_agents) => agents_to_mark.extend(rl_agents),
1044 Err(e) => tracing::warn!(err = %e, "failed to list rate_limits agents while clearing"),
1045 }
1046 match store.kv_list_prefix(RATE_LIMITS_CLEARED_AT_PREFIX).await {
1047 Ok(marks) => {
1048 for (k, _) in marks {
1049 let agent = k.trim_start_matches(RATE_LIMITS_CLEARED_AT_PREFIX);
1050 if !agent.is_empty() {
1051 agents_to_mark.push(agent.to_string());
1052 }
1053 }
1054 }
1055 Err(e) => tracing::warn!(err = %e, "failed to list rate_limits_cleared_at keys"),
1056 }
1057 } else {
1058 {
1059 let mut map = cooldown_lock();
1060 map.remove(key);
1061 }
1062 let kv_key = format!("{KV_PREFIX}{key}");
1063 if let Err(e) = store.kv_set(&kv_key, "0").await {
1064 tracing::warn!(key = kv_key, err = %e, "failed to clear cooldown");
1065 }
1066 let fc_key = format!("{FAILURE_COUNT_PREFIX}{key}");
1068 if let Err(e) = store.kv_set(&fc_key, "0").await {
1069 tracing::warn!(key = fc_key, err = %e, "failed to reset failure count");
1070 }
1071 let credit_fc_key = format!("{CREDIT_FAILURE_COUNT_PREFIX}{key}");
1073 if let Err(e) = store.kv_set(&credit_fc_key, "0").await {
1074 tracing::warn!(key = credit_fc_key, err = %e, "failed to reset credit failure count");
1075 }
1076 let persistent_fc_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{key}");
1078 if let Err(e) = store.kv_set(&persistent_fc_key, "0").await {
1079 tracing::warn!(key = persistent_fc_key, err = %e, "failed to reset persistent failure count");
1080 }
1081 tracing::info!(key, "cleared cooldown and failure count");
1082 agents_to_mark.push(agent_from_key(key).to_string());
1083 }
1084 mark_rate_limits_cleared(store, &agents_to_mark).await;
1085}
1086
1087fn set_cooldown_in_memory(key: &str, cooldown_until: i64, reason: &str) -> bool {
1096 let mut map = cooldown_lock();
1097 if let Some(existing) = map.get(key) {
1098 if existing.cooldown_until >= cooldown_until {
1102 return false;
1103 }
1104 }
1105 map.insert(
1106 key.to_string(),
1107 CooldownEntry {
1108 cooldown_until,
1109 reason: reason.to_string(),
1110 },
1111 );
1112 true
1113}
1114
1115async fn set_cooldown_async(key: &str, cooldown_until: i64, reason: &str) -> bool {
1125 if !set_cooldown_in_memory(key, cooldown_until, reason) {
1126 return false;
1127 }
1128 let store_opt = cooldown_store().lock().await.clone();
1129 if let Some(store) = store_opt {
1130 let kv_key = format!("{KV_PREFIX}{key}");
1131 let value = serde_json::json!({"until": cooldown_until, "reason": reason}).to_string();
1132 if let Err(e) = store.kv_set(&kv_key, &value).await {
1133 tracing::warn!(
1134 kv_key,
1135 err = %e,
1136 "failed to persist cooldown to KV store — rolling back in-memory entry"
1137 );
1138 let mut map = cooldown_lock();
1139 if map.get(key).map(|e| e.cooldown_until) == Some(cooldown_until) {
1143 map.remove(key);
1144 }
1145 return false;
1146 }
1147 }
1148 true
1149}
1150
1151fn cooldown_lock() -> std::sync::MutexGuard<'static, HashMap<String, CooldownEntry>> {
1153 cooldowns().lock().unwrap_or_else(|e| {
1154 tracing::error!("cooldown map lock poisoned — recovering with partial state; backoff and degraded-agent decisions may be incorrect");
1155 e.into_inner()
1156 })
1157}
1158
1159fn degraded_agents_lock() -> std::sync::MutexGuard<'static, HashMap<String, String>> {
1161 degraded_agents().lock().unwrap_or_else(|e| {
1162 tracing::error!("degraded agents lock poisoned — recovering with partial state; health check decisions may be incorrect");
1163 e.into_inner()
1164 })
1165}
1166
1167fn github_5xx_timestamps_lock() -> std::sync::MutexGuard<'static, Vec<i64>> {
1169 github_5xx_timestamps().lock().unwrap_or_else(|e| {
1170 tracing::error!("github 5xx timestamps lock poisoned — recovering with partial state; circuit breaker may operate incorrectly");
1171 e.into_inner()
1172 })
1173}
1174
1175fn github_5xx_circuit_open_lock() -> std::sync::MutexGuard<'static, Option<i64>> {
1177 github_5xx_circuit_open().lock().unwrap_or_else(|e| {
1178 tracing::error!("github 5xx circuit open lock poisoned — recovering with partial state; circuit breaker may operate incorrectly");
1179 e.into_inner()
1180 })
1181}
1182
1183fn is_in_cooldown_with_reason(key: &str, reason: &str) -> bool {
1185 let map = cooldown_lock();
1186 map.get(key).is_some_and(|entry| {
1187 entry.reason == reason && chrono::Utc::now().timestamp() < entry.cooldown_until
1188 })
1189}
1190
1191fn is_in_cooldown(key: &str) -> bool {
1193 let map = cooldown_lock();
1194 if let Some(entry) = map.get(key) {
1195 let now = chrono::Utc::now().timestamp();
1196 return now < entry.cooldown_until;
1197 }
1198 false
1199}
1200
1201pub const DEFAULT_HEALTH_CHECK_WINDOW_HOURS: u32 = 6;
1207
1208pub const DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD: i64 = 3;
1212
1213fn degraded_agents() -> &'static Mutex<HashMap<String, String>> {
1216 static DEGRADED: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
1217 DEGRADED.get_or_init(|| Mutex::new(HashMap::new()))
1218}
1219
1220pub fn is_agent_degraded(agent: &str) -> bool {
1225 let set = degraded_agents_lock();
1226 set.contains_key(agent)
1227}
1228
1229pub fn agent_degraded_reason(agent: &str) -> Option<String> {
1231 degraded_agents_lock().get(agent).cloned()
1232}
1233
1234pub fn list_degraded_agents() -> Vec<(String, String)> {
1236 let mut entries: Vec<(String, String)> = degraded_agents_lock()
1237 .iter()
1238 .map(|(agent, reason)| (agent.clone(), reason.clone()))
1239 .collect();
1240 entries.sort_by(|a, b| a.0.cmp(&b.0));
1241 entries
1242}
1243
1244pub fn mark_agent_degraded(agent: &str) {
1246 mark_agent_degraded_with_reason(agent, "degraded");
1247}
1248
1249fn mark_agent_degraded_with_reason(agent: &str, reason: &str) {
1250 let mut set = degraded_agents_lock();
1251 set.insert(agent.to_string(), reason.to_string());
1252}
1253
1254pub fn clear_agent_degraded(agent: &str) {
1256 let mut set = degraded_agents_lock();
1257 set.remove(agent);
1258}
1259
1260pub async fn refresh_degraded_agents(
1274 store: &Arc<crate::store::TaskStore>,
1275 available_agents: &[String],
1276 model_checker: &(dyn Fn(&str) -> bool + Send + Sync),
1277 window_hours: u32,
1278 threshold: i64,
1279) {
1280 clear_expired_cooldowns();
1284
1285 let start = chrono::Utc::now();
1288 let counts = match store.recent_rate_limit_counts(window_hours).await {
1289 Ok(c) => {
1290 let dur_ms = (chrono::Utc::now() - start).num_milliseconds();
1291 tracing::info!(
1292 duration_ms = dur_ms,
1293 window_hours,
1294 "recent_rate_limit_counts query completed"
1295 );
1296 c
1297 }
1298 Err(e) => {
1299 let dur_ms = (chrono::Utc::now() - start).num_milliseconds();
1300 tracing::warn!(
1301 err = %e,
1302 duration_ms = dur_ms,
1303 "failed to query recent rate limit counts for health check"
1304 );
1305 return;
1306 }
1307 };
1308
1309 for agent in available_agents {
1310 let in_cooldown = is_agent_in_cooldown(agent);
1311 let no_models = !model_checker(agent);
1312 let rate_limit_count = counts.get(agent.as_str()).copied().unwrap_or(0);
1313 let over_threshold = rate_limit_count >= threshold;
1314
1315 let degraded = in_cooldown || no_models || over_threshold;
1316
1317 if degraded {
1318 let reason = if in_cooldown {
1319 "agent in cooldown"
1320 } else if no_models {
1321 "all models cooled"
1322 } else {
1323 "rate limit threshold exceeded"
1324 };
1325 let previous = agent_degraded_reason(agent);
1326 match previous.as_deref() {
1327 None => tracing::warn!(
1328 agent,
1329 reason,
1330 rate_limit_count,
1331 window_hours,
1332 threshold,
1333 "pre-emptive health check: marking agent as degraded"
1334 ),
1335 Some(prev) if prev != reason => tracing::warn!(
1336 agent,
1337 reason,
1338 previous = prev,
1339 rate_limit_count,
1340 window_hours,
1341 threshold,
1342 "pre-emptive health check: degraded reason changed"
1343 ),
1344 Some(_) => {}
1345 }
1346 mark_agent_degraded_with_reason(agent, reason);
1347 } else {
1348 if is_agent_degraded(agent) {
1349 tracing::info!(
1350 agent,
1351 rate_limit_count,
1352 "pre-emptive health check: agent recovered, clearing degraded flag"
1353 );
1354 }
1355 clear_agent_degraded(agent);
1356 }
1357 }
1358}
1359
1360pub fn clear_expired_cooldowns() {
1362 let mut map = cooldown_lock();
1363 let now = chrono::Utc::now().timestamp();
1364 map.retain(|_key, entry| now < entry.cooldown_until);
1365}
1366
1367const GITHUB_5XX_WINDOW_SECS: i64 = 120;
1373
1374const GITHUB_5XX_THRESHOLD: usize = 5;
1376
1377const GITHUB_5XX_COOLDOWN_SECS: i64 = 180;
1379
1380fn github_5xx_timestamps() -> &'static Mutex<Vec<i64>> {
1382 static TS: OnceLock<Mutex<Vec<i64>>> = OnceLock::new();
1383 TS.get_or_init(|| Mutex::new(Vec::new()))
1384}
1385
1386fn github_5xx_circuit_open() -> &'static Mutex<Option<i64>> {
1388 static OPEN: OnceLock<Mutex<Option<i64>>> = OnceLock::new();
1389 OPEN.get_or_init(|| Mutex::new(None))
1390}
1391
1392pub async fn record_github_5xx() {
1397 let now = chrono::Utc::now().timestamp();
1398 let window_start = now - GITHUB_5XX_WINDOW_SECS;
1399
1400 let should_trip = {
1401 let mut ts = github_5xx_timestamps_lock();
1402 ts.retain(|t| *t >= window_start);
1403 ts.push(now);
1404 ts.len() >= GITHUB_5XX_THRESHOLD
1405 };
1406
1407 if should_trip {
1408 let cooldown_until = {
1409 let mut open = github_5xx_circuit_open_lock();
1410 if open.is_none() {
1411 let cd = now + GITHUB_5XX_COOLDOWN_SECS;
1412 *open = Some(cd);
1413 tracing::warn!(
1414 errors_in_window = GITHUB_5XX_THRESHOLD,
1415 window_secs = GITHUB_5XX_WINDOW_SECS,
1416 cooldown_secs = GITHUB_5XX_COOLDOWN_SECS,
1417 "GitHub 5xx circuit breaker OPEN — throttling non-critical work"
1418 );
1419 Some(cd)
1420 } else {
1421 None
1422 }
1423 };
1424 if let Some(cd) = cooldown_until {
1426 let store_opt = cooldown_store().lock().await.clone();
1427 if let Some(store) = store_opt {
1428 let _ = store.kv_set("cooldown:github:5xx", &cd.to_string()).await;
1429 }
1430 set_agent_cooldown("github:5xx", GITHUB_5XX_COOLDOWN_SECS as u64).await;
1434 }
1435 let mut ts = github_5xx_timestamps_lock();
1437 ts.clear();
1438 }
1439}
1440
1441pub fn is_github_circuit_open() -> bool {
1449 {
1452 let mut open = github_5xx_circuit_open_lock();
1453 if let Some(until) = *open {
1454 let now = chrono::Utc::now().timestamp();
1455 if now >= until {
1456 *open = None;
1457 tracing::info!("GitHub 5xx circuit breaker CLOSED — resuming normal operations");
1458 return false;
1459 }
1460 return true;
1461 }
1462 }
1463
1464 if let Some(until) = cooldown_until("github:5xx") {
1468 let mut open = github_5xx_circuit_open_lock();
1471 if open.is_none() {
1472 *open = Some(until);
1473 let now = chrono::Utc::now().timestamp();
1474 if now < until {
1475 let remaining = until - now;
1476 tracing::warn!(
1477 remaining_secs = remaining,
1478 "GitHub 5xx circuit breaker OPEN (discovered via generic cooldown) — throttling non-critical work"
1479 );
1480 }
1481 }
1482 return true;
1483 }
1484
1485 false
1486}
1487
1488pub fn github_circuit_remaining_secs() -> u64 {
1491 {
1493 let open = github_5xx_circuit_open_lock();
1494 if let Some(until) = *open {
1495 let now = chrono::Utc::now().timestamp();
1496 if now < until {
1497 return u64::try_from(until - now).unwrap_or(0);
1498 }
1499 return 0;
1500 }
1501 }
1502
1503 if let Some(until) = cooldown_until("github:5xx") {
1505 let now = chrono::Utc::now().timestamp();
1506 if now < until {
1507 return u64::try_from(until - now).unwrap_or(0);
1508 }
1509 }
1510 0
1511}
1512
1513pub(crate) fn parse_retry_at(error_message: &str) -> Option<i64> {
1517 if error_message.is_empty() {
1518 return None;
1519 }
1520
1521 let lower = error_message.to_lowercase();
1522 for retry_marker in [
1526 "try again at ",
1527 "reset at ",
1528 "resets at ",
1529 "resets ",
1530 "reset ",
1531 ] {
1532 if let Some(idx) = lower.find(retry_marker) {
1533 let date_str = &lower[idx + retry_marker.len()..];
1534 let date_str = date_str
1536 .split(['.', '\n'])
1537 .next()
1538 .unwrap_or(date_str)
1539 .trim();
1540
1541 let cleaned = date_str
1544 .replace("st,", ",")
1545 .replace("nd,", ",")
1546 .replace("rd,", ",")
1547 .replace("th,", ",");
1548
1549 for fmt in &[
1550 "%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M%p", "%B %d, %Y %I:%M %p", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", ] {
1557 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, fmt) {
1558 use chrono::TimeZone;
1561 let utc = match chrono::Local.from_local_datetime(&dt) {
1562 chrono::LocalResult::Single(local_dt) => {
1563 local_dt.with_timezone(&chrono::Utc)
1564 }
1565 chrono::LocalResult::Ambiguous(earliest, _) => {
1566 earliest.with_timezone(&chrono::Utc)
1568 }
1569 chrono::LocalResult::None => {
1570 dt.and_utc()
1572 }
1573 };
1574 tracing::info!(
1575 retry_at = %utc,
1576 raw = date_str,
1577 marker = retry_marker,
1578 "parsed rate limit retry-at date"
1579 );
1580 return Some(utc.timestamp());
1581 }
1582 }
1583 tracing::debug!(
1584 raw = date_str,
1585 marker = retry_marker,
1586 "could not parse retry-at date"
1587 );
1588
1589 let time_only = date_str.split_once('(').map_or(date_str, |(t, _)| t).trim();
1594 let mut compact = time_only.replace(" am", "am").replace(" pm", "pm");
1596 let split = compact.rfind(' ').map_or(0, |i| i + 1);
1597 let last = &compact[split..];
1598 if !last.contains(':')
1599 && (last.ends_with("am") || last.ends_with("pm"))
1600 && last.len() > 2
1601 {
1602 let at = split + last.len() - 2;
1603 compact.insert_str(at, ":00");
1604 }
1605 let md = compact.replace(" at ", " ");
1607 let now_local = chrono::Local::now();
1608 let this_year = chrono::Datelike::year(&now_local);
1609 for year in [this_year, this_year + 1] {
1610 let with_year = format!("{year} {md}");
1611 if let Ok(dt) =
1612 chrono::NaiveDateTime::parse_from_str(&with_year, "%Y %b %d %I:%M%p")
1613 {
1614 use chrono::TimeZone;
1615 if let Some(local_dt) = chrono::Local.from_local_datetime(&dt).earliest()
1616 && local_dt > now_local
1617 {
1618 return Some(local_dt.timestamp());
1619 }
1620 }
1621 }
1622 let time_only = compact.as_str();
1623 for tf in &["%I:%M %p", "%I:%M%p", "%I %p", "%H:%M"] {
1624 if let Ok(t) = chrono::NaiveTime::parse_from_str(time_only, tf) {
1625 let local_now = chrono::Local::now();
1628 let today = local_now.date_naive();
1629 let candidate = chrono::NaiveDateTime::new(today, t);
1630 let candidate_local: chrono::DateTime<chrono::Local> =
1633 match chrono::Local.from_local_datetime(&candidate) {
1634 chrono::LocalResult::Single(dt) => dt,
1635 chrono::LocalResult::Ambiguous(earliest, _) => earliest,
1636 chrono::LocalResult::None => {
1637 let utc_dt = candidate.and_utc();
1639 return Some(utc_dt.timestamp());
1640 }
1641 };
1642 let chosen = if candidate_local <= local_now {
1643 let tomorrow = today.succ_opt().unwrap_or(today);
1645 let ndt = chrono::NaiveDateTime::new(tomorrow, t);
1646 match chrono::Local.from_local_datetime(&ndt) {
1647 chrono::LocalResult::Single(dt) => dt.with_timezone(&chrono::Utc),
1648 chrono::LocalResult::Ambiguous(earliest, _) => {
1649 earliest.with_timezone(&chrono::Utc)
1650 }
1651 chrono::LocalResult::None => ndt.and_utc(),
1652 }
1653 } else {
1654 candidate_local.with_timezone(&chrono::Utc)
1655 };
1656 tracing::info!(
1657 retry_at = %chosen,
1658 raw = date_str,
1659 marker = retry_marker,
1660 "parsed rate limit retry-at time-only; interpreted as next occurrence"
1661 );
1662 return Some(chosen.timestamp());
1663 }
1664 }
1665 }
1666 }
1667
1668 None
1672}
1673
1674pub(crate) fn parse_relative_usage_window(error_message: &str) -> Option<u64> {
1685 static WINDOW_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
1686 Regex::new(
1687 r"(?i)(?:^|\s|[\(\)\.,;:!?])(?:(\d+)\s*-?\s*(hour|hours|hr|hrs|minute|minutes|min|mins|day|days)|(hourly|daily|weekly|monthly))(?:$|\s|[\(\)\.,;:!?])"
1688 )
1689 .expect("usage window regex is valid")
1690 });
1691
1692 if error_message.is_empty() {
1693 return None;
1694 }
1695
1696 let lower = error_message.to_lowercase();
1697 let has_context = lower.contains("usage limit")
1698 || lower.contains("quota")
1699 || lower.contains("window")
1700 || lower.contains("limit");
1701 if !has_context {
1702 return None;
1703 }
1704
1705 let mut max_secs: Option<u64> = None;
1706 for caps in WINDOW_RE.captures_iter(&lower) {
1707 let secs = if let Some(num) = caps.get(1) {
1708 let n: u64 = num.as_str().parse().ok()?;
1709 let unit = caps.get(2)?.as_str().to_lowercase();
1710 let multiplier = match unit.as_str() {
1711 "hour" | "hours" | "hr" | "hrs" => 3600,
1712 "minute" | "minutes" | "min" | "mins" => 60,
1713 "day" | "days" => 86400,
1714 _ => return None,
1715 };
1716 n.checked_mul(multiplier)?
1717 } else if let Some(word) = caps.get(3) {
1718 match word.as_str().to_lowercase().as_str() {
1719 "hourly" => 3600,
1720 "daily" => 86400,
1721 "weekly" => 604_800,
1722 "monthly" => 2_592_000,
1723 _ => return None,
1724 }
1725 } else {
1726 continue;
1727 };
1728 max_secs = Some(max_secs.map_or(secs, |m| m.max(secs)));
1729 }
1730
1731 max_secs
1732}
1733
1734#[cfg(test)]
1735pub(crate) async fn reset_global_state() {
1736 cooldowns().lock().unwrap().clear();
1737 *cooldown_store().lock().await = None;
1738 github_5xx_timestamps().lock().unwrap().clear();
1739 *github_5xx_circuit_open().lock().unwrap() = None;
1740 degraded_agents().lock().unwrap().clear();
1741 crate::engine::runner::agents::opencode::reset_model_caches_for_test();
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746 use super::*;
1747 use serial_test::serial;
1748
1749 async fn test_store() -> Arc<crate::store::TaskStore> {
1750 Arc::new(
1751 crate::store::TaskStore::open_memory()
1752 .await
1753 .expect("in-memory store"),
1754 )
1755 }
1756
1757 fn sqlite_datetime_offset(secs: i64) -> String {
1758 (chrono::Utc::now() + chrono::Duration::seconds(secs))
1759 .format("%Y-%m-%d %H:%M:%S")
1760 .to_string()
1761 }
1762
1763 #[serial(cooldown_state)]
1764 #[tokio::test]
1765 async fn init_loads_unexpired_cooldowns_from_kv() {
1766 super::reset_global_state().await;
1767 let store = test_store().await;
1768 let cooldown_until = chrono::Utc::now().timestamp() + 3600; store
1771 .kv_set(
1772 &format!("{KV_PREFIX}kimi:k2p5"),
1773 &cooldown_until.to_string(),
1774 )
1775 .await
1776 .unwrap();
1777
1778 let expired = chrono::Utc::now().timestamp() - 1;
1780 store
1781 .kv_set(
1782 &format!("{KV_PREFIX}opencode:old-free"),
1783 &expired.to_string(),
1784 )
1785 .await
1786 .unwrap();
1787
1788 {
1789 let mut map = cooldown_lock();
1790 map.clear();
1791 }
1792
1793 init_cooldown_store(store).await;
1794
1795 assert!(
1796 is_model_in_cooldown("kimi", "k2p5"),
1797 "unexpired cooldown should be loaded"
1798 );
1799 assert!(
1800 !is_model_in_cooldown("opencode", "old-free"),
1801 "expired cooldown should not be loaded"
1802 );
1803 }
1804
1805 #[serial(cooldown_state)]
1806 #[tokio::test]
1807 async fn credit_exhaustion_inside_active_cooldown_keeps_counter_at_one() {
1808 super::reset_global_state().await;
1809 let store = test_store().await;
1810 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1811
1812 let msg =
1813 "API error (status 402 Payment Required): usage balance exhausted, out of credits";
1814 assert!(record_credit_exhaustion_from_message("credit_dup_agent", Some("m"), msg).await);
1815 assert!(record_credit_exhaustion_from_message("credit_dup_agent", Some("m"), msg).await);
1816
1817 assert!(is_agent_in_cooldown("credit_dup_agent"));
1818 let count = store
1819 .kv_get(&format!("{CREDIT_FAILURE_COUNT_PREFIX}credit_dup_agent"))
1820 .await
1821 .unwrap();
1822 assert_eq!(count.as_deref(), Some("1"));
1823 }
1824
1825 #[serial(cooldown_state)]
1826 #[tokio::test]
1827 async fn non_credit_message_is_not_recorded_as_credit_exhaustion() {
1828 super::reset_global_state().await;
1829 assert!(!record_credit_exhaustion_from_message("x_agent", Some("m"), "boom").await);
1830 assert!(!is_agent_in_cooldown("x_agent"));
1831 }
1832
1833 #[serial(cooldown_state)]
1834 #[tokio::test]
1835 async fn record_model_failure_writes_to_kv() {
1836 super::reset_global_state().await;
1837 let store = test_store().await;
1838
1839 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1840
1841 record_model_failure("testagent_persist", "testmodel_persist").await;
1842
1843 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1845
1846 let kv_key = format!("{KV_PREFIX}testagent_persist:testmodel_persist");
1847 let stored = store.kv_get(&kv_key).await.unwrap();
1848 assert!(stored.is_some(), "model failure should be persisted to KV");
1849 let (ts, reason) = parse_cooldown_value(&stored.unwrap()).expect("cooldown value");
1850 let now = chrono::Utc::now().timestamp();
1851 assert!(ts > now, "persisted timestamp should be in the future");
1853 assert_eq!(reason, "model_error");
1854 }
1855
1856 #[serial(cooldown_state)]
1857 #[tokio::test]
1858 async fn cooldown_reason_survives_restart_via_kv() {
1859 super::reset_global_state().await;
1860 let store = test_store().await;
1861 let agent = "test_reason_restart";
1862 let future = chrono::Utc::now().timestamp() + 600;
1863
1864 init_cooldown_store(std::sync::Arc::clone(&store)).await;
1865 set_cooldown_async(agent, future, "billing_cycle_exhausted").await;
1866
1867 {
1869 let mut map = cooldown_lock();
1870 map.clear();
1871 }
1872 init_cooldown_store(store).await;
1873
1874 let entries = list_all_cooldowns();
1875 let entry = entries.iter().find(|(key, ..)| key == agent);
1876 assert_eq!(
1877 entry.map(|(_, _, reason)| reason.as_str()),
1878 Some("billing_cycle_exhausted"),
1879 "cooldown reason must survive a restart, not collapse to 'persisted'"
1880 );
1881 }
1882
1883 #[serial(cooldown_state)]
1884 #[tokio::test]
1885 async fn record_persistent_model_failure_escalates_and_increments_failure_count() {
1886 super::reset_global_state().await;
1887 let store = test_store().await;
1888 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1889
1890 let agent = "testagent_persistent";
1891 let model = "testmodel_persistent";
1892 let key = format!("{agent}:{model}");
1893
1894 record_persistent_model_failure(agent, model).await;
1896 let now = chrono::Utc::now().timestamp();
1897 let first_until = cooldown_until(&key).expect("first cooldown should exist");
1898 let first_remaining = first_until.saturating_sub(now);
1899 assert!(
1900 first_remaining >= PERSISTENT_MODEL_BACKOFF_BASE_SECS - 5,
1901 "first persistent model cooldown should be ~4h, got {first_remaining}s"
1902 );
1903
1904 record_persistent_model_failure(agent, model).await;
1906 let now2 = chrono::Utc::now().timestamp();
1907 let second_until = cooldown_until(&key).expect("second cooldown should exist");
1908 let second_remaining = second_until.saturating_sub(now2);
1909 assert!(
1910 second_remaining >= (PERSISTENT_MODEL_BACKOFF_BASE_SECS * 3) - 5,
1911 "second persistent model cooldown should be ~12h, got {second_remaining}s"
1912 );
1913
1914 let count_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{agent}:{model}");
1915 let stored_count = store
1916 .kv_get(&count_key)
1917 .await
1918 .expect("kv read should succeed")
1919 .expect("failure count should be written");
1920 assert_eq!(stored_count, "2");
1921 }
1922
1923 #[serial(cooldown_state)]
1924 #[tokio::test]
1925 async fn record_agent_success_does_not_reset_persistent_failure_count() {
1926 super::reset_global_state().await;
1927 let store = test_store().await;
1928 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1929
1930 let agent = "testagent_persistent_success";
1931 let model = "testmodel_persistent_success";
1932 let key = format!("{agent}:{model}");
1933
1934 record_persistent_model_failure(agent, model).await;
1936 record_persistent_model_failure(agent, model).await;
1937
1938 let count_key = format!("{PERSISTENT_FAILURE_COUNT_PREFIX}{agent}:{model}");
1939 let stored_count = store
1940 .kv_get(&count_key)
1941 .await
1942 .expect("kv read should succeed")
1943 .expect("failure count should be written");
1944 assert_eq!(stored_count, "2");
1945
1946 record_agent_success(agent, model).await;
1950
1951 let stored_count_after_success = store
1952 .kv_get(&count_key)
1953 .await
1954 .expect("kv read should succeed")
1955 .expect("persistent failure count should still exist");
1956 assert_eq!(
1957 stored_count_after_success, "2",
1958 "persistent failure count must survive an intervening success"
1959 );
1960
1961 record_persistent_model_failure(agent, model).await;
1963 let now = chrono::Utc::now().timestamp();
1964 let third_until = cooldown_until(&key).expect("third cooldown should exist");
1965 let third_remaining = third_until.saturating_sub(now);
1966 assert!(
1967 third_remaining > PERSISTENT_MODEL_BACKOFF_BASE_SECS,
1968 "third persistent model failure should escalate past the 4h base, got {third_remaining}s"
1969 );
1970 }
1971
1972 #[serial(cooldown_state)]
1973 #[tokio::test]
1974 async fn record_persistent_model_failure_stays_capped_at_7_days() {
1975 super::reset_global_state().await;
1976 let store = test_store().await;
1977 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
1978
1979 let agent = "testagent_persistent_capped";
1980 let model = "testmodel_persistent_capped";
1981 let key = format!("{agent}:{model}");
1982
1983 for _ in 0..9 {
1984 record_persistent_model_failure(agent, model).await;
1985 }
1986
1987 let remaining = cooldown_until(&key)
1988 .expect("cooldown should exist")
1989 .saturating_sub(chrono::Utc::now().timestamp());
1990 assert!(
1991 remaining >= PERSISTENT_MODEL_BACKOFF_MAX_SECS - 5,
1992 "persistent model cooldown should stay capped at 7 days, got {remaining}s"
1993 );
1994 assert!(
1995 remaining <= PERSISTENT_MODEL_BACKOFF_MAX_SECS + 5,
1996 "persistent model cooldown should not exceed 7 days, got {remaining}s"
1997 );
1998 }
1999
2000 #[serial(cooldown_state)]
2001 #[test]
2002 fn parse_retry_at_codex_format() {
2003 use chrono::Datelike;
2004 use chrono::TimeZone;
2005
2006 let msg = "You've hit your usage limit. Upgrade to Pro, visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Mar 26th, 2026 5:55 AM.";
2007 let ts = parse_retry_at(msg);
2008 assert!(ts.is_some(), "should parse codex retry-at date");
2009
2010 let naive = chrono::NaiveDateTime::new(
2013 chrono::NaiveDate::from_ymd_opt(2026, 3, 26).unwrap(),
2014 chrono::NaiveTime::from_hms_opt(5, 55, 0).unwrap(),
2015 );
2016 let expected_utc = match chrono::Local.from_local_datetime(&naive) {
2017 chrono::LocalResult::Single(local_dt) => local_dt.with_timezone(&chrono::Utc),
2018 chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
2019 chrono::LocalResult::None => naive.and_utc(),
2020 };
2021 assert_eq!(
2022 ts.unwrap(),
2023 expected_utc.timestamp(),
2024 "timestamp should reflect local-timezone interpretation of the vendor date"
2025 );
2026
2027 let dt = chrono::DateTime::from_timestamp(ts.unwrap(), 0).unwrap();
2029 assert_eq!(dt.month(), 3);
2030 assert_eq!(dt.day(), 26);
2031 }
2032
2033 #[serial(cooldown_state)]
2034 #[test]
2035 fn parse_retry_at_hour_only_and_month_day() {
2036 let now = chrono::Utc::now().timestamp();
2037 for msg in [
2038 "You've hit your session limit · resets 8am (America/Sao_Paulo)",
2039 "You've hit your session limit · resets 6pm (America/Sao_Paulo)",
2040 "You've hit your session limit · resets 12am (America/Sao_Paulo)",
2041 "You've hit your weekly limit · resets Jun 9 at 1am (America/Sao_Paulo)",
2042 ] {
2043 let ts = parse_retry_at(msg).unwrap_or_else(|| panic!("should parse: {msg}"));
2044 assert!(ts > now, "{msg}");
2045 }
2046 }
2047
2048 #[serial(cooldown_state)]
2049 #[test]
2050 fn parse_retry_at_weekly_resets_without_year() {
2051 use chrono::Datelike;
2052 let t = chrono::Local::now() + chrono::Duration::days(2);
2053 let msg = format!(
2054 "You've hit your weekly limit · resets {} {} at 1am (America/Sao_Paulo)",
2055 t.format("%b"),
2056 t.day()
2057 );
2058 let ts = parse_retry_at(&msg).expect("should parse year-less reset date");
2059 let remaining = ts - chrono::Utc::now().timestamp();
2060 assert!(
2061 remaining > 3600 && remaining < 3 * 86400,
2062 "expected ~2d retry-at, got {remaining}s"
2063 );
2064 }
2065
2066 #[serial(cooldown_state)]
2067 #[test]
2068 fn parse_retry_at_no_date() {
2069 assert!(parse_retry_at("generic rate limit error").is_none());
2070 assert!(parse_retry_at("").is_none());
2071 }
2072
2073 #[serial(cooldown_state)]
2074 #[test]
2075 fn parse_retry_at_glm_reset_format_with_seconds() {
2076 use chrono::TimeZone;
2077
2078 let msg = "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-23 18:33:48";
2079 let ts = parse_retry_at(msg).expect("should parse GLM reset-at date");
2080
2081 let naive = chrono::NaiveDateTime::new(
2082 chrono::NaiveDate::from_ymd_opt(2026, 4, 23).expect("valid date"),
2083 chrono::NaiveTime::from_hms_opt(18, 33, 48).expect("valid time"),
2084 );
2085 let expected_utc = match chrono::Local.from_local_datetime(&naive) {
2086 chrono::LocalResult::Single(local_dt) => local_dt.with_timezone(&chrono::Utc),
2087 chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
2088 chrono::LocalResult::None => naive.and_utc(),
2089 };
2090
2091 assert_eq!(
2092 ts,
2093 expected_utc.timestamp(),
2094 "timestamp should reflect local-timezone interpretation of vendor reset date"
2095 );
2096 }
2097
2098 #[serial(cooldown_state)]
2099 #[test]
2100 fn parse_retry_at_billing_cycle_without_date_returns_none() {
2101 let msg = "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. Upgrade to get more.";
2104 assert!(
2105 parse_retry_at(msg).is_none(),
2106 "billing cycle without date should return None"
2107 );
2108 }
2109
2110 #[serial(cooldown_state)]
2111 #[test]
2112 fn parse_relative_usage_window_extracts_kimi_five_hour_window() {
2113 let msg = "Failed to authenticate. API Error: 403 You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window expires.";
2114 assert_eq!(
2115 parse_relative_usage_window(msg),
2116 Some(5 * 3600),
2117 "should extract the 5-hour window as seconds"
2118 );
2119 }
2120
2121 #[serial(cooldown_state)]
2122 #[test]
2123 fn parse_relative_usage_window_handles_common_durations() {
2124 assert_eq!(
2125 parse_relative_usage_window("You've reached your hourly usage limit."),
2126 Some(3600)
2127 );
2128 assert_eq!(
2129 parse_relative_usage_window("You've reached your daily usage limit."),
2130 Some(86400)
2131 );
2132 assert_eq!(
2133 parse_relative_usage_window("You've reached your weekly limit."),
2134 Some(604_800)
2135 );
2136 assert_eq!(
2137 parse_relative_usage_window("You've reached your 30-minute usage limit."),
2138 Some(1800)
2139 );
2140 assert_eq!(
2141 parse_relative_usage_window("You've reached your 2-day quota."),
2142 Some(172_800)
2143 );
2144 }
2145
2146 #[serial(cooldown_state)]
2147 #[test]
2148 fn parse_relative_usage_window_returns_none_without_usage_context() {
2149 assert!(parse_relative_usage_window("429 Too Many Requests").is_none());
2152 assert!(parse_relative_usage_window("rate limit exceeded").is_none());
2153 assert!(parse_relative_usage_window("").is_none());
2154 }
2155
2156 #[serial(cooldown_state)]
2157 #[tokio::test]
2158 async fn record_rate_limit_uses_model_specific_window_cooldown() {
2159 super::reset_global_state().await;
2160 let store = test_store().await;
2161 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2162
2163 let agent = "test_record_rl_window_agent";
2164 let model = "test_record_rl_window_model";
2165 let msg = "You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window expires.";
2166
2167 record_rate_limit(agent, Some(model), msg).await;
2168
2169 assert!(
2170 is_model_in_cooldown(agent, model),
2171 "model should be cooled for the relative usage window"
2172 );
2173 assert!(
2174 !is_agent_in_cooldown(agent),
2175 "agent should not be cooled for a model-scoped quota"
2176 );
2177
2178 let key = format!("{agent}:{model}");
2179 let remaining = cooldown_until(&key)
2180 .expect("model cooldown should exist")
2181 .saturating_sub(chrono::Utc::now().timestamp());
2182 assert!(
2183 remaining >= 5 * 3600 - 5,
2184 "model cooldown should be at least ~5 hours, got {remaining}s"
2185 );
2186 assert_eq!(
2187 cooldown_reason(&key).as_deref(),
2188 Some("rate_limit_usage_window"),
2189 "relative usage-window cooldown must be labeled as a rate limit, not silence"
2190 );
2191 }
2192
2193 #[serial(cooldown_state)]
2194 #[tokio::test]
2195 async fn record_rate_limit_without_window_falls_back_to_agent_and_model() {
2196 super::reset_global_state().await;
2197 let store = test_store().await;
2198 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2199
2200 let agent = "test_record_rl_fallback_agent";
2201 let model = "test_record_rl_fallback_model";
2202 let msg = "429 Too Many Requests";
2203
2204 record_rate_limit(agent, Some(model), msg).await;
2205
2206 assert!(
2207 is_agent_in_cooldown(agent),
2208 "agent should be cooled when no relative window is detected"
2209 );
2210 assert!(
2211 is_model_in_cooldown(agent, model),
2212 "model should be cooled when no relative window is detected"
2213 );
2214 }
2215
2216 #[serial(cooldown_state)]
2217 #[tokio::test]
2218 async fn agent_cooldown_persists_to_kv() {
2219 super::reset_global_state().await;
2220 let agent = "test_agent_persist_check";
2222 record_agent_failure_with_message(agent, "").await;
2223 assert!(is_agent_in_cooldown(agent));
2224 }
2225
2226 #[serial(cooldown_state)]
2227 #[tokio::test]
2228 async fn cooldown_never_shortens() {
2229 super::reset_global_state().await;
2230 let agent = "test_agent_no_shorten";
2231 set_agent_cooldown(agent, 24 * 60 * 60).await;
2232 let initial = {
2233 let map = cooldowns().lock().unwrap();
2234 map.get(agent)
2235 .expect("cooldown entry should exist")
2236 .cooldown_until
2237 };
2238
2239 record_agent_failure_with_message(agent, "rate limit").await;
2241 let after = {
2242 let map = cooldowns().lock().unwrap();
2243 map.get(agent)
2244 .expect("cooldown entry should exist")
2245 .cooldown_until
2246 };
2247 assert_eq!(
2248 initial, after,
2249 "cooldown should not be shortened by later failures"
2250 );
2251 }
2252
2253 #[serial(cooldown_state)]
2254 #[tokio::test]
2255 async fn silence_agent_cooldown_is_short_lived() {
2256 super::reset_global_state().await;
2257 let agent = "test_silence_agent_cd";
2258 assert!(!is_agent_in_cooldown(agent));
2259
2260 set_agent_cooldown(agent, SILENCE_AGENT_COOLDOWN_SECS).await;
2261 assert!(is_agent_in_cooldown(agent));
2262
2263 let map = cooldowns().lock().unwrap();
2265 let entry = map.get(agent).expect("should have cooldown entry");
2266 let now = chrono::Utc::now().timestamp();
2267 let remaining = entry.cooldown_until - now;
2268 assert!(
2269 remaining <= i64::try_from(SILENCE_AGENT_COOLDOWN_SECS).unwrap(),
2270 "silence agent cooldown should be <= {SILENCE_AGENT_COOLDOWN_SECS}s, got {remaining}s"
2271 );
2272 assert!(remaining > 0, "cooldown should still be active");
2273 }
2274
2275 #[serial(cooldown_state)]
2276 #[tokio::test]
2277 async fn record_silence_detection_applies_extended_cooldown() {
2278 super::reset_global_state().await;
2279 let store = test_store().await;
2280 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2281
2282 let agent = "test_silence_count_agent";
2283 let model = "test_silence_count_model";
2284 assert!(!is_model_in_cooldown(agent, model));
2285
2286 for _ in 0..SILENCE_COUNT_THRESHOLD {
2287 let result = record_silence_detection(agent, model).await;
2288 assert!(result.is_some());
2289 }
2290
2291 assert!(is_model_in_cooldown(agent, model));
2292 let key = format!("{agent}:{model}");
2293 let remaining = {
2294 let map = cooldowns().lock().unwrap();
2295 let entry = map.get(&key).expect("cooldown entry should exist");
2296 entry.cooldown_until - chrono::Utc::now().timestamp()
2297 };
2298 assert!(
2299 remaining >= i64::try_from(SILENCE_EXTENDED_COOLDOWN_SECS).unwrap() - 5,
2300 "extended cooldown should be applied, got {remaining}s"
2301 );
2302
2303 let kv_key = format!("{SILENCE_COUNT_PREFIX}{agent}:{model}");
2304 let stored = store.kv_get(&kv_key).await.unwrap().unwrap();
2305 assert_eq!(stored, "[]");
2306 }
2307
2308 #[serial(cooldown_state)]
2309 #[test]
2310 fn detect_credit_exhaustion_out_of_credits() {
2311 let msg = "Error: API error - {\"error\":{\"message\":\"You exceeded your current quota\",\"type\":\"insufficient_quota\",\"param\":null,\"code\":\"insufficient_quota\"}}";
2312 assert_eq!(
2313 detect_credit_exhaustion(msg),
2314 Some(CreditExhaustionReason::OutOfCredits)
2315 );
2316 }
2317
2318 #[serial(cooldown_state)]
2319 #[test]
2320 fn detect_credit_exhaustion_out_of_credits_variations() {
2321 assert_eq!(
2322 detect_credit_exhaustion("out of credits"),
2323 Some(CreditExhaustionReason::OutOfCredits)
2324 );
2325 assert_eq!(
2326 detect_credit_exhaustion("outofcredits"),
2327 Some(CreditExhaustionReason::OutOfCredits)
2328 );
2329 assert_eq!(
2330 detect_credit_exhaustion("no credits remaining"),
2331 Some(CreditExhaustionReason::OutOfCredits)
2332 );
2333 assert_eq!(
2334 detect_credit_exhaustion("insufficient funds"),
2335 Some(CreditExhaustionReason::OutOfCredits)
2336 );
2337 }
2338
2339 #[serial(cooldown_state)]
2340 #[test]
2341 fn detect_credit_exhaustion_org_level_disabled() {
2342 let msg = "Error: API error - {\"error\":{\"message\":\"Organization has been disabled\",\"type\":\"org_disabled\",\"code\":\"overageDisabledReason:org_level_disabled\"}}";
2343 assert_eq!(
2344 detect_credit_exhaustion(msg),
2345 Some(CreditExhaustionReason::OrgLevelDisabled)
2346 );
2347 }
2348
2349 #[serial(cooldown_state)]
2350 #[test]
2351 fn detect_credit_exhaustion_org_level_disabled_variations() {
2352 assert_eq!(
2353 detect_credit_exhaustion("organization has been disabled"),
2354 Some(CreditExhaustionReason::OrgLevelDisabled)
2355 );
2356 assert_eq!(
2357 detect_credit_exhaustion("org disabled"),
2358 Some(CreditExhaustionReason::OrgLevelDisabled)
2359 );
2360 assert_eq!(
2361 detect_credit_exhaustion("billing quota exceeded"),
2362 Some(CreditExhaustionReason::OrgLevelDisabled)
2363 );
2364 assert_eq!(
2365 detect_credit_exhaustion("credit balance too low"),
2366 Some(CreditExhaustionReason::OrgLevelDisabled)
2367 );
2368 }
2369
2370 #[serial(cooldown_state)]
2371 #[test]
2372 fn detect_credit_exhaustion_overage_disabled_reason_parsing() {
2373 let msg = "overageDisabledReason: out_of_credits";
2374 assert_eq!(
2375 detect_credit_exhaustion(msg),
2376 Some(CreditExhaustionReason::OutOfCredits)
2377 );
2378
2379 let msg = "overageDisabledReason: org_level_disabled";
2380 assert_eq!(
2381 detect_credit_exhaustion(msg),
2382 Some(CreditExhaustionReason::OrgLevelDisabled)
2383 );
2384 }
2385
2386 #[serial(cooldown_state)]
2387 #[test]
2388 fn detect_credit_exhaustion_glm_insufficient_balance() {
2389 assert_eq!(
2391 detect_credit_exhaustion(
2392 "API Error: Request rejected (429) · Insufficient balance or no resource package. Please recharge."
2393 ),
2394 Some(CreditExhaustionReason::OutOfCredits)
2395 );
2396 assert_eq!(
2397 detect_credit_exhaustion("Insufficient balance"),
2398 Some(CreditExhaustionReason::OutOfCredits)
2399 );
2400 assert_eq!(
2401 detect_credit_exhaustion("please recharge your account"),
2402 Some(CreditExhaustionReason::OutOfCredits)
2403 );
2404 assert_eq!(
2405 detect_credit_exhaustion("no resource package available"),
2406 Some(CreditExhaustionReason::OutOfCredits)
2407 );
2408 }
2409
2410 #[serial(cooldown_state)]
2411 #[test]
2412 fn detect_credit_exhaustion_none_for_regular_rate_limit() {
2413 assert!(detect_credit_exhaustion("rate limit exceeded").is_none());
2414 assert!(detect_credit_exhaustion("too many requests").is_none());
2415 assert!(detect_credit_exhaustion("429 Too Many Requests").is_none());
2416 assert!(detect_credit_exhaustion("you've hit your usage limit").is_none());
2417 assert!(
2418 detect_credit_exhaustion("You've hit your usage limit, try again in a few minutes.")
2419 .is_none()
2420 );
2421 }
2422
2423 #[serial(cooldown_state)]
2424 #[tokio::test]
2425 async fn record_credit_exhaustion_applies_agent_cooldown() {
2426 super::reset_global_state().await;
2427 let agent = "test_credit_exhaust_agent";
2428 assert!(!is_agent_in_cooldown(agent));
2429
2430 record_credit_exhaustion(agent, CreditExhaustionReason::OutOfCredits).await;
2431 assert!(is_agent_in_cooldown(agent));
2432
2433 let remaining = {
2434 let map = cooldowns().lock().unwrap();
2435 let entry = map.get(agent).expect("cooldown entry should exist");
2436 entry.cooldown_until - chrono::Utc::now().timestamp()
2437 };
2438 assert!(
2440 remaining >= CREDIT_BACKOFF_BASE_SECS - 5,
2441 "first out_of_credits cooldown should be ~1 hour, got {remaining}s"
2442 );
2443 assert!(
2444 remaining <= CREDIT_BACKOFF_MAX_SECS,
2445 "cooldown should not exceed cap of 8h, got {remaining}s"
2446 );
2447 }
2448
2449 #[serial(cooldown_state)]
2450 #[tokio::test]
2451 async fn record_credit_exhaustion_org_level_applies_longer_cooldown() {
2452 super::reset_global_state().await;
2453 let agent = "test_org_disabled_agent";
2454 assert!(!is_agent_in_cooldown(agent));
2455
2456 record_credit_exhaustion(agent, CreditExhaustionReason::OrgLevelDisabled).await;
2457 assert!(is_agent_in_cooldown(agent));
2458
2459 let remaining = {
2460 let map = cooldowns().lock().unwrap();
2461 let entry = map.get(agent).expect("cooldown entry should exist");
2462 entry.cooldown_until - chrono::Utc::now().timestamp()
2463 };
2464 assert!(
2466 remaining >= ORG_BACKOFF_BASE_SECS - 5,
2467 "first org-level disabled cooldown should be ~2 hours, got {remaining}s"
2468 );
2469 assert!(
2470 remaining <= CREDIT_BACKOFF_MAX_SECS,
2471 "cooldown should not exceed cap of 8h, got {remaining}s"
2472 );
2473 }
2474
2475 #[serial(cooldown_state)]
2476 #[tokio::test]
2477 async fn record_credit_exhaustion_billing_cycle_applies_24h() {
2478 super::reset_global_state().await;
2479 let store = test_store().await;
2480 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2481
2482 let agent = "test_billing_cycle_agent";
2483 assert!(!is_agent_in_cooldown(agent));
2484
2485 record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2486 assert!(is_agent_in_cooldown(agent));
2487
2488 let remaining = {
2489 let map = cooldowns().lock().unwrap();
2490 let entry = map.get(agent).expect("cooldown entry should exist");
2491 entry.cooldown_until - chrono::Utc::now().timestamp()
2492 };
2493 assert!(
2495 remaining >= BILLING_CYCLE_COOLDOWN_SECS - 5,
2496 "first billing cycle cooldown should be ~24 hours, got {remaining}s"
2497 );
2498 assert!(
2499 remaining <= BILLING_CYCLE_MAX_SECS,
2500 "cooldown should not exceed cap of 7 days, got {remaining}s"
2501 );
2502 }
2503
2504 #[serial(cooldown_state)]
2505 #[tokio::test]
2506 async fn record_credit_exhaustion_billing_cycle_escalates() {
2507 super::reset_global_state().await;
2508 let store = test_store().await;
2509 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2510
2511 let agent = "test_billing_cycle_escalation";
2512
2513 record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2515 let remaining_1 = {
2516 let map = cooldowns().lock().unwrap();
2517 let entry = map.get(agent).expect("cooldown entry should exist");
2518 entry.cooldown_until - chrono::Utc::now().timestamp()
2519 };
2520 assert!(
2521 (BILLING_CYCLE_COOLDOWN_SECS - 5..=BILLING_CYCLE_COOLDOWN_SECS + 5)
2522 .contains(&remaining_1),
2523 "first billing cycle cooldown should be ~24h, got {remaining_1}s"
2524 );
2525
2526 {
2528 let mut map = cooldowns().lock().unwrap();
2529 map.remove(agent);
2530 }
2531
2532 record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2534 let remaining_2 = {
2535 let map = cooldowns().lock().unwrap();
2536 let entry = map.get(agent).expect("cooldown entry should exist");
2537 entry.cooldown_until - chrono::Utc::now().timestamp()
2538 };
2539 let expected_2 = BILLING_CYCLE_COOLDOWN_SECS * 3; assert!(
2541 remaining_2 >= expected_2 - 5,
2542 "second billing cycle cooldown should be ~72h, got {remaining_2}s"
2543 );
2544
2545 {
2547 let mut map = cooldowns().lock().unwrap();
2548 map.remove(agent);
2549 }
2550
2551 record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2553 let remaining_3 = {
2554 let map = cooldowns().lock().unwrap();
2555 let entry = map.get(agent).expect("cooldown entry should exist");
2556 entry.cooldown_until - chrono::Utc::now().timestamp()
2557 };
2558 assert!(
2559 remaining_3 >= BILLING_CYCLE_MAX_SECS - 5,
2560 "third billing cycle cooldown should be capped at 7 days, got {remaining_3}s"
2561 );
2562 assert!(
2563 remaining_3 <= BILLING_CYCLE_MAX_SECS + 5,
2564 "third billing cycle cooldown should not exceed 7 days, got {remaining_3}s"
2565 );
2566 }
2567
2568 #[serial(cooldown_state)]
2569 #[tokio::test]
2570 async fn record_credit_exhaustion_billing_cycle_stays_capped_after_repeats() {
2571 super::reset_global_state().await;
2572 let store = test_store().await;
2573 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
2574
2575 let agent = "test_billing_cycle_agent_capped";
2576
2577 for i in 0..5 {
2578 if i > 0 {
2579 cooldowns().lock().unwrap().remove(agent);
2581 }
2582 record_credit_exhaustion(agent, CreditExhaustionReason::BillingCycleExhausted).await;
2583 }
2584
2585 let remaining = {
2586 let map = cooldowns().lock().unwrap();
2587 let entry = map.get(agent).expect("cooldown entry should exist");
2588 entry.cooldown_until - chrono::Utc::now().timestamp()
2589 };
2590 assert!(
2591 remaining >= BILLING_CYCLE_MAX_SECS - 5,
2592 "billing cycle cooldown should stay capped at 7 days, got {remaining}s"
2593 );
2594 assert!(
2595 remaining <= BILLING_CYCLE_MAX_SECS + 5,
2596 "billing cycle cooldown should not exceed 7 days, got {remaining}s"
2597 );
2598 }
2599
2600 #[serial(cooldown_state)]
2601 #[test]
2602 fn credit_exhaustion_backoff_constants() {
2603 assert_eq!(
2605 compute_backoff(1, CREDIT_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS, true),
2606 CREDIT_BACKOFF_BASE_SECS
2607 );
2608 assert_eq!(
2610 compute_backoff(1, ORG_BACKOFF_BASE_SECS, CREDIT_BACKOFF_MAX_SECS, true),
2611 ORG_BACKOFF_BASE_SECS
2612 );
2613 assert_eq!(BILLING_CYCLE_COOLDOWN_SECS, 24 * 60 * 60);
2615 assert_eq!(BILLING_CYCLE_MAX_SECS, 7 * 24 * 60 * 60);
2616 }
2617
2618 #[serial(cooldown_state)]
2619 #[test]
2620 fn compute_backoff_long_cap_without_extended_stays_capped() {
2621 assert_eq!(
2622 compute_backoff(
2623 3,
2624 BILLING_CYCLE_COOLDOWN_SECS,
2625 BILLING_CYCLE_MAX_SECS,
2626 false
2627 ),
2628 BILLING_CYCLE_MAX_SECS,
2629 "7-day capped paths should stop at the documented max"
2630 );
2631 assert_eq!(
2632 compute_backoff(
2633 9,
2634 PERSISTENT_MODEL_BACKOFF_BASE_SECS,
2635 PERSISTENT_MODEL_BACKOFF_MAX_SECS,
2636 false,
2637 ),
2638 PERSISTENT_MODEL_BACKOFF_MAX_SECS,
2639 "persistent model failures should not extend past 7 days"
2640 );
2641 }
2642
2643 #[serial(cooldown_state)]
2646 #[test]
2647 fn mark_and_check_agent_degraded() {
2648 let agent = "test_degraded_mark";
2649 assert!(!is_agent_degraded(agent));
2650 mark_agent_degraded(agent);
2651 assert!(is_agent_degraded(agent));
2652 clear_agent_degraded(agent);
2653 assert!(!is_agent_degraded(agent));
2654 }
2655
2656 #[serial(cooldown_state)]
2657 #[tokio::test]
2658 async fn sync_from_kv_drops_in_memory_entry_cleared_externally() {
2659 super::reset_global_state().await;
2660 let store = test_store().await;
2661 let agent = "test_sync_external_clear";
2662 let future = chrono::Utc::now().timestamp() + 600;
2663
2664 init_cooldown_store(std::sync::Arc::clone(&store)).await;
2666 set_cooldown_async(agent, future, "test").await;
2667 assert!(is_agent_in_cooldown(agent));
2668
2669 store
2673 .kv_set(&format!("{KV_PREFIX}{agent}"), "0")
2674 .await
2675 .expect("kv_set tombstone");
2676 assert!(
2677 is_agent_in_cooldown(agent),
2678 "in-memory cooldown should still be present before sync"
2679 );
2680
2681 sync_from_kv(&store).await;
2682
2683 assert!(
2684 !is_agent_in_cooldown(agent),
2685 "sync_from_kv must drop in-memory entries the KV tombstoned"
2686 );
2687 }
2688
2689 #[serial(cooldown_state)]
2690 #[tokio::test]
2691 async fn sync_from_kv_closes_github_circuit_when_row_cleared() {
2692 super::reset_global_state().await;
2693 let store = test_store().await;
2694 {
2695 *github_5xx_circuit_open_lock() = Some(chrono::Utc::now().timestamp() + 600);
2696 github_5xx_timestamps_lock().push(chrono::Utc::now().timestamp());
2697 }
2698 assert!(is_github_circuit_open());
2699
2700 store
2701 .kv_set("cooldown:github:5xx", "0")
2702 .await
2703 .expect("kv_set tombstone");
2704 sync_from_kv(&store).await;
2705
2706 assert!(!is_github_circuit_open());
2707 assert!(github_5xx_timestamps_lock().is_empty());
2708 }
2709
2710 #[serial(cooldown_state)]
2711 #[tokio::test]
2712 async fn sync_from_kv_keeps_5xx_window_when_expired_row_and_breaker_closed() {
2713 super::reset_global_state().await;
2714 let store = test_store().await;
2715 let now = chrono::Utc::now().timestamp();
2716 {
2717 github_5xx_timestamps_lock().push(now);
2718 github_5xx_timestamps_lock().push(now);
2719 }
2720
2721 store
2722 .kv_set("cooldown:github:5xx", &(now - 600).to_string())
2723 .await
2724 .expect("kv_set past");
2725 sync_from_kv(&store).await;
2726
2727 assert!(!is_github_circuit_open());
2728 assert_eq!(github_5xx_timestamps_lock().len(), 2);
2729
2730 github_5xx_timestamps_lock().clear();
2731 }
2732
2733 #[serial(cooldown_state)]
2734 #[tokio::test]
2735 async fn sync_from_kv_keeps_github_circuit_open_for_future_row() {
2736 super::reset_global_state().await;
2737 let store = test_store().await;
2738 let future = chrono::Utc::now().timestamp() + 600;
2739 *github_5xx_circuit_open_lock() = Some(future);
2740
2741 store
2742 .kv_set("cooldown:github:5xx", &future.to_string())
2743 .await
2744 .expect("kv_set future");
2745 sync_from_kv(&store).await;
2746
2747 assert!(is_github_circuit_open());
2748
2749 *github_5xx_circuit_open_lock() = None;
2750 }
2751
2752 #[serial(cooldown_state)]
2753 #[tokio::test]
2754 async fn sync_from_kv_keeps_in_memory_entry_when_kv_row_missing() {
2755 super::reset_global_state().await;
2759 let store = test_store().await;
2760 let agent = "test_sync_race_window";
2761 let future = chrono::Utc::now().timestamp() + 600;
2762
2763 set_cooldown_in_memory(agent, future, "raced");
2765 assert!(is_agent_in_cooldown(agent));
2766
2767 sync_from_kv(&store).await;
2768
2769 assert!(
2770 is_agent_in_cooldown(agent),
2771 "sync_from_kv must NOT drop entries whose KV row is missing — that is the set_cooldown_async race window"
2772 );
2773 }
2774
2775 #[serial(cooldown_state)]
2776 #[tokio::test]
2777 async fn sync_from_kv_adopts_kv_timestamp() {
2778 super::reset_global_state().await;
2781 let store = test_store().await;
2782 let agent = "test_sync_adopt_kv";
2783 let original = chrono::Utc::now().timestamp() + 600;
2784 let updated = chrono::Utc::now().timestamp() + 1200;
2785
2786 init_cooldown_store(std::sync::Arc::clone(&store)).await;
2787 set_cooldown_async(agent, original, "initial").await;
2788
2789 store
2790 .kv_set(&format!("{KV_PREFIX}{agent}"), &updated.to_string())
2791 .await
2792 .expect("kv_set updated");
2793
2794 sync_from_kv(&store).await;
2795
2796 let until = cooldown_until(agent).expect("cooldown still active");
2797 assert_eq!(
2798 until, updated,
2799 "sync_from_kv must adopt KV's authoritative timestamp"
2800 );
2801 }
2802
2803 #[serial(cooldown_state)]
2804 #[tokio::test]
2805 async fn refresh_degraded_agents_marks_cooled_agent() {
2806 super::reset_global_state().await;
2807 let store = test_store().await;
2808 let agent = "test_refresh_cooled";
2809
2810 record_agent_failure_with_message(agent, "").await;
2812 assert!(is_agent_in_cooldown(agent));
2813
2814 let agents = vec![agent.to_string()];
2816 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2817
2818 assert!(
2819 is_agent_degraded(agent),
2820 "agent in cooldown should be marked degraded"
2821 );
2822
2823 clear_agent_degraded(agent);
2825 }
2826
2827 #[serial(cooldown_state)]
2828 #[tokio::test]
2829 async fn refresh_degraded_agents_marks_no_models_agent() {
2830 super::reset_global_state().await;
2831 let store = test_store().await;
2832 let agent = "test_refresh_no_models";
2833
2834 let agents = vec![agent.to_string()];
2836 refresh_degraded_agents(&store, &agents, &|_| false, 6, 3).await;
2837
2838 assert!(
2839 is_agent_degraded(agent),
2840 "agent with no available models should be marked degraded"
2841 );
2842
2843 clear_agent_degraded(agent);
2845 }
2846
2847 #[serial(cooldown_state)]
2848 #[tokio::test]
2849 async fn refresh_degraded_agents_marks_rate_limited_agent() {
2850 super::reset_global_state().await;
2851 let store = test_store().await;
2852 let agent = "test_refresh_rate_limited";
2853
2854 for _ in 0..4 {
2856 store.record_rate_limit(agent, "rate", None).await.unwrap();
2857 }
2858
2859 let agents = vec![agent.to_string()];
2860 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2862
2863 assert!(
2864 is_agent_degraded(agent),
2865 "agent with rate limit count >= threshold should be marked degraded"
2866 );
2867
2868 clear_agent_degraded(agent);
2870 }
2871
2872 #[serial(cooldown_state)]
2873 #[tokio::test]
2874 async fn refresh_degraded_agents_clears_healthy_agent() {
2875 super::reset_global_state().await;
2876 let store = test_store().await;
2877 let agent = "test_refresh_healthy";
2878
2879 mark_agent_degraded(agent);
2881 assert!(is_agent_degraded(agent));
2882
2883 let agents = vec![agent.to_string()];
2885 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2886
2887 assert!(
2888 !is_agent_degraded(agent),
2889 "healthy agent should have degraded flag cleared"
2890 );
2891 }
2892
2893 #[serial(cooldown_state)]
2894 #[tokio::test]
2895 async fn refresh_degraded_agents_ignores_expired_cooldowns() {
2896 super::reset_global_state().await;
2897 let store = test_store().await;
2898 let agent = "test_refresh_expired_cooldown";
2899
2900 let expired_ts = chrono::Utc::now().timestamp() - 100; {
2904 let mut map = cooldowns().lock().unwrap();
2905 map.insert(
2906 agent.to_string(),
2907 CooldownEntry {
2908 cooldown_until: expired_ts,
2909 reason: "expired_test".to_string(),
2910 },
2911 );
2912 }
2913
2914 assert!(
2916 !is_agent_in_cooldown(agent),
2917 "expired cooldown should not be active"
2918 );
2919
2920 mark_agent_degraded(agent);
2922 assert!(is_agent_degraded(agent));
2923
2924 let agents = vec![agent.to_string()];
2926 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
2927
2928 assert!(
2929 !is_agent_degraded(agent),
2930 "agent with only expired cooldown should NOT be marked degraded"
2931 );
2932
2933 clear_agent_degraded(agent);
2935 {
2936 let mut map = cooldowns().lock().unwrap();
2937 map.remove(agent);
2938 }
2939 }
2940
2941 #[serial(cooldown_state)]
2942 #[test]
2943 fn compute_backoff_grows_exponentially() {
2944 assert_eq!(compute_backoff(0, 300, 14400, true), 300);
2946 assert_eq!(compute_backoff(1, 300, 14400, true), 300);
2947 assert_eq!(compute_backoff(2, 300, 14400, true), 900);
2949 assert_eq!(compute_backoff(3, 300, 14400, true), 2700);
2951 assert_eq!(compute_backoff(4, 300, 14400, true), 8100);
2953 assert_eq!(compute_backoff(5, 300, 14400, true), 14400);
2955 }
2956
2957 #[serial(cooldown_state)]
2958 #[test]
2959 fn compute_backoff_extended_tier_1_applies_24h() {
2960 assert_eq!(
2966 compute_backoff(5, 300, 14400, true),
2967 14400,
2968 "first at max, not extended"
2969 );
2970 assert_eq!(
2971 compute_backoff(6, 300, 14400, true),
2972 14400,
2973 "times_at_max=1, not extended"
2974 );
2975 assert_eq!(
2976 compute_backoff(7, 300, 14400, true),
2977 86400,
2978 "times_at_max=2, extended 6x"
2979 );
2980 assert_eq!(
2981 compute_backoff(8, 300, 14400, true),
2982 86400,
2983 "times_at_max=3, still extended 6x"
2984 );
2985 }
2986
2987 #[serial(cooldown_state)]
2988 #[test]
2989 fn compute_backoff_extended_tier_2_applies_48h() {
2990 assert_eq!(
2993 compute_backoff(9, 300, 14400, true),
2994 86400,
2995 "times_at_max=4, still tier 1"
2996 );
2997 assert_eq!(
2998 compute_backoff(10, 300, 14400, true),
2999 172_800,
3000 "times_at_max=5, extended 12x"
3001 );
3002 assert_eq!(
3003 compute_backoff(15, 300, 14400, true),
3004 172_800,
3005 "times_at_max=10, still tier 2"
3006 );
3007 assert_eq!(
3008 compute_backoff(20, 300, 14400, true),
3009 172_800,
3010 "times_at_max=15, still tier 2"
3011 );
3012 }
3013
3014 #[serial(cooldown_state)]
3015 #[test]
3016 fn compute_backoff_extended_tier_with_different_base_max() {
3017 assert_eq!(compute_backoff(5, 60, 3600, true), 3600, "first at max");
3024 assert_eq!(compute_backoff(6, 60, 3600, true), 3600, "times_at_max=1");
3025 assert_eq!(
3026 compute_backoff(7, 60, 3600, true),
3027 21600,
3028 "times_at_max=2, extended 6x"
3029 );
3030 }
3031
3032 #[serial(cooldown_state)]
3033 #[test]
3034 fn compute_backoff_extended_tier_persistent_fails_example() {
3035 assert_eq!(
3039 compute_backoff(21, 300, 14400, true),
3040 172_800,
3041 "21 failures should get 48h extended cooldown"
3042 );
3043
3044 assert_eq!(
3046 compute_backoff(36, 300, 14400, true),
3047 172_800,
3048 "36 failures should also get 48h extended cooldown"
3049 );
3050 }
3051
3052 #[serial(cooldown_state)]
3053 #[test]
3054 fn compute_backoff_extended_tier_zero_base() {
3055 assert_eq!(compute_backoff(1, 0, 14400, true), 0);
3057 assert_eq!(compute_backoff(5, 0, 14400, true), 0);
3058 assert_eq!(compute_backoff(10, 0, 14400, true), 0);
3059 }
3060
3061 #[serial(cooldown_state)]
3062 #[test]
3063 fn detect_credit_exhaustion_billing_cycle() {
3064 assert_eq!(
3065 detect_credit_exhaustion("billing cycle"),
3066 Some(CreditExhaustionReason::BillingCycleExhausted)
3067 );
3068 assert_eq!(
3069 detect_credit_exhaustion("quota refreshed next cycle"),
3070 Some(CreditExhaustionReason::BillingCycleExhausted)
3071 );
3072 assert_eq!(
3073 detect_credit_exhaustion(
3074 "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle."
3075 ),
3076 Some(CreditExhaustionReason::BillingCycleExhausted)
3077 );
3078 assert_eq!(
3079 detect_credit_exhaustion("monthly quota exceeded"),
3080 Some(CreditExhaustionReason::BillingCycleExhausted)
3081 );
3082 assert_eq!(
3083 detect_credit_exhaustion(
3084 "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-23 18:33:48"
3085 ),
3086 Some(CreditExhaustionReason::BillingCycleExhausted)
3087 );
3088 }
3089
3090 #[serial(cooldown_state)]
3091 #[test]
3092 fn detect_credit_exhaustion_billing_cycle_minimax() {
3093 assert_eq!(
3094 detect_credit_exhaustion(
3095 "minimax rate limit: API Error: Request rejected (429) · Token Plan usage limit reached: Upgrade your Token Plan or purchase Credits for more usage."
3096 ),
3097 Some(CreditExhaustionReason::BillingCycleExhausted)
3098 );
3099 assert_eq!(
3100 detect_credit_exhaustion("Upgrade your Token Plan"),
3101 Some(CreditExhaustionReason::BillingCycleExhausted)
3102 );
3103 assert_eq!(
3104 detect_credit_exhaustion("Token Plan usage limit reached"),
3105 Some(CreditExhaustionReason::BillingCycleExhausted)
3106 );
3107 assert_eq!(
3108 detect_credit_exhaustion("purchase Credits for more usage"),
3109 Some(CreditExhaustionReason::BillingCycleExhausted)
3110 );
3111 assert_eq!(
3112 detect_credit_exhaustion(
3113 "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits"
3114 ),
3115 Some(CreditExhaustionReason::BillingCycleExhausted)
3116 );
3117 }
3118
3119 #[serial(cooldown_state)]
3120 #[test]
3121 fn detect_credit_exhaustion_outcome_kimi_billing_cycle() {
3122 let msg = "Failed to authenticate. API Error: 403 You've reached your usage limit for \
3127 this billing cycle. Your quota will be refreshed soon.";
3128 assert_eq!(
3129 detect_credit_exhaustion_outcome(msg),
3130 Some("billing_cycle_exhausted")
3131 );
3132 }
3133
3134 #[serial(cooldown_state)]
3135 #[test]
3136 fn detect_credit_exhaustion_outcome_maps_all_reasons() {
3137 assert_eq!(
3138 detect_credit_exhaustion_outcome("out of credits"),
3139 Some("credit_exhausted")
3140 );
3141 assert_eq!(
3142 detect_credit_exhaustion_outcome("organization has been disabled"),
3143 Some("org_level_disabled")
3144 );
3145 assert_eq!(
3146 detect_credit_exhaustion_outcome("monthly quota exceeded"),
3147 Some("billing_cycle_exhausted")
3148 );
3149 }
3150
3151 #[test]
3152 fn detect_credit_exhaustion_outcome_returns_none_for_unrelated_errors() {
3153 assert_eq!(
3154 detect_credit_exhaustion_outcome("429 Too Many Requests"),
3155 None
3156 );
3157 assert_eq!(detect_credit_exhaustion_outcome("connection reset"), None);
3158 }
3159
3160 #[serial(cooldown_state)]
3161 #[test]
3162 fn detect_credit_exhaustion_overage_takes_priority_over_billing_cycle() {
3163 let msg = "overageDisabledReason: org_level_disabled for billing cycle";
3166 assert_eq!(
3167 detect_credit_exhaustion(msg),
3168 Some(CreditExhaustionReason::OrgLevelDisabled)
3169 );
3170 }
3171
3172 #[serial(cooldown_state)]
3173 #[tokio::test]
3174 async fn refresh_degraded_agents_below_threshold_not_degraded() {
3175 super::reset_global_state().await;
3176 let store = test_store().await;
3177 let agent = "test_refresh_below_threshold";
3178
3179 for _ in 0..2 {
3181 store.record_rate_limit(agent, "rate", None).await.unwrap();
3182 }
3183
3184 let agents = vec![agent.to_string()];
3185 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3187
3188 assert!(
3189 !is_agent_degraded(agent),
3190 "agent with rate limit count < threshold should not be degraded"
3191 );
3192 }
3193
3194 #[serial(cooldown_state)]
3195 #[tokio::test]
3196 async fn refresh_degraded_agents_ignores_error_limit_type() {
3197 super::reset_global_state().await;
3198 let store = test_store().await;
3199 let agent = "test_refresh_error_rows";
3200
3201 for _ in 0..3 {
3202 store.record_rate_limit(agent, "error", None).await.unwrap();
3203 }
3204
3205 let agents = vec![agent.to_string()];
3206 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3207
3208 assert!(
3209 !is_agent_degraded(agent),
3210 "three error rows in 6 hours must not mark an agent degraded"
3211 );
3212 }
3213
3214 #[serial(cooldown_state)]
3215 #[tokio::test]
3216 async fn refresh_degraded_agents_marks_three_rate_rows() {
3217 super::reset_global_state().await;
3218 let store = test_store().await;
3219 let agent = "test_refresh_three_rate_rows";
3220
3221 for _ in 0..3 {
3222 store.record_rate_limit(agent, "rate", None).await.unwrap();
3223 }
3224
3225 let agents = vec![agent.to_string()];
3226 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3227
3228 assert!(
3229 is_agent_degraded(agent),
3230 "three rate rows in 6 hours must mark an agent degraded"
3231 );
3232 assert_eq!(
3233 agent_degraded_reason(agent).as_deref(),
3234 Some("rate limit threshold exceeded")
3235 );
3236 let listed = list_degraded_agents();
3237 assert!(
3238 listed
3239 .iter()
3240 .any(|(a, r)| a == agent && r == "rate limit threshold exceeded"),
3241 "orch cooldown list must show the degraded reason, got {listed:?}"
3242 );
3243
3244 clear_agent_degraded(agent);
3245 }
3246
3247 #[serial(cooldown_state)]
3248 #[tokio::test]
3249 async fn refresh_degraded_agents_logs_reason_change() {
3250 super::reset_global_state().await;
3251 let store = test_store().await;
3252 let agent = "test_refresh_reason_change";
3253 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3254
3255 set_agent_cooldown(agent, 600).await;
3256 let agents = vec![agent.to_string()];
3257 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3258 assert_eq!(
3259 agent_degraded_reason(agent).as_deref(),
3260 Some("agent in cooldown")
3261 );
3262
3263 clear_cooldown(agent, &store).await;
3264 let after_clear = sqlite_datetime_offset(60);
3265 for _ in 0..3 {
3266 store
3267 .record_rate_limit_at(agent, "rate", None, &after_clear)
3268 .await
3269 .unwrap();
3270 }
3271 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3272 assert_eq!(
3273 agent_degraded_reason(agent).as_deref(),
3274 Some("rate limit threshold exceeded"),
3275 "degraded reason must update when the hold changes"
3276 );
3277
3278 clear_agent_degraded(agent);
3279 }
3280
3281 #[serial(cooldown_state)]
3282 #[tokio::test]
3283 async fn clear_un_degrades_on_next_health_check() {
3284 super::reset_global_state().await;
3285 let store = test_store().await;
3286 let agent = "test_clear_un_degrades";
3287 let before = sqlite_datetime_offset(-30);
3288 let after = sqlite_datetime_offset(60);
3289
3290 for _ in 0..3 {
3291 store
3292 .record_rate_limit_at(agent, "rate", None, &before)
3293 .await
3294 .unwrap();
3295 }
3296
3297 let agents = vec![agent.to_string()];
3298 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3299 assert!(
3300 is_agent_degraded(agent),
3301 "three rate rows must mark an agent degraded"
3302 );
3303
3304 clear_cooldown(agent, &store).await;
3305 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3306 assert!(
3307 !is_agent_degraded(agent),
3308 "clear must un-degrade on the next health check"
3309 );
3310
3311 store
3312 .record_rate_limit_at(agent, "rate", None, &after)
3313 .await
3314 .unwrap();
3315 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3316 assert!(
3317 !is_agent_degraded(agent),
3318 "one rate row after the mark must not degrade again"
3319 );
3320
3321 for _ in 0..2 {
3322 store
3323 .record_rate_limit_at(agent, "rate", None, &after)
3324 .await
3325 .unwrap();
3326 }
3327 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3328 assert!(
3329 is_agent_degraded(agent),
3330 "three rate rows after the mark must degrade again"
3331 );
3332
3333 clear_agent_degraded(agent);
3334 }
3335
3336 #[serial(cooldown_state)]
3337 #[tokio::test]
3338 async fn clear_all_un_degrades_rate_limited_agent() {
3339 super::reset_global_state().await;
3340 let store = test_store().await;
3341 let agent = "test_clear_all_un_degrades";
3342 let before = sqlite_datetime_offset(-30);
3343
3344 for _ in 0..3 {
3345 store
3346 .record_rate_limit_at(agent, "rate", None, &before)
3347 .await
3348 .unwrap();
3349 }
3350
3351 let agents = vec![agent.to_string()];
3352 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3353 assert!(is_agent_degraded(agent));
3354
3355 clear_cooldown("*", &store).await;
3356 refresh_degraded_agents(&store, &agents, &|_| true, 6, 3).await;
3357 assert!(
3358 !is_agent_degraded(agent),
3359 "clear --all must un-degrade an agent held only by rate rows"
3360 );
3361 }
3362
3363 #[serial(cooldown_state)]
3366 #[tokio::test]
3367 async fn repeated_agent_failures_escalate_backoff() {
3368 super::reset_global_state().await;
3369 let store = test_store().await;
3370 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3371
3372 let agent = "test_escalation_agent";
3373
3374 record_agent_failure_with_message(agent, "").await;
3376 let remaining_1 = {
3377 let map = cooldowns().lock().unwrap();
3378 let entry = map.get(agent).expect("should have cooldown");
3379 entry.cooldown_until - chrono::Utc::now().timestamp()
3380 };
3381 assert!(
3382 (BACKOFF_BASE_SECS - 5..=BACKOFF_BASE_SECS + 5).contains(&remaining_1),
3383 "first failure should be ~5 min, got {remaining_1}s"
3384 );
3385
3386 {
3388 let mut map = cooldowns().lock().unwrap();
3389 map.remove(agent);
3390 }
3391
3392 record_agent_failure_with_message(agent, "").await;
3394 let remaining_2 = {
3395 let map = cooldowns().lock().unwrap();
3396 let entry = map.get(agent).expect("should have cooldown");
3397 entry.cooldown_until - chrono::Utc::now().timestamp()
3398 };
3399 assert!(
3400 remaining_2 >= BACKOFF_BASE_SECS * 3 - 5,
3401 "second failure should be ~15 min, got {remaining_2}s"
3402 );
3403 }
3404
3405 #[serial(cooldown_state)]
3406 #[tokio::test]
3407 async fn vendor_past_retry_falls_back_to_backoff() {
3408 super::reset_global_state().await;
3409 let store = test_store().await;
3413 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3414
3415 let agent = "test_vendor_past_retry";
3416
3417 {
3419 let mut map = cooldowns().lock().unwrap();
3420 map.remove(agent);
3421 }
3422
3423 let past_msg = "You've hit the limit; try again at Jan 01, 2000 01:00 AM.";
3425
3426 record_agent_failure_with_message(agent, past_msg).await;
3427
3428 assert!(
3432 is_agent_in_cooldown(agent),
3433 "agent should be in cooldown after failure"
3434 );
3435
3436 let remaining = {
3437 let map = cooldowns().lock().unwrap();
3438 let entry = map.get(agent).expect("cooldown entry should exist");
3439 entry.cooldown_until - chrono::Utc::now().timestamp()
3440 };
3441
3442 assert!(
3443 remaining >= BACKOFF_BASE_SECS - 5,
3444 "expected backoff of ~{BACKOFF_BASE_SECS}s when vendor timestamp is past, got {remaining}s"
3445 );
3446
3447 let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3449 let stored = store
3450 .kv_get(&kv_key)
3451 .await
3452 .unwrap()
3453 .expect("failure count should be stored");
3454 assert_eq!(stored, "1");
3455 }
3456
3457 #[serial(cooldown_state)]
3458 #[tokio::test]
3459 async fn record_agent_success_resets_backoff() {
3460 super::reset_global_state().await;
3461 let store = test_store().await;
3462 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3463
3464 let agent = "test_success_reset_agent";
3465 let model = "test_model";
3466
3467 for _ in 0..3 {
3469 let _ = read_and_increment_failure_count(Some(&store), agent).await;
3470 }
3471 let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3473 let count: u32 = store
3474 .kv_get(&kv_key)
3475 .await
3476 .unwrap()
3477 .unwrap()
3478 .parse()
3479 .unwrap();
3480 assert_eq!(count, 3);
3481
3482 record_agent_success(agent, model).await;
3484
3485 let count_after: u32 = store
3486 .kv_get(&kv_key)
3487 .await
3488 .unwrap()
3489 .unwrap()
3490 .parse()
3491 .unwrap();
3492 assert_eq!(count_after, 0, "success should reset failure count to 0");
3493
3494 let model_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}:{model}");
3496 let model_count: u32 = store
3497 .kv_get(&model_kv_key)
3498 .await
3499 .unwrap()
3500 .unwrap()
3501 .parse()
3502 .unwrap();
3503 assert_eq!(
3504 model_count, 0,
3505 "success should reset model failure count to 0"
3506 );
3507 }
3508
3509 #[serial(cooldown_state)]
3510 #[tokio::test]
3511 async fn record_agent_success_without_model_skips_empty_model_key() {
3512 super::reset_global_state().await;
3513 let store = test_store().await;
3514 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3515
3516 let agent = "test_success_without_model";
3517
3518 record_agent_success(agent, "").await;
3519
3520 let agent_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3521 let agent_count = store
3522 .kv_get(&agent_kv_key)
3523 .await
3524 .unwrap()
3525 .expect("agent failure count should be stored");
3526 assert_eq!(agent_count, "0");
3527
3528 let empty_model_kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}:");
3529 let empty_model_count = store.kv_get(&empty_model_kv_key).await.unwrap();
3530 assert!(
3531 empty_model_count.is_none(),
3532 "success without model should not create an empty model failure count key"
3533 );
3534 }
3535
3536 #[serial(cooldown_state)]
3537 #[tokio::test]
3538 async fn clear_cooldown_resets_failure_counts() {
3539 super::reset_global_state().await;
3540 let store = test_store().await;
3541 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3542
3543 let agent = "test_clear_fc_agent";
3544
3545 for _ in 0..5 {
3547 let _ = read_and_increment_failure_count(Some(&store), agent).await;
3548 }
3549 set_agent_cooldown(agent, 3600).await;
3551
3552 let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3554 let count: u32 = store
3555 .kv_get(&kv_key)
3556 .await
3557 .unwrap()
3558 .unwrap()
3559 .parse()
3560 .unwrap();
3561 assert_eq!(count, 5);
3562
3563 clear_cooldown(agent, &store).await;
3565
3566 let count_after: u32 = store
3568 .kv_get(&kv_key)
3569 .await
3570 .unwrap()
3571 .unwrap()
3572 .parse()
3573 .unwrap();
3574 assert_eq!(
3575 count_after, 0,
3576 "clear_cooldown should reset failure count to 0"
3577 );
3578
3579 assert!(
3581 !is_agent_in_cooldown(agent),
3582 "agent should not be in cooldown after clear"
3583 );
3584 }
3585
3586 #[serial(cooldown_state)]
3587 #[tokio::test]
3588 async fn clear_all_cooldowns_resets_all_failure_counts() {
3589 super::reset_global_state().await;
3590 let store = test_store().await;
3591 *cooldown_store().lock().await = Some(std::sync::Arc::clone(&store));
3592
3593 let agents = ["test_clear_all_a", "test_clear_all_b"];
3594 for agent in &agents {
3595 let _ = read_and_increment_failure_count(Some(&store), agent).await;
3596 set_agent_cooldown(agent, 3600).await;
3597 }
3598
3599 clear_cooldown("*", &store).await;
3600
3601 for agent in &agents {
3602 assert!(!is_agent_in_cooldown(agent));
3603 let kv_key = format!("{FAILURE_COUNT_PREFIX}{agent}");
3604 let count: u32 = store
3605 .kv_get(&kv_key)
3606 .await
3607 .unwrap()
3608 .unwrap()
3609 .parse()
3610 .unwrap();
3611 assert_eq!(
3612 count, 0,
3613 "clear --all should reset failure count for {agent}"
3614 );
3615 }
3616 }
3617
3618 #[serial(cooldown_state)]
3619 #[test]
3620 fn compute_backoff_saturates_on_large_counts() {
3621 assert_eq!(
3623 compute_backoff(100, BACKOFF_BASE_SECS, BACKOFF_MAX_SECS, true),
3624 BACKOFF_MAX_SECS * 12
3625 );
3626 assert_eq!(
3627 compute_backoff(u64::from(u32::MAX), 300, 14400, true),
3628 172_800
3629 );
3630 assert_eq!(compute_backoff(u64::MAX, 300, 14400, true), 172_800);
3631 }
3632
3633 #[serial(cooldown_state)]
3634 #[test]
3635 fn compute_backoff_zero_base_returns_zero() {
3636 assert_eq!(compute_backoff(1, 0, 14400, true), 0);
3637 assert_eq!(compute_backoff(5, 0, 14400, true), 0);
3638 }
3639
3640 #[serial(cooldown_state)]
3643 #[tokio::test]
3644 async fn github_circuit_stays_closed_below_threshold() {
3645 super::reset_global_state().await;
3646 {
3648 let mut open = github_5xx_circuit_open().lock().unwrap();
3649 *open = None;
3650 let mut ts = github_5xx_timestamps().lock().unwrap();
3651 ts.clear();
3652 let mut map = cooldown_lock();
3656 map.remove("github:5xx");
3657 }
3658
3659 for _ in 0..GITHUB_5XX_THRESHOLD - 1 {
3661 record_github_5xx().await;
3662 }
3663
3664 assert!(
3665 !is_github_circuit_open(),
3666 "circuit should stay closed below threshold"
3667 );
3668 assert_eq!(github_circuit_remaining_secs(), 0);
3669 }
3670
3671 #[serial(cooldown_state)]
3672 #[tokio::test]
3673 async fn github_circuit_trips_at_threshold() {
3674 super::reset_global_state().await;
3675 {
3677 let mut open = github_5xx_circuit_open_lock();
3678 *open = None;
3679 let mut ts = github_5xx_timestamps_lock();
3680 ts.clear();
3681 let mut map = cooldown_lock();
3682 map.remove("github:5xx");
3683 }
3684
3685 for _ in 0..GITHUB_5XX_THRESHOLD {
3687 record_github_5xx().await;
3688 }
3689
3690 assert!(is_github_circuit_open(), "circuit should trip at threshold");
3691 assert!(
3692 github_circuit_remaining_secs() > 0,
3693 "remaining should be > 0 when open"
3694 );
3695 assert!(
3696 github_circuit_remaining_secs() <= GITHUB_5XX_COOLDOWN_SECS as u64,
3697 "remaining should not exceed cooldown duration"
3698 );
3699
3700 {
3702 let mut open = github_5xx_circuit_open().lock().unwrap();
3703 *open = None;
3704 }
3705 }
3706
3707 #[serial(cooldown_state)]
3708 #[tokio::test]
3709 async fn github_circuit_cannot_double_trip() {
3710 super::reset_global_state().await;
3711 {
3713 let mut open = github_5xx_circuit_open_lock();
3714 *open = None;
3715 let mut ts = github_5xx_timestamps_lock();
3716 ts.clear();
3717 let mut map = cooldown_lock();
3718 map.remove("github:5xx");
3719 }
3720
3721 for _ in 0..GITHUB_5XX_THRESHOLD {
3723 record_github_5xx().await;
3724 }
3725 assert!(is_github_circuit_open());
3726
3727 let first_remaining = github_circuit_remaining_secs();
3728
3729 for _ in 0..GITHUB_5XX_THRESHOLD {
3731 record_github_5xx().await;
3732 }
3733
3734 let second_remaining = github_circuit_remaining_secs();
3735 assert!(
3736 second_remaining <= first_remaining,
3737 "additional errors should not extend cooldown"
3738 );
3739
3740 {
3742 let mut open = github_5xx_circuit_open().lock().unwrap();
3743 *open = None;
3744 }
3745 }
3746
3747 #[serial(cooldown_state)]
3748 #[tokio::test]
3749 async fn github_circuit_recovers_after_cooldown() {
3750 super::reset_global_state().await;
3751 {
3753 let mut open = github_5xx_circuit_open_lock();
3754 *open = None;
3755 let mut ts = github_5xx_timestamps_lock();
3756 ts.clear();
3757 let mut map = cooldown_lock();
3758 map.remove("github:5xx");
3759 }
3760
3761 {
3763 let mut open = github_5xx_circuit_open().lock().unwrap();
3764 *open = Some(chrono::Utc::now().timestamp() - 1);
3765 }
3766
3767 assert!(
3769 !is_github_circuit_open(),
3770 "circuit should auto-recover after cooldown"
3771 );
3772 }
3773
3774 #[serial(cooldown_state)]
3775 #[tokio::test]
3776 async fn github_circuit_sliding_window_ages_out() {
3777 super::reset_global_state().await;
3778 {
3780 let mut open = github_5xx_circuit_open_lock();
3781 *open = None;
3782 let mut ts = github_5xx_timestamps_lock();
3783 ts.clear();
3784 let mut map = cooldown_lock();
3785 map.remove("github:5xx");
3786 }
3787
3788 {
3790 let mut ts = github_5xx_timestamps().lock().unwrap();
3791 let old = chrono::Utc::now().timestamp() - GITHUB_5XX_WINDOW_SECS - 10;
3792 for _ in 0..GITHUB_5XX_THRESHOLD {
3793 ts.push(old);
3794 }
3795 }
3796
3797 record_github_5xx().await;
3799
3800 assert!(
3801 !is_github_circuit_open(),
3802 "old errors outside window should not trip circuit"
3803 );
3804 }
3805}