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