Skip to main content

opendev_http/
rotation.rs

1//! API key rotation and failover across providers.
2//!
3//! Supports multiple API keys per provider with automatic rotation on rate
4//! limits (429) and auth failures (401/402). Keys cool down after failures
5//! before being retried.
6
7use std::collections::HashMap;
8use std::time::Instant;
9use tracing::{info, warn};
10
11/// Cooldown durations in seconds by HTTP status code.
12fn cooldown_seconds(status: u16) -> f64 {
13    match status {
14        429 => 30.0,  // Rate limit
15        401 => 300.0, // Unauthorized
16        402 => 300.0, // Payment required
17        403 => 600.0, // Forbidden
18        500 => 60.0,  // Server error
19        502 => 30.0,  // Bad gateway
20        503 => 60.0,  // Service unavailable
21        _ => 60.0,    // Default
22    }
23}
24
25/// A single API key with usage tracking.
26#[derive(Debug)]
27struct AuthProfile {
28    key: String,
29    #[allow(dead_code)]
30    provider: String,
31    failed_at: Option<Instant>,
32    failure_status: u16,
33    cooldown_until: Option<Instant>,
34    request_count: u64,
35    failure_count: u64,
36}
37
38impl AuthProfile {
39    fn new(key: String, provider: String) -> Self {
40        Self {
41            key,
42            provider,
43            failed_at: None,
44            failure_status: 0,
45            cooldown_until: None,
46            request_count: 0,
47            failure_count: 0,
48        }
49    }
50
51    fn is_available(&self) -> bool {
52        match self.cooldown_until {
53            None => true,
54            Some(until) => Instant::now() >= until,
55        }
56    }
57
58    fn mark_success(&mut self) {
59        self.request_count += 1;
60        self.failed_at = None;
61        self.failure_status = 0;
62        self.cooldown_until = None;
63    }
64
65    fn mark_failure(&mut self, status_code: u16) {
66        self.failure_count += 1;
67        let now = Instant::now();
68        self.failed_at = Some(now);
69        self.failure_status = status_code;
70        let cooldown = cooldown_seconds(status_code);
71        self.cooldown_until = Some(now + std::time::Duration::from_secs_f64(cooldown));
72        warn!(
73            key_prefix = &self.key[..self.key.len().min(8)],
74            status_code,
75            cooldown_secs = cooldown,
76            "Auth profile failed, cooling down"
77        );
78    }
79
80    /// Remaining cooldown in seconds (0.0 if available).
81    fn cooldown_remaining(&self) -> f64 {
82        match self.cooldown_until {
83            None => 0.0,
84            Some(until) => {
85                let now = Instant::now();
86                if now >= until {
87                    0.0
88                } else {
89                    (until - now).as_secs_f64()
90                }
91            }
92        }
93    }
94}
95
96/// Manages multiple API keys per provider with rotation and failover.
97///
98/// Keys rotate automatically when the active key fails. Failed keys enter
99/// a cooldown period before being retried.
100pub struct AuthProfileManager {
101    provider: String,
102    profiles: Vec<AuthProfile>,
103    current_index: usize,
104}
105
106impl AuthProfileManager {
107    /// Create a new manager with the given keys.
108    pub fn new(provider: impl Into<String>, keys: Vec<String>) -> Self {
109        let provider = provider.into();
110        let profiles: Vec<_> = keys
111            .into_iter()
112            .filter(|k| !k.is_empty())
113            .map(|k| AuthProfile::new(k, provider.clone()))
114            .collect();
115
116        if profiles.is_empty() {
117            warn!("No API keys configured for provider '{}'", provider);
118        }
119
120        Self {
121            provider,
122            profiles,
123            current_index: 0,
124        }
125    }
126
127    /// Create from environment variables.
128    ///
129    /// Looks for `{PROVIDER}_API_KEY`, `{PROVIDER}_API_KEY_2`, etc.
130    pub fn from_env(provider: &str) -> Self {
131        let prefix = provider.to_uppercase().replace('-', "_");
132        let mut keys = Vec::new();
133
134        // Primary key
135        if let Ok(val) = std::env::var(format!("{prefix}_API_KEY"))
136            && !val.is_empty()
137        {
138            keys.push(val);
139        }
140
141        // Additional keys: _2, _3, ...
142        for i in 2..10 {
143            match std::env::var(format!("{prefix}_API_KEY_{i}")) {
144                Ok(val) if !val.is_empty() => keys.push(val),
145                _ => break,
146            }
147        }
148
149        Self::new(provider, keys)
150    }
151
152    /// Create from a configuration map.
153    ///
154    /// Accepts `{"api_keys": [...]}` or `{"api_key": "..."}`.
155    pub fn from_config(provider: &str, config: &HashMap<String, serde_json::Value>) -> Self {
156        let keys = if let Some(serde_json::Value::Array(arr)) = config.get("api_keys") {
157            arr.iter()
158                .filter_map(|v| v.as_str().map(String::from))
159                .collect()
160        } else if let Some(serde_json::Value::String(single)) = config.get("api_key") {
161            vec![single.clone()]
162        } else {
163            vec![]
164        };
165        Self::new(provider, keys)
166    }
167
168    /// Get the current active API key, rotating if needed.
169    ///
170    /// Returns `None` if all keys are in cooldown.
171    pub fn get_active_key(&mut self) -> Option<&str> {
172        if self.profiles.is_empty() {
173            return None;
174        }
175
176        // Try current profile first
177        if self.profiles[self.current_index].is_available() {
178            return Some(&self.profiles[self.current_index].key);
179        }
180
181        // Rotate through other profiles
182        let len = self.profiles.len();
183        for i in 1..len {
184            let idx = (self.current_index + i) % len;
185            if self.profiles[idx].is_available() {
186                self.current_index = idx;
187                let profile = &self.profiles[idx];
188                info!(
189                    key_prefix = &profile.key[..profile.key.len().min(8)],
190                    provider = %self.provider,
191                    "Rotated to next API key"
192                );
193                return Some(&self.profiles[idx].key);
194            }
195        }
196
197        // All keys in cooldown
198        let soonest = self
199            .profiles
200            .iter()
201            .map(|p| p.cooldown_remaining())
202            .fold(f64::MAX, f64::min);
203        warn!(
204            total = self.profiles.len(),
205            provider = %self.provider,
206            soonest_available_secs = soonest,
207            "All API keys are in cooldown"
208        );
209        None
210    }
211
212    /// Mark the current key as successful.
213    pub fn mark_success(&mut self) {
214        if !self.profiles.is_empty() {
215            self.profiles[self.current_index].mark_success();
216        }
217    }
218
219    /// Mark the current key as failed with a specific HTTP status code.
220    pub fn mark_failure(&mut self, status_code: u16) {
221        if !self.profiles.is_empty() {
222            self.profiles[self.current_index].mark_failure(status_code);
223        }
224    }
225
226    /// Number of configured profiles.
227    pub fn profile_count(&self) -> usize {
228        self.profiles.len()
229    }
230
231    /// Number of currently available profiles.
232    pub fn available_count(&self) -> usize {
233        self.profiles.iter().filter(|p| p.is_available()).count()
234    }
235
236    /// Get the provider name.
237    pub fn provider(&self) -> &str {
238        &self.provider
239    }
240}
241
242impl std::fmt::Debug for AuthProfileManager {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("AuthProfileManager")
245            .field("provider", &self.provider)
246            .field("profile_count", &self.profiles.len())
247            .field("current_index", &self.current_index)
248            .finish()
249    }
250}
251
252#[cfg(test)]
253#[path = "rotation_tests.rs"]
254mod tests;