Skip to main content

lc_core/router_llm/
rate.rs

1//! Per-model admission control for [`super::RouterLLM`] (B13, 0.22.4).
2//!
3//! Two independent limits are applied to every routed call:
4//!
5//! - **Request rate**: at most `requests_per_window` calls may start in any
6//!   sliding `window`. Admitted requests borrow a permit that is returned
7//!   exactly one window later (each permit is scheduled back with a timer
8//!   task), so a burst of `N` is accepted immediately and the next caller
9//!   queues until the oldest permit comes home.
10//! - **Concurrency**: at most `max_concurrent` calls may be in flight at once
11//!   (including the time the caller spends streaming the response, since the
12//!   router keeps the [`GatePermit`] alive inside the returned stream).
13//!
14//! When a limit is saturated, callers **queue** instead of being rejected:
15//! `tokio::sync::Semaphore` wakes waiters FIFO, which is exactly the queue
16//! fairness the router needs (the oldest blocked caller is admitted first).
17//! The queue is optionally bounded (`max_queue`) and each waiter waits at
18//! most `wait_timeout`; both produce a
19//! [`RouterError::RateLimited`](super::RouterError::RateLimited) that the
20//! router treats like a model failure and uses to fall through to the next
21//! candidate model.
22
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::Arc;
25use std::time::Duration;
26
27use tokio::sync::{OwnedSemaphorePermit, Semaphore};
28
29use super::RouterError;
30
31/// Default admission queue timeout when callers configure nothing else.
32const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(60);
33
34/// Per-model rate / concurrency limits attached to one router slot.
35///
36/// Construct with [`ModelRateLimit::per_minute`] (or
37/// [`ModelRateLimit::per_second`]) and tighten the optional dimensions with
38/// the `with_*` builders. Pass to
39/// [`RouterLLM::with_model_rate_limited`](super::RouterLLM::with_model_rate_limited)
40/// or [`RouterLLM::with_last_rate_limit`](super::RouterLLM::with_last_rate_limit).
41#[derive(Debug, Clone)]
42pub struct ModelRateLimit {
43    /// Max calls whose windows overlap (0 disables the rate dimension).
44    pub(crate) requests_per_window: usize,
45    /// Length of the rate window.
46    pub(crate) window: Duration,
47    /// Max simultaneously in-flight calls (0 disables the dimension).
48    pub(crate) max_concurrent: usize,
49    /// Max callers allowed to wait (0 = unbounded).
50    pub(crate) max_queue: usize,
51    /// Max time one caller waits for admission.
52    pub(crate) wait_timeout: Duration,
53}
54
55impl ModelRateLimit {
56    /// At most `requests_per_minute` admitted per 60-second window, unlimited
57    /// concurrency, unbounded queue, 60-second wait timeout.
58    pub fn per_minute(requests_per_minute: usize) -> Self {
59        Self {
60            requests_per_window: requests_per_minute,
61            window: Duration::from_secs(60),
62            max_concurrent: 0,
63            max_queue: 0,
64            wait_timeout: DEFAULT_WAIT_TIMEOUT,
65        }
66    }
67
68    /// At most `requests_per_second` admitted per one-second window.
69    pub fn per_second(requests_per_second: usize) -> Self {
70        Self {
71            requests_per_window: requests_per_second,
72            window: Duration::from_secs(1),
73            max_concurrent: 0,
74            max_queue: 0,
75            wait_timeout: Duration::from_secs(1),
76        }
77    }
78
79    /// Overrides the rate window length (e.g. a provider quota stated per day).
80    /// The wait timeout is intentionally left untouched: a queued caller may
81    /// legitimately wait several windows deep, so configure
82    /// [`ModelRateLimit::with_wait_timeout`] separately when shortening.
83    pub fn with_window(mut self, window: Duration) -> Self {
84        self.window = window;
85        self
86    }
87
88    /// Bounds in-flight calls. The slot stays occupied until the whole
89    /// response (including a streamed body) has finished. `0` (the default)
90    /// disables the concurrency dimension.
91    pub fn with_max_concurrent(mut self, max_concurrent: usize) -> Self {
92        self.max_concurrent = max_concurrent;
93        self
94    }
95
96    /// Bounds how many callers may wait for admission; `0` (the default)
97    /// means an unbounded FIFO queue. A full queue rejects immediately with
98    /// [`RouterError::RateLimited`](super::RouterError::RateLimited) so the
99    /// router can fall through to a fallback model without delay.
100    pub fn with_max_queue(mut self, max_queue: usize) -> Self {
101        self.max_queue = max_queue;
102        self
103    }
104
105    /// Caps how long one caller waits before the router gives up on this
106    /// slot and tries the next model. Defaults to one rate window.
107    pub fn with_wait_timeout(mut self, wait_timeout: Duration) -> Self {
108        self.wait_timeout = wait_timeout;
109        self
110    }
111
112    /// Configured requests-per-window.
113    pub fn requests_per_window(&self) -> usize {
114        self.requests_per_window
115    }
116
117    /// Configured rate window.
118    pub fn window(&self) -> Duration {
119        self.window
120    }
121
122    /// Configured concurrency cap (0 = unlimited).
123    pub fn max_concurrent(&self) -> usize {
124        self.max_concurrent
125    }
126
127    /// Configured queue depth (0 = unbounded).
128    pub fn max_queue(&self) -> usize {
129        self.max_queue
130    }
131
132    /// Configured admission wait timeout.
133    pub fn wait_timeout(&self) -> Duration {
134        self.wait_timeout
135    }
136}
137
138/// Admission gate built from a [`ModelRateLimit`] and shared (`Arc`) by every
139/// call routed to one slot.
140pub(super) struct ModelGate {
141    /// `None` when the rate dimension is disabled.
142    rate: Option<RateGate>,
143    /// Concurrency permits; [`Semaphore::MAX_PERMITS`] when unlimited.
144    concurrency: Arc<Semaphore>,
145    /// Max wait for a free concurrency slot (same configured timeout as the
146    /// rate queue, so a slot saturated by in-flight streams is skipped).
147    wait_timeout: Duration,
148}
149
150impl ModelGate {
151    pub(super) fn new(config: &ModelRateLimit) -> Arc<Self> {
152        let concurrency = if config.max_concurrent == 0 {
153            Semaphore::MAX_PERMITS
154        } else {
155            config.max_concurrent
156        };
157        Arc::new(Self {
158            rate: if config.requests_per_window > 0 {
159                Some(RateGate::new(config))
160            } else {
161                None
162            },
163            concurrency: Arc::new(Semaphore::new(concurrency)),
164            wait_timeout: config.wait_timeout,
165        })
166    }
167
168    /// Acquires admission for one call.
169    ///
170    /// Rate admission happens **first** so the FIFO rate queue determines
171    /// global order; the concurrency slot is taken afterwards and held in the
172    /// returned guard until the call (and its streamed body) completes.
173    /// Either dimension parks at most `wait_timeout`, then the router skips
174    /// the slot.
175    pub(super) async fn acquire(&self, model: &str) -> Result<GatePermit, RouterError> {
176        if let Some(rate) = &self.rate {
177            rate.acquire(model).await?;
178        }
179        // An unlimited-configured semaphore cannot park or close; a bounded
180        // one waits up to the configured timeout so the caller can fall
181        // through to the next candidate instead of blocking indefinitely.
182        let acquired =
183            tokio::time::timeout(self.wait_timeout, self.concurrency.clone().acquire_owned()).await;
184        let concurrency = match acquired {
185            Ok(Ok(permit)) => permit,
186            Ok(Err(_closed)) => panic!("router concurrency semaphore is never closed"),
187            Err(_elapsed) => {
188                return Err(RouterError::RateLimited {
189                    model: model.to_string(),
190                    reason: RateLimitReason::Timeout {
191                        waited: self.wait_timeout,
192                    },
193                });
194            }
195        };
196        Ok(GatePermit {
197            _concurrency: concurrency,
198        })
199    }
200}
201
202/// Held while one call is in flight; dropping it frees the concurrency slot.
203pub(super) struct GatePermit {
204    _concurrency: OwnedSemaphorePermit,
205}
206
207impl std::fmt::Debug for GatePermit {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("GatePermit").finish_non_exhaustive()
210    }
211}
212
213/// Sliding-window request-rate gate with a bounded FIFO wait queue.
214struct RateGate {
215    /// One permit per admissible call; returned `window` after acquisition.
216    semaphore: Arc<Semaphore>,
217    window: Duration,
218    /// Callers currently trying to acquire (waiting or about to be admitted).
219    queued: AtomicUsize,
220    max_queue: usize,
221    wait_timeout: Duration,
222}
223
224impl RateGate {
225    fn new(config: &ModelRateLimit) -> Self {
226        Self {
227            semaphore: Arc::new(Semaphore::new(config.requests_per_window)),
228            window: config.window,
229            queued: AtomicUsize::new(0),
230            max_queue: config.max_queue,
231            wait_timeout: config.wait_timeout,
232        }
233    }
234
235    async fn acquire(&self, model: &str) -> Result<(), RouterError> {
236        // Queue accounting brackets only the wait: a call that already holds
237        // a rate permit must not consume queue capacity.
238        let position = self.queued.fetch_add(1, Ordering::AcqRel);
239        if self.max_queue > 0 && position >= self.max_queue {
240            self.queued.fetch_sub(1, Ordering::AcqRel);
241            return Err(RouterError::RateLimited {
242                model: model.to_string(),
243                reason: RateLimitReason::QueueFull {
244                    max_queue: self.max_queue,
245                },
246            });
247        }
248
249        let acquired =
250            tokio::time::timeout(self.wait_timeout, self.semaphore.clone().acquire_owned()).await;
251        self.queued.fetch_sub(1, Ordering::AcqRel);
252
253        let permit = match acquired {
254            Ok(Ok(permit)) => permit,
255            Ok(Err(_closed)) => panic!("router rate semaphore is never closed"),
256            Err(_elapsed) => {
257                return Err(RouterError::RateLimited {
258                    model: model.to_string(),
259                    reason: RateLimitReason::Timeout {
260                        waited: self.wait_timeout,
261                    },
262                });
263            }
264        };
265
266        // Return the permit exactly one window after this acquisition: the
267        // semaphore itself is the sliding-window log, and its FIFO wake order
268        // is the admission queue.
269        let window = self.window;
270        tokio::spawn(async move {
271            tokio::time::sleep(window).await;
272            drop(permit);
273        });
274        Ok(())
275    }
276}
277
278/// Why a queued call could not be admitted.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum RateLimitReason {
281    /// The bounded waiting queue was already full.
282    QueueFull {
283        /// Configured queue capacity.
284        max_queue: usize,
285    },
286    /// No permit became available within the configured wait timeout.
287    Timeout {
288        /// Time waited.
289        waited: Duration,
290    },
291}
292
293impl std::fmt::Display for RateLimitReason {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        match self {
296            RateLimitReason::QueueFull { max_queue } => {
297                write!(f, "admission queue full (max_queue={max_queue})")
298            }
299            RateLimitReason::Timeout { waited } => {
300                write!(f, "no permit within {} ms", waited.as_millis())
301            }
302        }
303    }
304}
305
306impl std::error::Error for RateLimitReason {}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn gate(rl: &ModelRateLimit) -> Arc<ModelGate> {
313        ModelGate::new(rl)
314    }
315
316    #[tokio::test(start_paused = true)]
317    async fn admits_burst_then_queues_fifo_across_window() {
318        // 1 call / 100 ms, concurrency 1 so admissions serialize and the
319        // completion order is the admission order.
320        let rl = ModelRateLimit::per_second(1)
321            .with_window(Duration::from_millis(100))
322            .with_max_concurrent(1);
323        let g = gate(&rl);
324
325        let finished = Arc::new(tokio::sync::Mutex::new(Vec::new()));
326        let mut handles = Vec::new();
327        for id in 0..3u8 {
328            let g = g.clone();
329            let finished = finished.clone();
330            handles.push(tokio::spawn(async move {
331                let _permit = g.acquire("m").await.unwrap();
332                // Simulate in-call work while holding both permits.
333                tokio::time::sleep(Duration::from_millis(10)).await;
334                finished.lock().await.push(id);
335            }));
336        }
337        for h in handles {
338            h.await.unwrap();
339        }
340        // FIFO: spawn order 0,1,2 must be completion order even though
341        // callers 1 and 2 had to wait for sliding-window replenishment.
342        assert_eq!(*finished.lock().await, vec![0, 1, 2]);
343    }
344
345    #[tokio::test(start_paused = true)]
346    async fn full_queue_rejects_instead_of_waiting() {
347        let rl = ModelRateLimit::per_minute(1).with_max_queue(1);
348        let g = gate(&rl);
349
350        let _p0 = g.acquire("m").await.unwrap();
351        // One caller is allowed to park in the queue ...
352        let g1 = g.clone();
353        let waiter = tokio::spawn(async move { g1.acquire("m").await });
354        // Let the waiter park.
355        tokio::task::yield_now().await;
356        tokio::task::yield_now().await;
357        // ... the next caller hits the bounded queue and fails immediately so
358        // the router can fall through to a fallback.
359        let err = g.acquire("m").await.unwrap_err();
360        assert!(
361            matches!(
362                err,
363                RouterError::RateLimited {
364                    reason: RateLimitReason::QueueFull { max_queue: 1 },
365                    ..
366                }
367            ),
368            "got {err:?}"
369        );
370        // The parked waiter is unaffected and gets admitted once a permit
371        // returns (one window later).
372        tokio::time::sleep(Duration::from_secs(60)).await;
373        assert!(waiter.await.unwrap().is_ok());
374    }
375
376    #[tokio::test(start_paused = true)]
377    async fn wait_timeout_falls_through() {
378        let rl = ModelRateLimit::per_minute(1).with_wait_timeout(Duration::from_secs(5));
379        let g = gate(&rl);
380        let _p0 = g.acquire("m").await.unwrap();
381        let started = tokio::time::Instant::now();
382        let err = g.acquire("primary").await.unwrap_err();
383        assert_eq!(started.elapsed(), Duration::from_secs(5));
384        assert!(
385            matches!(
386                err,
387                RouterError::RateLimited {
388                    reason: RateLimitReason::Timeout { .. },
389                    ..
390                }
391            ),
392            "got {err:?}"
393        );
394    }
395
396    #[tokio::test(start_paused = true)]
397    async fn permit_returns_after_window_and_keeps_rate_stable() {
398        // 2 / 100 ms: two bursts of two, separated by a window, must admit.
399        let rl = ModelRateLimit::per_second(2).with_window(Duration::from_millis(100));
400        let g = gate(&rl);
401        let _p1 = g.acquire("m").await.unwrap();
402        let _p2 = g.acquire("m").await.unwrap();
403        // Third call queues; must be admitted shortly after the window rolls.
404        let g2 = g.clone();
405        let h = tokio::spawn(async move { g2.acquire("m").await });
406        tokio::time::sleep(Duration::from_millis(101)).await;
407        assert!(h.await.unwrap().is_ok());
408    }
409
410    #[test]
411    fn disabled_dimensions_are_represented_as_zero() {
412        let rl = ModelRateLimit::per_minute(10);
413        assert_eq!(rl.max_concurrent(), 0);
414        assert_eq!(rl.max_queue(), 0);
415        assert_eq!(rl.requests_per_window(), 10);
416        let rl2 = rl
417            .with_window(Duration::from_secs(30))
418            .with_wait_timeout(Duration::from_secs(30));
419        // Window and wait timeout are configured independently (a queued
420        // caller may wait more than one window).
421        assert_eq!(rl2.window(), Duration::from_secs(30));
422        assert_eq!(rl2.wait_timeout(), Duration::from_secs(30));
423    }
424}