Skip to main content

camel_processor/
throttler.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::time::{Duration, Instant};
4
5use camel_api::CAMEL_STOP;
6use camel_api::{CamelError, ConfigValidationError, ThrottleStrategy, ThrottlerConfig};
7
8pub struct RateLimiter {
9    tokens: f64,
10    max_tokens: f64,
11    refill_rate: f64,
12    last_refill: Instant,
13}
14
15impl RateLimiter {
16    fn new(max_requests: usize, period: Duration) -> Self {
17        let refill_rate = max_requests as f64 / period.as_secs_f64();
18        Self {
19            tokens: max_requests as f64,
20            max_tokens: max_requests as f64,
21            refill_rate,
22            last_refill: Instant::now(),
23        }
24    }
25
26    fn try_acquire(&mut self) -> bool {
27        let now = Instant::now();
28        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
29        if elapsed > 0.0 {
30            self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
31            self.last_refill = now;
32        }
33        if self.tokens >= 1.0 {
34            self.tokens -= 1.0;
35            true
36        } else {
37            false
38        }
39    }
40
41    fn time_until_next_token(&self) -> Duration {
42        if self.tokens >= 1.0 {
43            Duration::ZERO
44        } else {
45            let tokens_needed = 1.0 - self.tokens;
46            Duration::from_secs_f64(tokens_needed / self.refill_rate)
47        }
48    }
49}
50
51/// Outcome-aware throttle segment (ADR-0025).
52///
53/// Wraps a `ThrottlerConfig` + shared `RateLimiter` + child sub-pipeline body.
54/// The segment propagates `PipelineOutcome::Stopped` / `Failed` from the body.
55pub struct ThrottleSegment {
56    pub config: ThrottlerConfig,
57    pub limiter: std::sync::Arc<std::sync::Mutex<RateLimiter>>,
58    pub body: camel_api::OutcomeSegment,
59}
60
61impl ThrottleSegment {
62    /// Construct a throttle segment. **Panics** if invariants are violated.
63    /// Prefer `try_new`.
64    pub fn new(config: ThrottlerConfig, body: camel_api::OutcomeSegment) -> Self {
65        // Intentional panic-on-invariant contract for pre-validated
66        // configs; try_new is the fallible path (D-M8).
67        Self::try_new(config, body).expect("ThrottleSegment::new invariants violated") // allow-unwrap
68    }
69
70    /// Fallible constructor. Validates `period` and `max_requests`.
71    pub fn try_new(
72        config: ThrottlerConfig,
73        body: camel_api::OutcomeSegment,
74    ) -> Result<Self, CamelError> {
75        if config.period == Duration::ZERO {
76            return Err(CamelError::Config(
77                "ThrottlerConfig.period must be > 0".to_string(),
78            ));
79        }
80        if config.max_requests == 0 {
81            return Err(CamelError::from(
82                ConfigValidationError::ThrottlerMaxRequestsZero,
83            ));
84        }
85        Ok(Self {
86            limiter: std::sync::Arc::new(std::sync::Mutex::new(RateLimiter::new(
87                config.max_requests,
88                config.period,
89            ))),
90            config,
91            body,
92        })
93    }
94}
95
96impl Clone for ThrottleSegment {
97    fn clone(&self) -> Self {
98        Self {
99            config: self.config.clone(),
100            limiter: std::sync::Arc::clone(&self.limiter),
101            body: self.body.clone(),
102        }
103    }
104}
105
106impl camel_api::OutcomePipeline for ThrottleSegment {
107    fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
108        Box::new(self.clone())
109    }
110
111    fn run<'a>(
112        &'a mut self,
113        exchange: camel_api::Exchange,
114    ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
115        Box::pin(async move {
116            let acquired = {
117                let mut limiter = self.limiter.lock().unwrap(); // allow-unwrap
118                limiter.try_acquire()
119            };
120            if acquired {
121                return self.body.run(exchange).await;
122            }
123            match self.config.strategy {
124                ThrottleStrategy::Reject => {
125                    camel_api::PipelineOutcome::Failed(camel_api::CamelError::ProcessorError(
126                        "Throttled: rate limit exceeded".to_string(),
127                    ))
128                }
129                ThrottleStrategy::Drop => {
130                    let mut ex = exchange;
131                    ex.set_property(CAMEL_STOP, camel_api::Value::Bool(true));
132                    camel_api::PipelineOutcome::Stopped(ex)
133                }
134                // Delay and any future variant wait for an available token.
135                _ => {
136                    loop {
137                        let wait_time = {
138                            let limiter = self.limiter.lock().unwrap(); // allow-unwrap
139                            limiter.time_until_next_token()
140                        };
141                        if wait_time > Duration::ZERO {
142                            tokio::time::sleep(wait_time).await;
143                        }
144                        let acquired = {
145                            let mut limiter = self.limiter.lock().unwrap(); // allow-unwrap
146                            limiter.try_acquire()
147                        };
148                        if acquired {
149                            break;
150                        }
151                        tokio::task::yield_now().await;
152                    }
153                    self.body.run(exchange).await
154                }
155            }
156        })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::test_processor_segment::processor_segment;
164    use camel_api::{
165        Body, BoxProcessor, BoxProcessorExt, Exchange, Message, OutcomePipeline, PipelineOutcome,
166    };
167
168    fn passthrough() -> BoxProcessor {
169        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
170    }
171
172    fn segment(config: ThrottlerConfig) -> ThrottleSegment {
173        ThrottleSegment::new(config, processor_segment(passthrough()))
174    }
175
176    /// D-M8: zero `period` is rejected at construction with `Err`, not a
177    /// panic. The audit reproduced the original panic; the fix returns
178    /// `Err(CamelError::Config)` from `try_new` so the caller can refuse
179    /// to start the route.
180    #[test]
181    fn throttle_segment_zero_period_returns_err() {
182        let config = ThrottlerConfig::new(5, Duration::ZERO);
183        let err = ThrottleSegment::try_new(config, processor_segment(passthrough()))
184            .err()
185            .expect("zero period must be Err from try_new, not Ok");
186        let msg = err.to_string();
187        assert!(
188            msg.contains("period") && msg.contains("> 0"),
189            "error must mention period > 0: {msg}"
190        );
191    }
192
193    #[tokio::test]
194    async fn throttle_segment_allows_under_limit() {
195        let config = ThrottlerConfig::new(5, Duration::from_secs(1));
196        let mut seg = segment(config);
197
198        for _ in 0..5 {
199            let ex = Exchange::new(Message::new("test"));
200            let outcome = seg.run(ex).await;
201            assert!(matches!(outcome, PipelineOutcome::Completed(_)));
202        }
203    }
204
205    #[tokio::test]
206    async fn throttle_segment_delay_strategy_queues_message() {
207        let config = ThrottlerConfig::new(1, Duration::from_millis(100));
208        let mut seg = segment(config);
209
210        let first = seg.run(Exchange::new(Message::new("first"))).await;
211        assert!(matches!(first, PipelineOutcome::Completed(_)));
212
213        let start = Instant::now();
214        let second = seg.run(Exchange::new(Message::new("second"))).await;
215        assert!(matches!(second, PipelineOutcome::Completed(_)));
216        assert!(start.elapsed() >= Duration::from_millis(50));
217    }
218
219    // ── D-M8 Batch 1: zero max_requests / zero period is Err, not panic ──
220
221    /// D-M8 (reproduced): construction with `max_requests=0` used to panic
222    /// via `time_until_next_token → 1.0/0.0 = inf →
223    /// Duration::from_secs_f64(inf) panic`. The fix returns `Err`
224    /// (CamelError::ConfigValidation(ThrottlerMaxRequestsZero)) instead.
225    /// Caller can match the error and refuse to start the route.
226    #[test]
227    fn throttle_segment_zero_max_requests_returns_err() {
228        let config = ThrottlerConfig::new(0, Duration::from_secs(1));
229        let err = ThrottleSegment::try_new(config, processor_segment(passthrough()))
230            .err()
231            .expect("zero max_requests must be Err, not Ok");
232        assert!(
233            matches!(
234                err,
235                CamelError::ConfigValidation(
236                    camel_api::ConfigValidationError::ThrottlerMaxRequestsZero,
237                )
238            ),
239            "expected ConfigValidation(ThrottlerMaxRequestsZero), got: {err}"
240        );
241    }
242
243    // ── ThrottleSegment tests (ADR-0025 OutcomePipeline parity) ────────────
244
245    #[tokio::test]
246    async fn throttle_segment_reject_strategy_returns_failed() {
247        #[derive(Clone)]
248        struct NoopSeg;
249        impl OutcomePipeline for NoopSeg {
250            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
251                Box::new(NoopSeg)
252            }
253            fn run<'a>(
254                &'a mut self,
255                ex: Exchange,
256            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
257                Box::pin(async move { PipelineOutcome::Completed(ex) })
258            }
259        }
260
261        // Single-token throttler. First call exhausts the token; second
262        // call triggers the Reject strategy. D-M8 rejects max_requests=0,
263        // so we use 1 and fire one request to consume the token.
264        let config = ThrottlerConfig {
265            max_requests: 1,
266            period: Duration::from_secs(1),
267            strategy: ThrottleStrategy::Reject,
268        };
269        let body = camel_api::OutcomeSegment::new(Box::new(NoopSeg));
270        let mut seg = ThrottleSegment::new(config, body);
271        let ex = Exchange::new(Message::new("test"));
272        // First call exhausts the single token
273        let _first = seg.run(ex).await;
274        // Second call is rejected
275        let ex2 = Exchange::new(Message::new("test2"));
276        let outcome = seg.run(ex2).await;
277        let err = match outcome {
278            PipelineOutcome::Failed(err) => err,
279            other => panic!("Reject strategy must return Failed when tokens exhausted: {other:?}"),
280        };
281        assert!(
282            matches!(err, CamelError::ProcessorError(_)),
283            "throttle rejection must be ProcessorError, got: {err:?}"
284        );
285        assert!(
286            err.to_string().contains("Throttled"),
287            "throttle rejection error must mention Throttled: {err}"
288        );
289    }
290
291    #[tokio::test]
292    async fn throttle_segment_drop_strategy_returns_stopped() {
293        #[derive(Clone)]
294        struct NoopSeg;
295        impl OutcomePipeline for NoopSeg {
296            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
297                Box::new(NoopSeg)
298            }
299            fn run<'a>(
300                &'a mut self,
301                ex: Exchange,
302            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
303                Box::pin(async move { PipelineOutcome::Completed(ex) })
304            }
305        }
306
307        // Single-token throttler. First call exhausts the token; second
308        // call triggers the Drop strategy. D-M8 rejects max_requests=0.
309        let config = ThrottlerConfig {
310            max_requests: 1,
311            period: Duration::from_secs(1),
312            strategy: ThrottleStrategy::Drop,
313        };
314        let body = camel_api::OutcomeSegment::new(Box::new(NoopSeg));
315        let mut seg = ThrottleSegment::new(config, body);
316        let ex = Exchange::new(Message::new("test"));
317        // First call exhausts the single token
318        let _first = seg.run(ex).await;
319        // Second call is dropped
320        let ex2 = Exchange::new(Message::new("test2"));
321        let outcome = seg.run(ex2).await;
322        match outcome {
323            PipelineOutcome::Stopped(returned_ex) => {
324                let stopped_flag = returned_ex.property(CAMEL_STOP).and_then(|v| v.as_bool());
325                assert_eq!(
326                    stopped_flag,
327                    Some(true),
328                    "Drop strategy must set CamelStop=true property"
329                );
330            }
331            other => panic!("Drop must return Stopped, got {:?}", other),
332        }
333    }
334
335    #[tokio::test]
336    async fn throttle_segment_delay_strategy_propagates_stopped_body() {
337        #[derive(Clone)]
338        struct StoppingSeg;
339        impl OutcomePipeline for StoppingSeg {
340            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
341                Box::new(StoppingSeg)
342            }
343            fn run<'a>(
344                &'a mut self,
345                mut ex: Exchange,
346            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
347                Box::pin(async move {
348                    ex.input.body = Body::Bytes(b"stopped-mut".to_vec().into());
349                    PipelineOutcome::Stopped(ex)
350                })
351            }
352        }
353
354        let config = ThrottlerConfig {
355            max_requests: 1, // 1 token available immediately
356            period: Duration::from_secs(1),
357            strategy: ThrottleStrategy::Delay,
358        };
359        let body = camel_api::OutcomeSegment::new(Box::new(StoppingSeg));
360        let mut seg = ThrottleSegment::new(config, body);
361        let ex = Exchange::new(Message::new("test"));
362        let outcome = seg.run(ex).await;
363        match outcome {
364            PipelineOutcome::Stopped(returned_ex) => {
365                if let Body::Bytes(b) = &returned_ex.input.body {
366                    assert_eq!(
367                        b.as_ref(),
368                        b"stopped-mut",
369                        "BUG: throttle body Stop must preserve mutations"
370                    );
371                } else {
372                    panic!("expected Body::Bytes");
373                }
374            }
375            other => panic!("expected Stopped propagation, got {:?}", other),
376        }
377    }
378}