Skip to main content

camel_processor/
throttler.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Mutex;
4use std::task::{Context, Poll};
5use std::time::{Duration, Instant};
6
7use tower::Service;
8
9use camel_api::CAMEL_STOP;
10use camel_api::{
11    BoxProcessor, CamelError, ConfigValidationError, Exchange, ThrottleStrategy, ThrottlerConfig,
12    Value,
13};
14
15pub struct RateLimiter {
16    tokens: f64,
17    max_tokens: f64,
18    refill_rate: f64,
19    last_refill: Instant,
20}
21
22impl RateLimiter {
23    fn new(max_requests: usize, period: Duration) -> Self {
24        let refill_rate = max_requests as f64 / period.as_secs_f64();
25        Self {
26            tokens: max_requests as f64,
27            max_tokens: max_requests as f64,
28            refill_rate,
29            last_refill: Instant::now(),
30        }
31    }
32
33    fn try_acquire(&mut self) -> bool {
34        let now = Instant::now();
35        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
36        if elapsed > 0.0 {
37            self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
38            self.last_refill = now;
39        }
40        if self.tokens >= 1.0 {
41            self.tokens -= 1.0;
42            true
43        } else {
44            false
45        }
46    }
47
48    fn time_until_next_token(&self) -> Duration {
49        if self.tokens >= 1.0 {
50            Duration::ZERO
51        } else {
52            let tokens_needed = 1.0 - self.tokens;
53            Duration::from_secs_f64(tokens_needed / self.refill_rate)
54        }
55    }
56}
57
58#[derive(Clone)]
59pub struct ThrottlerService {
60    config: ThrottlerConfig,
61    limiter: std::sync::Arc<Mutex<RateLimiter>>,
62    next: BoxProcessor,
63}
64
65impl ThrottlerService {
66    /// Construct a throttler. **Panics** if `period` is zero or
67    /// `max_requests` is zero. Prefer `try_new` for fallible construction.
68    pub fn new(config: ThrottlerConfig, next: BoxProcessor) -> Self {
69        // Intentional panic-on-invariant contract for pre-validated
70        // configs; try_new is the fallible path (D-M8).
71        Self::try_new(config, next).expect("ThrottlerService::new invariants violated") // allow-unwrap
72    }
73
74    /// Fallible constructor. Returns `Err(CamelError::Config)` if
75    /// `config.period` is `Duration::ZERO` or `config.max_requests == 0`.
76    /// D-M8 fix: zero `max_requests` used to panic at first throttled
77    /// message via `1.0/0.0 = inf` in `Duration::from_secs_f64`. Now the
78    /// construction fails closed with a config error so the route never
79    /// starts with an unsafe throttler.
80    pub fn try_new(config: ThrottlerConfig, next: BoxProcessor) -> Result<Self, CamelError> {
81        if config.period == Duration::ZERO {
82            return Err(CamelError::Config(
83                "ThrottlerConfig.period must be > 0".to_string(),
84            ));
85        }
86        if config.max_requests == 0 {
87            return Err(CamelError::from(
88                ConfigValidationError::ThrottlerMaxRequestsZero,
89            ));
90        }
91        let limiter = RateLimiter::new(config.max_requests, config.period);
92        Ok(Self {
93            config,
94            limiter: std::sync::Arc::new(Mutex::new(limiter)),
95            next,
96        })
97    }
98}
99
100impl Service<Exchange> for ThrottlerService {
101    type Response = Exchange;
102    type Error = CamelError;
103    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
104
105    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
106        self.next.poll_ready(cx)
107    }
108
109    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
110        let config = self.config.clone();
111        let limiter = self.limiter.clone();
112        let mut next = self.next.clone();
113
114        Box::pin(async move {
115            let acquired = {
116                let mut limiter = limiter.lock().unwrap(); // allow-unwrap
117                limiter.try_acquire()
118            };
119
120            if acquired {
121                next.call(exchange).await
122            } else {
123                match config.strategy {
124                    ThrottleStrategy::Delay => {
125                        loop {
126                            let wait_time = {
127                                let limiter = limiter.lock().unwrap(); // allow-unwrap
128                                limiter.time_until_next_token()
129                            };
130                            if wait_time > Duration::ZERO {
131                                tokio::time::sleep(wait_time).await;
132                            }
133                            let acquired = {
134                                let mut limiter = limiter.lock().unwrap(); // allow-unwrap
135                                limiter.try_acquire()
136                            };
137                            if acquired {
138                                break;
139                            }
140                            // Yield to avoid tight spinning when concurrent tasks
141                            // wake simultaneously and contend for the same token.
142                            tokio::task::yield_now().await;
143                        }
144                        next.call(exchange).await
145                    }
146                    ThrottleStrategy::Reject => Err(CamelError::ProcessorError(
147                        "Throttled: rate limit exceeded".to_string(),
148                    )),
149                    ThrottleStrategy::Drop => {
150                        exchange.set_property(CAMEL_STOP, Value::Bool(true));
151                        Ok(exchange)
152                    }
153                }
154            }
155        })
156    }
157}
158
159/// Outcome-aware throttle segment (ADR-0025).
160///
161/// Wraps a `ThrottlerConfig` + shared `RateLimiter` + child sub-pipeline body.
162/// Unlike `ThrottlerService` (which operates at the Tower layer),
163/// `ThrottleSegment` correctly propagates `PipelineOutcome::Stopped` / `Failed`
164/// from the body.
165pub struct ThrottleSegment {
166    pub config: ThrottlerConfig,
167    pub limiter: std::sync::Arc<std::sync::Mutex<RateLimiter>>,
168    pub body: camel_api::OutcomeSegment,
169}
170
171impl ThrottleSegment {
172    /// Construct a throttle segment. **Panics** if invariants are violated.
173    /// Prefer `try_new`.
174    pub fn new(config: ThrottlerConfig, body: camel_api::OutcomeSegment) -> Self {
175        // Intentional panic-on-invariant contract for pre-validated
176        // configs; try_new is the fallible path (D-M8).
177        Self::try_new(config, body).expect("ThrottleSegment::new invariants violated") // allow-unwrap
178    }
179
180    /// Fallible constructor. Same validation as `ThrottlerService::try_new`.
181    pub fn try_new(
182        config: ThrottlerConfig,
183        body: camel_api::OutcomeSegment,
184    ) -> Result<Self, CamelError> {
185        if config.period == Duration::ZERO {
186            return Err(CamelError::Config(
187                "ThrottlerConfig.period must be > 0".to_string(),
188            ));
189        }
190        if config.max_requests == 0 {
191            return Err(CamelError::from(
192                ConfigValidationError::ThrottlerMaxRequestsZero,
193            ));
194        }
195        Ok(Self {
196            limiter: std::sync::Arc::new(std::sync::Mutex::new(RateLimiter::new(
197                config.max_requests,
198                config.period,
199            ))),
200            config,
201            body,
202        })
203    }
204}
205
206impl Clone for ThrottleSegment {
207    fn clone(&self) -> Self {
208        Self {
209            config: self.config.clone(),
210            limiter: std::sync::Arc::clone(&self.limiter),
211            body: self.body.clone(),
212        }
213    }
214}
215
216impl camel_api::OutcomePipeline for ThrottleSegment {
217    fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
218        Box::new(self.clone())
219    }
220
221    fn run<'a>(
222        &'a mut self,
223        exchange: camel_api::Exchange,
224    ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
225        Box::pin(async move {
226            let acquired = {
227                let mut limiter = self.limiter.lock().unwrap(); // allow-unwrap
228                limiter.try_acquire()
229            };
230            if acquired {
231                return self.body.run(exchange).await;
232            }
233            match self.config.strategy {
234                ThrottleStrategy::Delay => {
235                    loop {
236                        let wait_time = {
237                            let limiter = self.limiter.lock().unwrap(); // allow-unwrap
238                            limiter.time_until_next_token()
239                        };
240                        if wait_time > Duration::ZERO {
241                            tokio::time::sleep(wait_time).await;
242                        }
243                        let acquired = {
244                            let mut limiter = self.limiter.lock().unwrap(); // allow-unwrap
245                            limiter.try_acquire()
246                        };
247                        if acquired {
248                            break;
249                        }
250                        tokio::task::yield_now().await;
251                    }
252                    self.body.run(exchange).await
253                }
254                ThrottleStrategy::Reject => {
255                    camel_api::PipelineOutcome::Failed(camel_api::CamelError::ProcessorError(
256                        "Throttled: rate limit exceeded".to_string(),
257                    ))
258                }
259                ThrottleStrategy::Drop => {
260                    let mut ex = exchange;
261                    ex.set_property(CAMEL_STOP, camel_api::Value::Bool(true));
262                    camel_api::PipelineOutcome::Stopped(ex)
263                }
264            }
265        })
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use camel_api::{BoxProcessorExt, Message};
273    use tower::ServiceExt;
274
275    fn passthrough() -> BoxProcessor {
276        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
277    }
278
279    /// D-M8: zero `period` is rejected at construction with `Err`, not a
280    /// panic. The audit reproduced the original panic; the fix returns
281    /// `Err(CamelError::Config)` from `try_new` so the caller can refuse
282    /// to start the route. This test was previously called
283    /// `test_throttler_zero_period_rejected` and asserted the panic on
284    /// `new`; after the fix it asserts the Err from `try_new` (the path
285    /// operators / route constructors should use). The `new` path still
286    /// panics via `expect(..)` — that's a separate, intentional contract
287    /// for callers that have already validated the config.
288    #[test]
289    fn test_throttler_zero_period_returns_err() {
290        let config = ThrottlerConfig::new(5, Duration::ZERO);
291        let err = ThrottlerService::try_new(config, passthrough())
292            .err()
293            .expect("zero period must be Err from try_new, not Ok");
294        let msg = err.to_string();
295        assert!(
296            msg.contains("period") && msg.contains("> 0"),
297            "error must mention period > 0: {msg}"
298        );
299    }
300
301    #[tokio::test]
302    async fn test_throttler_allows_under_limit() {
303        let config = ThrottlerConfig::new(5, Duration::from_secs(1));
304        let mut svc = ThrottlerService::new(config, passthrough());
305
306        for _ in 0..5 {
307            let ex = Exchange::new(Message::new("test"));
308            let result = svc.ready().await.unwrap().call(ex).await;
309            assert!(result.is_ok());
310        }
311    }
312
313    #[tokio::test]
314    async fn test_throttler_delay_strategy_queues_message() {
315        let config = ThrottlerConfig::new(1, Duration::from_millis(100));
316        let mut svc = ThrottlerService::new(config, passthrough());
317
318        let ex1 = Exchange::new(Message::new("first"));
319        let result1 = svc.ready().await.unwrap().call(ex1).await;
320        assert!(result1.is_ok());
321
322        let start = Instant::now();
323        let ex2 = Exchange::new(Message::new("second"));
324        let result2 = svc.ready().await.unwrap().call(ex2).await;
325        let elapsed = start.elapsed();
326        assert!(result2.is_ok());
327        assert!(elapsed >= Duration::from_millis(50));
328    }
329
330    #[tokio::test]
331    async fn test_throttler_reject_strategy_returns_error() {
332        let config =
333            ThrottlerConfig::new(1, Duration::from_secs(10)).strategy(ThrottleStrategy::Reject);
334        let mut svc = ThrottlerService::new(config, passthrough());
335
336        let ex1 = Exchange::new(Message::new("first"));
337        let _ = svc.ready().await.unwrap().call(ex1).await;
338
339        let ex2 = Exchange::new(Message::new("second"));
340        let result = svc.ready().await.unwrap().call(ex2).await;
341        assert!(result.is_err());
342        let err = result.unwrap_err().to_string();
343        assert!(err.contains("Throttled"));
344    }
345
346    #[tokio::test]
347    async fn test_throttler_drop_strategy_sets_camel_stop() {
348        let config =
349            ThrottlerConfig::new(1, Duration::from_secs(10)).strategy(ThrottleStrategy::Drop);
350        let mut svc = ThrottlerService::new(config, passthrough());
351
352        let ex1 = Exchange::new(Message::new("first"));
353        let _ = svc.ready().await.unwrap().call(ex1).await;
354
355        let ex2 = Exchange::new(Message::new("second"));
356        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
357        assert_eq!(result.property(CAMEL_STOP), Some(&Value::Bool(true)));
358    }
359
360    #[tokio::test]
361    async fn test_throttler_token_replenishment() {
362        let config = ThrottlerConfig::new(1, Duration::from_millis(50));
363        let mut svc = ThrottlerService::new(config, passthrough());
364
365        let ex1 = Exchange::new(Message::new("first"));
366        let _ = svc.ready().await.unwrap().call(ex1).await;
367
368        tokio::time::sleep(Duration::from_millis(100)).await;
369
370        let ex2 = Exchange::new(Message::new("second"));
371        let result = svc.ready().await.unwrap().call(ex2).await;
372        assert!(result.is_ok());
373    }
374
375    // ── D-M8 Batch 1: zero max_requests / zero period is Err, not panic ──
376
377    /// D-M8 (reproduced): `ThrottlerService::new` with `max_requests=0`
378    /// used to panic via `time_until_next_token → 1.0/0.0 = inf →
379    /// Duration::from_secs_f64(inf) panic`. The fix returns `Err`
380    /// (CamelError::ConfigValidation(ThrottlerMaxRequestsZero)) instead.
381    /// Caller can match the error and refuse to start the route.
382    #[test]
383    fn test_throttler_zero_max_requests_returns_err() {
384        // We need a constructor that returns Result. The current API is
385        // `new(config, next)` which asserts. Wrap it: this test asserts
386        // that calling the (post-fix) `try_new` with max_requests=0
387        // returns Err. If the constructor still asserts, the test will
388        // panic (caught by `catch_unwind`) and we will see a failure
389        // distinct from "Err returned" — but the test asserts is_err.
390        let config = ThrottlerConfig::new(0, Duration::from_secs(1));
391        // Try both names: post-fix API is `try_new`; pre-fix is `new`.
392        // The post-fix `try_new` is what we want; the pre-fix `new`
393        // panics, which is the bug.
394        let result = std::panic::catch_unwind(|| ThrottlerService::try_new(config, passthrough()));
395        match result {
396            Ok(Ok(_)) => panic!("zero max_requests must be Err, not Ok"),
397            Ok(Err(e)) => {
398                assert!(
399                    matches!(
400                        e,
401                        CamelError::ConfigValidation(
402                            camel_api::ConfigValidationError::ThrottlerMaxRequestsZero,
403                        )
404                    ),
405                    "expected ConfigValidation(ThrottlerMaxRequestsZero), got: {e}"
406                );
407            }
408            Err(_) => panic!("zero max_requests must return Err, not panic"),
409        }
410    }
411
412    // ── ThrottleSegment tests (ADR-0025 OutcomePipeline parity) ────────────
413
414    #[tokio::test]
415    async fn throttle_segment_reject_strategy_returns_failed() {
416        use camel_api::{Exchange, Message, OutcomePipeline, PipelineOutcome};
417
418        #[derive(Clone)]
419        struct NoopSeg;
420        impl OutcomePipeline for NoopSeg {
421            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
422                Box::new(NoopSeg)
423            }
424            fn run<'a>(
425                &'a mut self,
426                ex: Exchange,
427            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
428                Box::pin(async move { PipelineOutcome::Completed(ex) })
429            }
430        }
431
432        // Single-token throttler. First call exhausts the token; second
433        // call triggers the Reject strategy. D-M8 rejects max_requests=0,
434        // so we use 1 and fire one request to consume the token.
435        let config = ThrottlerConfig {
436            max_requests: 1,
437            period: Duration::from_secs(1),
438            strategy: ThrottleStrategy::Reject,
439        };
440        let body = camel_api::OutcomeSegment::new(Box::new(NoopSeg));
441        let mut seg = ThrottleSegment::new(config, body);
442        let ex = Exchange::new(Message::new("test"));
443        // First call exhausts the single token
444        let _first = seg.run(ex).await;
445        // Second call is rejected
446        let ex2 = Exchange::new(Message::new("test2"));
447        let outcome = seg.run(ex2).await;
448        assert!(
449            matches!(outcome, PipelineOutcome::Failed(_)),
450            "Reject strategy must return Failed when tokens exhausted"
451        );
452    }
453
454    #[tokio::test]
455    async fn throttle_segment_drop_strategy_returns_stopped() {
456        use camel_api::{Exchange, Message, OutcomePipeline, PipelineOutcome};
457
458        #[derive(Clone)]
459        struct NoopSeg;
460        impl OutcomePipeline for NoopSeg {
461            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
462                Box::new(NoopSeg)
463            }
464            fn run<'a>(
465                &'a mut self,
466                ex: Exchange,
467            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
468                Box::pin(async move { PipelineOutcome::Completed(ex) })
469            }
470        }
471
472        // Single-token throttler. First call exhausts the token; second
473        // call triggers the Drop strategy. D-M8 rejects max_requests=0.
474        let config = ThrottlerConfig {
475            max_requests: 1,
476            period: Duration::from_secs(1),
477            strategy: ThrottleStrategy::Drop,
478        };
479        let body = camel_api::OutcomeSegment::new(Box::new(NoopSeg));
480        let mut seg = ThrottleSegment::new(config, body);
481        let ex = Exchange::new(Message::new("test"));
482        // First call exhausts the single token
483        let _first = seg.run(ex).await;
484        // Second call is dropped
485        let ex2 = Exchange::new(Message::new("test2"));
486        let outcome = seg.run(ex2).await;
487        match outcome {
488            PipelineOutcome::Stopped(returned_ex) => {
489                let stopped_flag = returned_ex.property(CAMEL_STOP).and_then(|v| v.as_bool());
490                assert_eq!(
491                    stopped_flag,
492                    Some(true),
493                    "Drop strategy must set CamelStop=true property"
494                );
495            }
496            other => panic!("Drop must return Stopped, got {:?}", other),
497        }
498    }
499
500    #[tokio::test]
501    async fn throttle_segment_delay_strategy_propagates_stopped_body() {
502        use camel_api::{Body, Exchange, Message, OutcomePipeline, PipelineOutcome};
503
504        #[derive(Clone)]
505        struct StoppingSeg;
506        impl OutcomePipeline for StoppingSeg {
507            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
508                Box::new(StoppingSeg)
509            }
510            fn run<'a>(
511                &'a mut self,
512                mut ex: Exchange,
513            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
514                Box::pin(async move {
515                    ex.input.body = Body::Bytes(b"stopped-mut".to_vec().into());
516                    PipelineOutcome::Stopped(ex)
517                })
518            }
519        }
520
521        let config = ThrottlerConfig {
522            max_requests: 1, // 1 token available immediately
523            period: Duration::from_secs(1),
524            strategy: ThrottleStrategy::Delay,
525        };
526        let body = camel_api::OutcomeSegment::new(Box::new(StoppingSeg));
527        let mut seg = ThrottleSegment::new(config, body);
528        let ex = Exchange::new(Message::new("test"));
529        let outcome = seg.run(ex).await;
530        match outcome {
531            PipelineOutcome::Stopped(returned_ex) => {
532                if let Body::Bytes(b) = &returned_ex.input.body {
533                    assert_eq!(
534                        b.as_ref(),
535                        b"stopped-mut",
536                        "BUG: throttle body Stop must preserve mutations"
537                    );
538                } else {
539                    panic!("expected Body::Bytes");
540                }
541            }
542            other => panic!("expected Stopped propagation, got {:?}", other),
543        }
544    }
545}