1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::{Arc, Mutex};
4
5use crate::config::{ServiceConfig, UpstreamConfig};
6use crate::runtime_identity::ProviderEndpointKey;
7use tracing::info;
8
9pub const FAILURE_THRESHOLD: u32 = 3;
10pub const COOLDOWN_SECS: u64 = 30;
11
12#[derive(Debug, Clone, Copy)]
13pub struct CooldownBackoff {
14 pub factor: u64,
15 pub max_secs: u64,
16}
17
18impl CooldownBackoff {
19 pub(crate) fn effective_cooldown_secs(&self, base_secs: u64, penalty_streak: u32) -> u64 {
20 if base_secs == 0 {
21 return 0;
22 }
23 if self.factor <= 1 {
24 return base_secs;
25 }
26 let cap = if self.max_secs == 0 {
27 base_secs
28 } else {
29 self.max_secs.max(base_secs)
30 };
31
32 let mut secs = base_secs;
33 for _ in 0..penalty_streak.min(64) {
34 secs = secs.saturating_mul(self.factor);
35 if secs >= cap {
36 return cap;
37 }
38 }
39 secs.min(cap)
40 }
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct LbState {
45 pub failure_counts: Vec<u32>,
46 pub cooldown_until: Vec<Option<std::time::Instant>>,
47 pub usage_exhausted: Vec<bool>,
48 pub last_good_index: Option<usize>,
49 pub penalty_streak: Vec<u32>,
50 pub(crate) upstream_signature: Vec<String>,
51}
52
53impl LbState {
54 pub(crate) fn ensure_layout(&mut self, service_name: &str, upstreams: &[UpstreamConfig]) {
55 let signature = upstreams
56 .iter()
57 .enumerate()
58 .map(|(idx, upstream)| upstream_signature_key(service_name, idx, upstream))
59 .collect::<Vec<_>>();
60 let legacy_signature = upstreams
61 .iter()
62 .map(|upstream| upstream.base_url.clone())
63 .collect::<Vec<_>>();
64
65 if has_duplicate_signatures(&signature) {
66 self.reset_for_layout(signature);
67 return;
68 }
69
70 let len = upstreams.len();
71 if self.upstream_signature == signature
72 && self.failure_counts.len() == len
73 && self.cooldown_until.len() == len
74 && self.usage_exhausted.len() == len
75 && self.penalty_streak.len() == len
76 {
77 return;
78 }
79
80 self.migrate_layout(signature, legacy_signature);
81 }
82
83 fn reset_for_layout(&mut self, signature: Vec<String>) {
84 let len = signature.len();
85 self.failure_counts = vec![0; len];
86 self.cooldown_until = vec![None; len];
87 self.usage_exhausted = vec![false; len];
88 self.penalty_streak = vec![0; len];
89 self.last_good_index = None;
91 self.upstream_signature = signature;
92 }
93
94 fn migrate_layout(&mut self, signature: Vec<String>, legacy_signature: Vec<String>) {
95 if self.upstream_signature.is_empty() {
96 self.reset_for_layout(signature);
97 return;
98 }
99
100 let old_signature = std::mem::take(&mut self.upstream_signature);
101 if has_duplicate_signatures(&old_signature) {
102 self.reset_for_layout(signature);
103 return;
104 }
105
106 let old_index_by_signature = old_signature
107 .iter()
108 .enumerate()
109 .map(|(idx, key)| (key.clone(), idx))
110 .collect::<std::collections::HashMap<_, _>>();
111 let legacy_fallback_enabled = !has_duplicate_signatures(&legacy_signature);
112
113 let old_failure_counts = std::mem::take(&mut self.failure_counts);
114 let old_cooldown_until = std::mem::take(&mut self.cooldown_until);
115 let old_usage_exhausted = std::mem::take(&mut self.usage_exhausted);
116 let old_penalty_streak = std::mem::take(&mut self.penalty_streak);
117 let old_last_good_index = self.last_good_index.take();
118
119 let len = signature.len();
120 self.failure_counts = vec![0; len];
121 self.cooldown_until = vec![None; len];
122 self.usage_exhausted = vec![false; len];
123 self.penalty_streak = vec![0; len];
124
125 for (new_idx, key) in signature.iter().enumerate() {
126 let old_idx = old_index_by_signature.get(key).copied().or_else(|| {
127 legacy_fallback_enabled
128 .then(|| legacy_signature.get(new_idx))
129 .flatten()
130 .and_then(|legacy_key| old_index_by_signature.get(legacy_key).copied())
131 });
132 let Some(old_idx) = old_idx else {
133 continue;
134 };
135 self.failure_counts[new_idx] = old_failure_counts.get(old_idx).copied().unwrap_or(0);
136 self.cooldown_until[new_idx] = old_cooldown_until.get(old_idx).and_then(|until| *until);
137 self.usage_exhausted[new_idx] =
138 old_usage_exhausted.get(old_idx).copied().unwrap_or(false);
139 self.penalty_streak[new_idx] = old_penalty_streak.get(old_idx).copied().unwrap_or(0);
140 }
141
142 self.last_good_index = old_last_good_index.and_then(|old_idx| {
143 old_signature.get(old_idx).and_then(|key| {
144 signature
145 .iter()
146 .position(|new_key| new_key == key)
147 .or_else(|| {
148 legacy_fallback_enabled
149 .then(|| {
150 legacy_signature
151 .iter()
152 .position(|legacy_key| legacy_key == key)
153 })
154 .flatten()
155 })
156 })
157 });
158 self.upstream_signature = signature;
159 }
160}
161
162fn has_duplicate_signatures(values: &[String]) -> bool {
163 let mut seen = HashSet::new();
164 values.iter().any(|value| !seen.insert(value))
165}
166
167fn upstream_signature_key(
168 service_name: &str,
169 upstream_index: usize,
170 upstream: &UpstreamConfig,
171) -> String {
172 let provider_id = upstream
173 .tags
174 .get("provider_id")
175 .cloned()
176 .unwrap_or_else(|| format!("{service_name}#{upstream_index}"));
177 let endpoint_id = upstream
178 .tags
179 .get("endpoint_id")
180 .cloned()
181 .unwrap_or_else(|| upstream_index.to_string());
182 let provider_endpoint = ProviderEndpointKey::new(service_name, provider_id, endpoint_id);
183 format!("{}|{}", provider_endpoint.stable_key(), upstream.base_url)
184}
185
186#[derive(Debug, Clone)]
188pub struct SelectedUpstream {
189 pub station_name: String,
190 pub index: usize,
191 pub upstream: UpstreamConfig,
192}
193
194#[derive(Clone)]
196pub struct LoadBalancer {
197 pub service: Arc<ServiceConfig>,
198 pub states: Arc<Mutex<HashMap<String, LbState>>>,
199}
200
201impl LoadBalancer {
202 pub fn new(service: Arc<ServiceConfig>, states: Arc<Mutex<HashMap<String, LbState>>>) -> Self {
203 Self { service, states }
204 }
205
206 #[cfg(test)]
207 pub fn select_upstream(&self) -> Option<SelectedUpstream> {
208 self.select_upstream_avoiding(&HashSet::new())
209 }
210
211 pub fn select_upstream_avoiding(&self, avoid: &HashSet<usize>) -> Option<SelectedUpstream> {
212 self.select_upstream_avoiding_inner(avoid, false)
213 }
214
215 pub fn select_upstream_avoiding_strict(
216 &self,
217 avoid: &HashSet<usize>,
218 ) -> Option<SelectedUpstream> {
219 self.select_upstream_avoiding_inner(avoid, true)
220 }
221
222 fn select_upstream_avoiding_inner(
223 &self,
224 avoid: &HashSet<usize>,
225 strict: bool,
226 ) -> Option<SelectedUpstream> {
227 if self.service.upstreams.is_empty() {
228 return None;
229 }
230
231 let mut map = match self.states.lock() {
232 Ok(m) => m,
233 Err(e) => e.into_inner(),
234 };
235 let entry = map.entry(self.service.name.clone()).or_default();
236 entry.ensure_layout(self.service.name.as_str(), &self.service.upstreams);
237
238 let now = std::time::Instant::now();
239
240 for idx in 0..self.service.upstreams.len() {
242 if let Some(until) = entry.cooldown_until.get(idx).and_then(|v| *v)
243 && now >= until
244 {
245 entry.failure_counts[idx] = 0;
246 if let Some(slot) = entry.cooldown_until.get_mut(idx) {
247 *slot = None;
248 }
249 }
250 }
251
252 if let Some(idx) = entry.last_good_index
255 && idx < self.service.upstreams.len()
256 && entry.failure_counts[idx] < FAILURE_THRESHOLD
257 && !entry.usage_exhausted.get(idx).copied().unwrap_or(false)
258 && !avoid.contains(&idx)
259 {
260 let upstream = self.service.upstreams[idx].clone();
261 return Some(SelectedUpstream {
262 station_name: self.service.name.clone(),
263 index: idx,
264 upstream,
265 });
266 }
267
268 if let Some(idx) = self
270 .service
271 .upstreams
272 .iter()
273 .enumerate()
274 .find_map(|(idx, _)| {
275 if avoid.contains(&idx) {
276 return None;
277 }
278 if entry.failure_counts[idx] >= FAILURE_THRESHOLD {
279 return None;
280 }
281 if entry.usage_exhausted.get(idx).copied().unwrap_or(false) {
282 return None;
283 }
284 Some(idx)
285 })
286 {
287 let upstream = self.service.upstreams[idx].clone();
288 return Some(SelectedUpstream {
289 station_name: self.service.name.clone(),
290 index: idx,
291 upstream,
292 });
293 }
294
295 if let Some(idx) = self
297 .service
298 .upstreams
299 .iter()
300 .enumerate()
301 .find_map(|(idx, _)| {
302 if avoid.contains(&idx) {
303 return None;
304 }
305 if entry.usage_exhausted.get(idx).copied().unwrap_or(false) {
306 return None;
307 }
308 if strict && entry.failure_counts[idx] >= FAILURE_THRESHOLD {
309 return None;
310 }
311 Some(idx)
312 })
313 {
314 let upstream = self.service.upstreams[idx].clone();
315 return Some(SelectedUpstream {
316 station_name: self.service.name.clone(),
317 index: idx,
318 upstream,
319 });
320 }
321
322 None
323 }
324
325 pub fn penalize_with_backoff(
326 &self,
327 index: usize,
328 cooldown_secs: u64,
329 reason: &str,
330 backoff: CooldownBackoff,
331 ) {
332 let mut map = match self.states.lock() {
333 Ok(m) => m,
334 Err(_) => return,
335 };
336 let entry = map
337 .entry(self.service.name.clone())
338 .or_insert_with(LbState::default);
339 entry.ensure_layout(self.service.name.as_str(), &self.service.upstreams);
340 if index >= entry.failure_counts.len() {
341 return;
342 }
343
344 let streak = entry.penalty_streak.get(index).copied().unwrap_or(0);
345 let effective_secs = backoff.effective_cooldown_secs(cooldown_secs, streak);
346
347 entry.failure_counts[index] = FAILURE_THRESHOLD;
348 if let Some(slot) = entry.cooldown_until.get_mut(index) {
349 *slot =
350 Some(std::time::Instant::now() + std::time::Duration::from_secs(effective_secs));
351 }
352 if let Some(slot) = entry.penalty_streak.get_mut(index) {
353 *slot = streak.saturating_add(1);
354 }
355 if entry.last_good_index == Some(index) {
356 entry.last_good_index = None;
357 }
358 info!(
359 "lb: upstream '{}' index {} penalized for {}s (reason: {})",
360 self.service.name, index, effective_secs, reason
361 );
362 }
363
364 pub fn record_result_with_backoff(
365 &self,
366 index: usize,
367 success: bool,
368 failure_threshold_cooldown_secs: u64,
369 backoff: CooldownBackoff,
370 ) {
371 let mut map = match self.states.lock() {
372 Ok(m) => m,
373 Err(_) => return,
374 };
375 let entry = map
376 .entry(self.service.name.clone())
377 .or_insert_with(LbState::default);
378 entry.ensure_layout(self.service.name.as_str(), &self.service.upstreams);
379 if index >= entry.failure_counts.len() {
380 return;
381 }
382 if success {
383 entry.failure_counts[index] = 0;
384 if let Some(slot) = entry.cooldown_until.get_mut(index) {
385 *slot = None;
386 }
387 if let Some(slot) = entry.penalty_streak.get_mut(index) {
388 *slot = 0;
389 }
390 entry.last_good_index = Some(index);
392 } else {
393 entry.failure_counts[index] = entry.failure_counts[index].saturating_add(1);
394 if entry.failure_counts[index] >= FAILURE_THRESHOLD
395 && let Some(slot) = entry.cooldown_until.get_mut(index)
396 {
397 let base_secs = if failure_threshold_cooldown_secs == 0 {
398 COOLDOWN_SECS
399 } else {
400 failure_threshold_cooldown_secs
401 };
402 let streak = entry.penalty_streak.get(index).copied().unwrap_or(0);
403 let effective_secs = backoff.effective_cooldown_secs(base_secs, streak);
404 let now = std::time::Instant::now();
405 let new_until = now + std::time::Duration::from_secs(effective_secs);
406 let should_update = match *slot {
407 Some(existing) => new_until > existing,
408 None => true,
409 };
410 if should_update {
411 *slot = Some(new_until);
412 }
413 if let Some(slot) = entry.penalty_streak.get_mut(index) {
414 *slot = streak.saturating_add(1);
415 }
416 info!(
417 "lb: upstream '{}' index {} reached failure threshold {} (count = {}), entering cooldown for {}s",
418 self.service.name,
419 index,
420 FAILURE_THRESHOLD,
421 entry.failure_counts[index],
422 effective_secs
423 );
424 if entry.last_good_index == Some(index) {
426 entry.last_good_index = None;
427 }
428 }
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::config::{ServiceConfig, UpstreamAuth, UpstreamConfig};
437
438 fn make_service(name: &str, urls: &[&str]) -> ServiceConfig {
439 ServiceConfig {
440 name: name.to_string(),
441 alias: None,
442 enabled: true,
443 level: 1,
444 upstreams: urls
445 .iter()
446 .map(|u| UpstreamConfig {
447 base_url: u.to_string(),
448 auth: UpstreamAuth {
449 auth_token: Some("sk-test".to_string()),
450 auth_token_env: None,
451 api_key: None,
452 api_key_env: None,
453 },
454 tags: HashMap::new(),
455 supported_models: HashMap::new(),
456 model_mapping: HashMap::new(),
457 })
458 .collect(),
459 }
460 }
461
462 fn make_provider_endpoint_service(
463 name: &str,
464 upstreams: &[(&str, &str, &str)],
465 ) -> ServiceConfig {
466 ServiceConfig {
467 name: name.to_string(),
468 alias: None,
469 enabled: true,
470 level: 1,
471 upstreams: upstreams
472 .iter()
473 .map(|(base_url, provider_id, endpoint_id)| UpstreamConfig {
474 base_url: (*base_url).to_string(),
475 auth: UpstreamAuth {
476 auth_token: Some("sk-test".to_string()),
477 auth_token_env: None,
478 api_key: None,
479 api_key_env: None,
480 },
481 tags: HashMap::from([
482 ("provider_id".to_string(), (*provider_id).to_string()),
483 ("endpoint_id".to_string(), (*endpoint_id).to_string()),
484 ]),
485 supported_models: HashMap::new(),
486 model_mapping: HashMap::new(),
487 })
488 .collect(),
489 }
490 }
491
492 #[test]
493 fn lb_prefers_non_exhausted_upstream_when_available() {
494 let service = make_service(
495 "codex-main",
496 &["https://primary.example", "https://backup.example"],
497 );
498 let states = Arc::new(Mutex::new(HashMap::new()));
499 let lb = LoadBalancer::new(Arc::new(service), states.clone());
500
501 let first = lb.select_upstream().expect("should select an upstream");
503 assert_eq!(first.index, 0);
504
505 {
507 let mut guard = states.lock().unwrap();
508 let entry = guard
509 .entry("codex-main".to_string())
510 .or_insert_with(LbState::default);
511 entry.ensure_layout(lb.service.name.as_str(), &lb.service.upstreams);
512 entry.usage_exhausted[0] = true;
513 entry.usage_exhausted[1] = false;
514 }
515
516 let second = lb.select_upstream().expect("should select backup upstream");
518 assert_eq!(second.index, 1);
519 }
520
521 #[test]
522 fn lb_does_not_select_when_all_usage_exhausted() {
523 let service = make_service(
524 "codex-main",
525 &["https://primary.example", "https://backup.example"],
526 );
527 let states = Arc::new(Mutex::new(HashMap::new()));
528 let lb = LoadBalancer::new(Arc::new(service), states.clone());
529
530 let _ = lb.select_upstream();
531
532 {
533 let mut guard = states.lock().unwrap();
534 let entry = guard
535 .entry("codex-main".to_string())
536 .or_insert_with(LbState::default);
537 entry.ensure_layout(lb.service.name.as_str(), &lb.service.upstreams);
538 entry.usage_exhausted[0] = true;
539 entry.usage_exhausted[1] = true;
540 }
541
542 assert!(
543 lb.select_upstream().is_none(),
544 "trusted usage exhaustion must not keep hammering a depleted upstream"
545 );
546 }
547
548 #[test]
549 fn lb_strict_mode_does_not_select_when_all_usage_exhausted() {
550 let service = make_service(
551 "codex-main",
552 &["https://primary.example", "https://backup.example"],
553 );
554 let states = Arc::new(Mutex::new(HashMap::new()));
555 let lb = LoadBalancer::new(Arc::new(service), states.clone());
556
557 {
558 let mut guard = states.lock().unwrap();
559 let entry = guard
560 .entry("codex-main".to_string())
561 .or_insert_with(LbState::default);
562 entry.ensure_layout(lb.service.name.as_str(), &lb.service.upstreams);
563 entry.usage_exhausted[0] = true;
564 entry.usage_exhausted[1] = true;
565 }
566
567 assert!(
568 lb.select_upstream_avoiding_strict(&HashSet::new())
569 .is_none(),
570 "strict mode must not ignore trusted usage exhaustion"
571 );
572 }
573
574 #[test]
575 fn lb_resets_state_when_upstream_layout_changes() {
576 let states = Arc::new(Mutex::new(HashMap::new()));
577 let initial = LoadBalancer::new(
578 Arc::new(make_service(
579 "codex-main",
580 &["https://primary.example", "https://backup.example"],
581 )),
582 states.clone(),
583 );
584 initial.record_result_with_backoff(
585 0,
586 false,
587 COOLDOWN_SECS,
588 CooldownBackoff {
589 factor: 1,
590 max_secs: 0,
591 },
592 );
593
594 {
595 let guard = states.lock().unwrap();
596 let entry = guard.get("codex-main").expect("state exists");
597 assert_eq!(entry.failure_counts, vec![1, 0]);
598 }
599
600 let reordered = LoadBalancer::new(
601 Arc::new(make_service(
602 "codex-main",
603 &["https://backup.example", "https://primary.example"],
604 )),
605 states.clone(),
606 );
607 let selected = reordered
608 .select_upstream()
609 .expect("should select an upstream");
610 assert_eq!(selected.index, 0);
611
612 let guard = states.lock().unwrap();
613 let entry = guard.get("codex-main").expect("state exists");
614 assert_eq!(entry.failure_counts, vec![0, 0]);
615 assert_eq!(entry.last_good_index, None);
616 }
617
618 #[test]
619 fn lb_migrates_state_when_provider_endpoint_order_changes() {
620 let states = Arc::new(Mutex::new(HashMap::new()));
621 let initial = LoadBalancer::new(
622 Arc::new(make_provider_endpoint_service(
623 "routing",
624 &[
625 ("https://primary.example", "primary", "default"),
626 ("https://backup.example", "backup", "default"),
627 ],
628 )),
629 states.clone(),
630 );
631
632 {
633 let mut guard = states.lock().unwrap();
634 let entry = guard
635 .entry("routing".to_string())
636 .or_insert_with(LbState::default);
637 entry.ensure_layout(initial.service.name.as_str(), &initial.service.upstreams);
638 entry.failure_counts[0] = 2;
639 entry.cooldown_until[0] =
640 Some(std::time::Instant::now() + std::time::Duration::from_secs(30));
641 entry.penalty_streak[0] = 3;
642 entry.usage_exhausted[1] = true;
643 entry.last_good_index = Some(1);
644 }
645
646 let reordered = LoadBalancer::new(
647 Arc::new(make_provider_endpoint_service(
648 "routing",
649 &[
650 ("https://backup.example", "backup", "default"),
651 ("https://primary.example", "primary", "default"),
652 ],
653 )),
654 states.clone(),
655 );
656 let selected = reordered
657 .select_upstream()
658 .expect("should select a migrated non-exhausted upstream");
659 assert_eq!(selected.index, 1);
660
661 let guard = states.lock().unwrap();
662 let entry = guard.get("routing").expect("state exists");
663 assert_eq!(entry.failure_counts, vec![0, 2]);
664 assert_eq!(entry.usage_exhausted, vec![true, false]);
665 assert_eq!(entry.penalty_streak, vec![0, 3]);
666 assert!(entry.cooldown_until[0].is_none());
667 assert!(entry.cooldown_until[1].is_some());
668 assert_eq!(entry.last_good_index, Some(0));
669 }
670
671 #[test]
672 fn lb_migrates_legacy_base_url_signature_when_endpoint_identity_is_unambiguous() {
673 let states = Arc::new(Mutex::new(HashMap::new()));
674 let primary_url = "https://primary.example";
675 let backup_url = "https://backup.example";
676
677 {
678 let mut guard = states.lock().unwrap();
679 guard.insert(
680 "routing".to_string(),
681 LbState {
682 failure_counts: vec![FAILURE_THRESHOLD, 0],
683 cooldown_until: vec![None, None],
684 usage_exhausted: vec![false, true],
685 last_good_index: Some(1),
686 penalty_streak: vec![2, 0],
687 upstream_signature: vec![primary_url.to_string(), backup_url.to_string()],
688 },
689 );
690 }
691
692 let reordered = LoadBalancer::new(
693 Arc::new(make_provider_endpoint_service(
694 "routing",
695 &[
696 (backup_url, "backup", "default"),
697 (primary_url, "primary", "default"),
698 ],
699 )),
700 states.clone(),
701 );
702 {
703 let mut guard = states.lock().unwrap();
704 let entry = guard.get_mut("routing").expect("state exists");
705 entry.ensure_layout(
706 reordered.service.name.as_str(),
707 &reordered.service.upstreams,
708 );
709 }
710
711 let guard = states.lock().unwrap();
712 let entry = guard.get("routing").expect("state exists");
713 assert_eq!(entry.failure_counts, vec![0, FAILURE_THRESHOLD]);
714 assert_eq!(entry.usage_exhausted, vec![true, false]);
715 assert_eq!(entry.penalty_streak, vec![0, 2]);
716 assert_eq!(entry.last_good_index, Some(0));
717 }
718
719 #[test]
720 fn lb_replaces_state_when_provider_endpoint_base_url_changes() {
721 let states = Arc::new(Mutex::new(HashMap::new()));
722 let initial = LoadBalancer::new(
723 Arc::new(make_provider_endpoint_service(
724 "routing",
725 &[("https://old.example", "input", "default")],
726 )),
727 states.clone(),
728 );
729
730 {
731 let mut guard = states.lock().unwrap();
732 let entry = guard
733 .entry("routing".to_string())
734 .or_insert_with(LbState::default);
735 entry.ensure_layout(initial.service.name.as_str(), &initial.service.upstreams);
736 entry.failure_counts[0] = FAILURE_THRESHOLD;
737 entry.cooldown_until[0] =
738 Some(std::time::Instant::now() + std::time::Duration::from_secs(30));
739 entry.usage_exhausted[0] = true;
740 entry.penalty_streak[0] = 2;
741 entry.last_good_index = Some(0);
742 }
743
744 let updated = LoadBalancer::new(
745 Arc::new(make_provider_endpoint_service(
746 "routing",
747 &[("https://new.example", "input", "default")],
748 )),
749 states.clone(),
750 );
751 let selected = updated
752 .select_upstream()
753 .expect("new endpoint URL should be selectable after state replacement");
754 assert_eq!(selected.index, 0);
755
756 let guard = states.lock().unwrap();
757 let entry = guard.get("routing").expect("state exists");
758 assert_eq!(entry.failure_counts, vec![0]);
759 assert_eq!(entry.cooldown_until, vec![None]);
760 assert_eq!(entry.usage_exhausted, vec![false]);
761 assert_eq!(entry.penalty_streak, vec![0]);
762 assert_eq!(entry.last_good_index, None);
763 }
764
765 #[test]
766 fn lb_avoids_upstreams_past_failure_threshold() {
767 let service = make_service(
768 "codex-main",
769 &["https://primary.example", "https://backup.example"],
770 );
771 let states = Arc::new(Mutex::new(HashMap::new()));
772 let lb = LoadBalancer::new(Arc::new(service), states.clone());
773
774 let disabled_backoff = CooldownBackoff {
775 factor: 1,
776 max_secs: 0,
777 };
778
779 for _ in 0..FAILURE_THRESHOLD {
781 lb.record_result_with_backoff(0, false, COOLDOWN_SECS, disabled_backoff);
782 }
783
784 let selected = lb
786 .select_upstream()
787 .expect("should select backup after failures");
788 assert_eq!(selected.index, 1);
789 }
790
791 #[test]
792 fn lb_cooldown_expiry_restores_upstream_selection() {
793 let service = make_service(
794 "codex-main",
795 &["https://primary.example", "https://backup.example"],
796 );
797 let states = Arc::new(Mutex::new(HashMap::new()));
798 let lb = LoadBalancer::new(Arc::new(service), states.clone());
799
800 let disabled_backoff = CooldownBackoff {
801 factor: 1,
802 max_secs: 0,
803 };
804
805 for _ in 0..FAILURE_THRESHOLD {
806 lb.record_result_with_backoff(0, false, 2, disabled_backoff);
807 }
808
809 {
810 let guard = states.lock().unwrap();
811 let entry = guard.get("codex-main").expect("lb state exists");
812 assert_eq!(entry.failure_counts[0], FAILURE_THRESHOLD);
813 assert!(entry.cooldown_until[0].is_some());
814 }
815
816 let during_cooldown = lb
817 .select_upstream()
818 .expect("should select backup while primary cools down");
819 assert_eq!(during_cooldown.index, 1);
820
821 {
822 let mut guard = states.lock().unwrap();
823 let entry = guard.get_mut("codex-main").expect("lb state exists");
824 entry.cooldown_until[0] =
825 Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
826 }
827
828 let recovered = lb
829 .select_upstream()
830 .expect("should select primary after cooldown expiry");
831 assert_eq!(recovered.index, 0);
832
833 {
834 let guard = states.lock().unwrap();
835 let entry = guard.get("codex-main").expect("lb state exists");
836 assert_eq!(entry.failure_counts[0], 0);
837 assert!(entry.cooldown_until[0].is_none());
838 }
839 }
840
841 #[test]
842 fn lb_threshold_cooldown_backoff_grows_and_success_resets_streak() {
843 let service = make_service(
844 "codex-main",
845 &["https://primary.example", "https://backup.example"],
846 );
847 let states = Arc::new(Mutex::new(HashMap::new()));
848 let lb = LoadBalancer::new(Arc::new(service), states.clone());
849
850 let backoff = CooldownBackoff {
851 factor: 2,
852 max_secs: 10,
853 };
854
855 for _ in 0..FAILURE_THRESHOLD {
856 lb.record_result_with_backoff(0, false, 2, backoff);
857 }
858
859 let first_remaining_secs = {
860 let guard = states.lock().unwrap();
861 let entry = guard.get("codex-main").expect("lb state exists");
862 assert_eq!(entry.penalty_streak[0], 1);
863 entry.cooldown_until[0]
864 .map(|until| {
865 until
866 .saturating_duration_since(std::time::Instant::now())
867 .as_secs()
868 })
869 .expect("first cooldown exists")
870 };
871 assert!(first_remaining_secs <= 2);
872
873 {
874 let mut guard = states.lock().unwrap();
875 let entry = guard.get_mut("codex-main").expect("lb state exists");
876 entry.cooldown_until[0] =
877 Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
878 }
879 let _ = lb.select_upstream();
880
881 for _ in 0..FAILURE_THRESHOLD {
882 lb.record_result_with_backoff(0, false, 2, backoff);
883 }
884
885 let second_remaining_secs = {
886 let guard = states.lock().unwrap();
887 let entry = guard.get("codex-main").expect("lb state exists");
888 assert_eq!(entry.penalty_streak[0], 2);
889 entry.cooldown_until[0]
890 .map(|until| {
891 until
892 .saturating_duration_since(std::time::Instant::now())
893 .as_secs()
894 })
895 .expect("second cooldown exists")
896 };
897 assert!(second_remaining_secs <= 4);
898 assert!(second_remaining_secs >= first_remaining_secs);
899
900 lb.record_result_with_backoff(0, true, 2, backoff);
901
902 {
903 let guard = states.lock().unwrap();
904 let entry = guard.get("codex-main").expect("lb state exists");
905 assert_eq!(entry.failure_counts[0], 0);
906 assert!(entry.cooldown_until[0].is_none());
907 assert_eq!(entry.penalty_streak[0], 0);
908 assert_eq!(entry.last_good_index, Some(0));
909 }
910 }
911}