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