Skip to main content

featherbit/traffic/
mod.rs

1//! Shared state for traffic-control plugins whose logic spans the upstream
2//! call — concurrency limits, circuit breakers, and response caching.
3//!
4//! A featherbit node occupies a single graph position, but these behaviors
5//! need to act both *before* the upstream (acquire a slot / check the breaker
6//! / look up the cache) and *after* it (release / observe the status / store
7//! the response). The idiomatic featherbit expression is a **pair of nodes**
8//! wired around `upstream`, both configured with the same `id` and sharing an
9//! entry in one of these process-wide registries — the same "two phases, one
10//! shared key" shape `proxy-rewrite` uses for request/response.
11//!
12//! All three registries are lock-light concurrent maps keyed by the plugin's
13//! configured `id`, so multiple routes can maintain independent state and a
14//! pair on one route shares exactly one entry.
15
16use std::sync::atomic::AtomicI64;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use dashmap::DashMap;
21use tokio::sync::Mutex;
22
23/// Per-key in-flight request counters for `limit-conn`.
24///
25/// The acquire node increments and the release node decrements the same
26/// counter, so concurrency is measured across the whole pipeline between the
27/// paired nodes.
28#[derive(Default)]
29pub struct ConnRegistry {
30    counters: DashMap<String, Arc<AtomicI64>>,
31}
32
33impl ConnRegistry {
34    /// Returns the shared counter for `key`, creating it on first use.
35    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/// State of one circuit breaker (shared by an `api-breaker` check/observe pair).
44#[derive(Default)]
45pub struct BreakerState {
46    /// Consecutive unhealthy responses observed while closed.
47    unhealthy_count: u32,
48    /// Consecutive healthy responses observed while half-open.
49    healthy_count: u32,
50    /// How many times the breaker has tripped in the current unhealthy spell,
51    /// used to grow the cooldown.
52    trip_round: u32,
53    /// When the breaker is open, the instant it may move to half-open.
54    open_until: Option<Instant>,
55}
56
57impl BreakerState {
58    /// Whether a request should be allowed through right now. When the open
59    /// window has elapsed the breaker becomes half-open (this call returns
60    /// `true` to let one probe through).
61    pub fn allow(&mut self) -> bool {
62        match self.open_until {
63            Some(until) if Instant::now() < until => false,
64            Some(_) => {
65                // Cooldown elapsed → half-open: let a probe through.
66                self.open_until = None;
67                true
68            }
69            None => true,
70        }
71    }
72
73    /// Records a healthy upstream response. After `healthy_threshold`
74    /// consecutive healthy responses the breaker fully closes.
75    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    /// Records an unhealthy upstream response. After `unhealthy_threshold`
85    /// consecutive unhealthy responses the breaker opens for
86    /// `min(max_breaker_sec, break_base_sec * 2^trip_round)`.
87    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/// Circuit-breaker registry for `api-breaker` node pairs.
107#[derive(Default)]
108pub struct BreakerRegistry {
109    breakers: DashMap<String, Arc<Mutex<BreakerState>>>,
110}
111
112impl BreakerRegistry {
113    /// Returns the shared breaker state for `key`, creating it on first use.
114    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/// A cached upstream response.
123#[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/// Response cache for `proxy-cache` lookup/store node pairs.
132///
133/// In-memory and per gateway instance; entries expire lazily on read.
134#[derive(Default)]
135pub struct CacheRegistry {
136    entries: DashMap<String, CacheEntry>,
137}
138
139impl CacheRegistry {
140    /// Returns a fresh cached entry for `key`, or `None` on miss/expiry.
141    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    /// Stores `entry` under `key` with a `ttl` freshness lifetime.
153    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/// The three registries, held in `PluginResources`.
174#[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        // Two unhealthy with threshold 2 → open.
201        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        // A healthy response while closed resets the unhealthy streak.
207        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); // trip 0 → 2s
218        let first = s.open_until.unwrap();
219        s.open_until = None; // simulate half-open
220        s.record_unhealthy(1, 2, 100); // trip 1 → 4s
221        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}