featherbit/traffic/
mod.rs1use std::sync::atomic::AtomicI64;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use dashmap::DashMap;
21use tokio::sync::Mutex;
22
23#[derive(Default)]
29pub struct ConnRegistry {
30 counters: DashMap<String, Arc<AtomicI64>>,
31}
32
33impl ConnRegistry {
34 pub fn counter(&self, key: &str) -> Arc<AtomicI64> {
36 self.counters
37 .entry(key.to_string())
38 .or_insert_with(|| Arc::new(AtomicI64::new(0)))
39 .clone()
40 }
41}
42
43#[derive(Default)]
45pub struct BreakerState {
46 unhealthy_count: u32,
48 healthy_count: u32,
50 trip_round: u32,
53 open_until: Option<Instant>,
55}
56
57impl BreakerState {
58 pub fn allow(&mut self) -> bool {
62 match self.open_until {
63 Some(until) if Instant::now() < until => false,
64 Some(_) => {
65 self.open_until = None;
67 true
68 }
69 None => true,
70 }
71 }
72
73 pub fn record_healthy(&mut self, healthy_threshold: u32) {
76 self.unhealthy_count = 0;
77 self.healthy_count = self.healthy_count.saturating_add(1);
78 if self.healthy_count >= healthy_threshold {
79 self.healthy_count = 0;
80 self.trip_round = 0;
81 }
82 }
83
84 pub fn record_unhealthy(
88 &mut self,
89 unhealthy_threshold: u32,
90 break_base_sec: u64,
91 max_breaker_sec: u64,
92 ) {
93 self.healthy_count = 0;
94 self.unhealthy_count = self.unhealthy_count.saturating_add(1);
95 if self.unhealthy_count >= unhealthy_threshold {
96 self.unhealthy_count = 0;
97 let backoff = break_base_sec
98 .saturating_mul(1u64 << self.trip_round.min(16))
99 .min(max_breaker_sec.max(break_base_sec));
100 self.open_until = Some(Instant::now() + Duration::from_secs(backoff));
101 self.trip_round = self.trip_round.saturating_add(1);
102 }
103 }
104}
105
106#[derive(Default)]
108pub struct BreakerRegistry {
109 breakers: DashMap<String, Arc<Mutex<BreakerState>>>,
110}
111
112impl BreakerRegistry {
113 pub fn breaker(&self, key: &str) -> Arc<Mutex<BreakerState>> {
115 self.breakers
116 .entry(key.to_string())
117 .or_insert_with(|| Arc::new(Mutex::new(BreakerState::default())))
118 .clone()
119 }
120}
121
122#[derive(Clone)]
124pub struct CacheEntry {
125 pub status: u16,
126 pub headers: std::collections::HashMap<String, Vec<String>>,
127 pub body: bytes::Bytes,
128 expires_at: Instant,
129}
130
131#[derive(Default)]
135pub struct CacheRegistry {
136 entries: DashMap<String, CacheEntry>,
137}
138
139impl CacheRegistry {
140 pub fn get(&self, key: &str) -> Option<CacheEntry> {
142 let entry = self.entries.get(key)?;
143 if Instant::now() < entry.expires_at {
144 Some(entry.clone())
145 } else {
146 drop(entry);
147 self.entries.remove(key);
148 None
149 }
150 }
151
152 pub fn put(
154 &self,
155 key: String,
156 status: u16,
157 headers: std::collections::HashMap<String, Vec<String>>,
158 body: bytes::Bytes,
159 ttl: Duration,
160 ) {
161 self.entries.insert(
162 key,
163 CacheEntry {
164 status,
165 headers,
166 body,
167 expires_at: Instant::now() + ttl,
168 },
169 );
170 }
171}
172
173#[derive(Default)]
175pub struct TrafficRegistries {
176 pub conn: ConnRegistry,
177 pub breakers: BreakerRegistry,
178 pub cache: CacheRegistry,
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use std::sync::atomic::Ordering;
185
186 #[test]
187 fn test_conn_counter_shared() {
188 let reg = ConnRegistry::default();
189 let a = reg.counter("k");
190 let b = reg.counter("k");
191 a.fetch_add(1, Ordering::Relaxed);
192 assert_eq!(b.load(Ordering::Relaxed), 1);
193 assert_eq!(reg.counter("other").load(Ordering::Relaxed), 0);
194 }
195
196 #[test]
197 fn test_breaker_trips_and_recovers() {
198 let mut s = BreakerState::default();
199 assert!(s.allow());
200 s.record_unhealthy(2, 3600, 3600);
202 assert!(s.allow());
203 s.record_unhealthy(2, 3600, 3600);
204 assert!(!s.allow(), "breaker should be open after threshold");
205
206 let mut s = BreakerState::default();
208 s.record_unhealthy(3, 10, 100);
209 s.record_healthy(1);
210 s.record_unhealthy(3, 10, 100);
211 assert!(s.allow(), "healthy response should have reset the streak");
212 }
213
214 #[test]
215 fn test_breaker_backoff_grows() {
216 let mut s = BreakerState::default();
217 s.record_unhealthy(1, 2, 100); let first = s.open_until.unwrap();
219 s.open_until = None; s.record_unhealthy(1, 2, 100); let second = s.open_until.unwrap();
222 assert!(second > first, "cooldown should grow across trips");
223 }
224
225 #[test]
226 fn test_cache_get_put_and_expiry() {
227 let reg = CacheRegistry::default();
228 let mut headers = std::collections::HashMap::new();
229 headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
230 reg.put(
231 "k".to_string(),
232 200,
233 headers,
234 bytes::Bytes::from_static(b"hi"),
235 Duration::from_millis(30),
236 );
237 let hit = reg.get("k").unwrap();
238 assert_eq!(hit.status, 200);
239 assert_eq!(hit.body, bytes::Bytes::from_static(b"hi"));
240
241 std::thread::sleep(Duration::from_millis(45));
242 assert!(reg.get("k").is_none(), "entry should have expired");
243 }
244}