clawft_kernel/
refusal_budget.rs1use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14pub const DEFAULT_PER_WINDOW: u32 = 16;
16pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
18
19#[derive(Debug)]
20struct State {
21 start: Instant,
22 used: u32,
23 suppressed: u64,
24}
25
26#[derive(Debug)]
28pub struct RefusalBudget {
29 per_window: u32,
30 window: Duration,
31 state: Mutex<State>,
32}
33
34impl Default for RefusalBudget {
35 fn default() -> Self {
36 Self::new(DEFAULT_PER_WINDOW, DEFAULT_WINDOW)
37 }
38}
39
40impl RefusalBudget {
41 pub fn new(per_window: u32, window: Duration) -> Self {
43 Self {
44 per_window,
45 window,
46 state: Mutex::new(State {
47 start: Instant::now(),
48 used: 0,
49 suppressed: 0,
50 }),
51 }
52 }
53
54 pub fn take(&self) -> Option<u64> {
57 let mut s = self.state.lock().ok()?;
58 if s.start.elapsed() >= self.window {
59 s.start = Instant::now();
60 s.used = 0;
61 }
62 if s.used >= self.per_window {
63 if s.suppressed == 0 {
64 tracing::warn!("unauthenticated workload.ctl refusals over budget; not chaining");
65 }
66 s.suppressed = s.suppressed.saturating_add(1);
67 return None;
68 }
69 s.used += 1;
70 Some(std::mem::take(&mut s.suppressed))
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn bounded_per_window_and_the_suppressed_count_is_carried_forward() {
80 let b = RefusalBudget::new(2, Duration::from_millis(60));
81 assert_eq!(b.take(), Some(0));
82 assert_eq!(b.take(), Some(0));
83 for _ in 0..5 {
84 assert_eq!(b.take(), None);
85 }
86 std::thread::sleep(Duration::from_millis(80));
87 assert_eq!(b.take(), Some(5), "the next chained refusal reports 5");
88 assert_eq!(b.take(), Some(0));
89 assert_eq!(b.take(), None);
90 }
91}