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
23pub mod cache;
24pub mod purge;
25// `CacheError` joins the re-export now that the redis backend (`redis_cache.rs`)
26// is a real non-test caller that needs to name it — but that caller only
27// exists under `redis-store`, so a headless build still has no user for it.
28#[cfg(feature = "redis-store")]
29pub use cache::CacheError;
30pub use cache::{CachedResponse, LocalResponseCache, ResponseCache};
31pub use purge::{collect_targets, purge_targets, CacheTarget, PurgeOutcome};
32
33/// Per-key in-flight request counters for `limit-conn`.
34///
35/// The acquire node increments and the release node decrements the same
36/// counter, so concurrency is measured across the whole pipeline between the
37/// paired nodes.
38#[derive(Default)]
39pub struct ConnRegistry {
40    counters: DashMap<String, Arc<AtomicI64>>,
41}
42
43impl ConnRegistry {
44    /// Returns the shared counter for `key`, creating it on first use.
45    pub fn counter(&self, key: &str) -> Arc<AtomicI64> {
46        self.counters
47            .entry(key.to_string())
48            .or_insert_with(|| Arc::new(AtomicI64::new(0)))
49            .clone()
50    }
51}
52
53/// State of one circuit breaker (shared by an `api-breaker` check/observe pair).
54#[derive(Default)]
55pub struct BreakerState {
56    /// Consecutive unhealthy responses observed while closed.
57    unhealthy_count: u32,
58    /// Consecutive healthy responses observed while half-open.
59    healthy_count: u32,
60    /// How many times the breaker has tripped in the current unhealthy spell,
61    /// used to grow the cooldown.
62    trip_round: u32,
63    /// When the breaker is open, the instant it may move to half-open.
64    open_until: Option<Instant>,
65}
66
67impl BreakerState {
68    /// Whether a request should be allowed through right now. When the open
69    /// window has elapsed the breaker becomes half-open (this call returns
70    /// `true` to let one probe through).
71    pub fn allow(&mut self) -> bool {
72        match self.open_until {
73            Some(until) if Instant::now() < until => false,
74            Some(_) => {
75                // Cooldown elapsed → half-open: let a probe through.
76                self.open_until = None;
77                true
78            }
79            None => true,
80        }
81    }
82
83    /// Records a healthy upstream response. After `healthy_threshold`
84    /// consecutive healthy responses the breaker fully closes.
85    pub fn record_healthy(&mut self, healthy_threshold: u32) {
86        self.unhealthy_count = 0;
87        self.healthy_count = self.healthy_count.saturating_add(1);
88        if self.healthy_count >= healthy_threshold {
89            self.healthy_count = 0;
90            self.trip_round = 0;
91        }
92    }
93
94    /// Records an unhealthy upstream response. After `unhealthy_threshold`
95    /// consecutive unhealthy responses the breaker opens for
96    /// `min(max_breaker_sec, break_base_sec * 2^trip_round)`.
97    pub fn record_unhealthy(
98        &mut self,
99        unhealthy_threshold: u32,
100        break_base_sec: u64,
101        max_breaker_sec: u64,
102    ) {
103        self.healthy_count = 0;
104        self.unhealthy_count = self.unhealthy_count.saturating_add(1);
105        if self.unhealthy_count >= unhealthy_threshold {
106            self.unhealthy_count = 0;
107            let backoff = break_base_sec
108                .saturating_mul(1u64 << self.trip_round.min(16))
109                .min(max_breaker_sec.max(break_base_sec));
110            self.open_until = Some(Instant::now() + Duration::from_secs(backoff));
111            self.trip_round = self.trip_round.saturating_add(1);
112        }
113    }
114}
115
116/// Circuit-breaker registry for `api-breaker` node pairs.
117#[derive(Default)]
118pub struct BreakerRegistry {
119    breakers: DashMap<String, Arc<Mutex<BreakerState>>>,
120}
121
122impl BreakerRegistry {
123    /// Returns the shared breaker state for `key`, creating it on first use.
124    pub fn breaker(&self, key: &str) -> Arc<Mutex<BreakerState>> {
125        self.breakers
126            .entry(key.to_string())
127            .or_insert_with(|| Arc::new(Mutex::new(BreakerState::default())))
128            .clone()
129    }
130}
131
132/// The three registries, held in `PluginResources`.
133#[derive(Default)]
134pub struct TrafficRegistries {
135    pub conn: ConnRegistry,
136    pub breakers: BreakerRegistry,
137    pub cache: Arc<LocalResponseCache>,
138}
139
140impl TrafficRegistries {
141    /// Builds the registries with the process metrics wired into the local
142    /// cache, so its evictions are observable from the moment traffic starts.
143    pub fn new(metrics: Option<Arc<crate::metrics::GatewayMetrics>>) -> Self {
144        Self {
145            conn: ConnRegistry::default(),
146            breakers: BreakerRegistry::default(),
147            cache: Arc::new(LocalResponseCache::new(metrics)),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::sync::atomic::Ordering;
156
157    #[test]
158    fn test_conn_counter_shared() {
159        let reg = ConnRegistry::default();
160        let a = reg.counter("k");
161        let b = reg.counter("k");
162        a.fetch_add(1, Ordering::Relaxed);
163        assert_eq!(b.load(Ordering::Relaxed), 1);
164        assert_eq!(reg.counter("other").load(Ordering::Relaxed), 0);
165    }
166
167    #[test]
168    fn test_breaker_trips_and_recovers() {
169        let mut s = BreakerState::default();
170        assert!(s.allow());
171        // Two unhealthy with threshold 2 → open.
172        s.record_unhealthy(2, 3600, 3600);
173        assert!(s.allow());
174        s.record_unhealthy(2, 3600, 3600);
175        assert!(!s.allow(), "breaker should be open after threshold");
176
177        // A healthy response while closed resets the unhealthy streak.
178        let mut s = BreakerState::default();
179        s.record_unhealthy(3, 10, 100);
180        s.record_healthy(1);
181        s.record_unhealthy(3, 10, 100);
182        assert!(s.allow(), "healthy response should have reset the streak");
183    }
184
185    #[test]
186    fn test_breaker_backoff_grows() {
187        let mut s = BreakerState::default();
188        s.record_unhealthy(1, 2, 100); // trip 0 → 2s
189        let first = s.open_until.unwrap();
190        s.open_until = None; // simulate half-open
191        s.record_unhealthy(1, 2, 100); // trip 1 → 4s
192        let second = s.open_until.unwrap();
193        assert!(second > first, "cooldown should grow across trips");
194    }
195}