Skip to main content

ironflow_api/
sse_limiter.rs

1//! Caps on simultaneous Server-Sent Events connections.
2//!
3//! An SSE stream (`GET /api/v1/events`, `GET /api/v1/runs/{id}/events`) holds
4//! a connection, a broadcast receiver and a task for as long as the client
5//! keeps it open. [`SseLimiter`] bounds how many of them one principal (a user,
6//! or the owner of an API key) and the whole server may hold at once. A
7//! connection beyond either cap is refused with `429 SSE_CONNECTION_LIMIT`.
8//!
9//! # Examples
10//!
11//! ```
12//! use ironflow_api::sse_limiter::{SseLimiter, SseLimits};
13//! use uuid::Uuid;
14//!
15//! # fn example() -> Result<(), ironflow_api::error::ApiError> {
16//! let limiter = SseLimiter::new(SseLimits {
17//!     per_principal: Some(1),
18//!     global: None,
19//! });
20//! let user = Uuid::now_v7();
21//!
22//! let connection = limiter.try_acquire(user)?;
23//! assert!(limiter.try_acquire(user).is_err());
24//! drop(connection);
25//! assert!(limiter.try_acquire(user).is_ok());
26//! # Ok(())
27//! # }
28//! ```
29
30use std::collections::HashMap;
31use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
32
33use uuid::Uuid;
34
35use crate::error::ApiError;
36
37/// Default cap on simultaneous SSE streams per principal.
38pub const DEFAULT_SSE_MAX_PER_PRINCIPAL: usize = 5;
39
40/// Default cap on simultaneous SSE streams for the whole server.
41pub const DEFAULT_SSE_MAX_GLOBAL: usize = 1000;
42
43/// Caps applied by an [`SseLimiter`]. `None` means unlimited.
44///
45/// # Examples
46///
47/// ```
48/// use ironflow_api::sse_limiter::{
49///     DEFAULT_SSE_MAX_GLOBAL, DEFAULT_SSE_MAX_PER_PRINCIPAL, SseLimits,
50/// };
51///
52/// let limits = SseLimits::default();
53/// assert_eq!(limits.per_principal, Some(DEFAULT_SSE_MAX_PER_PRINCIPAL));
54/// assert_eq!(limits.global, Some(DEFAULT_SSE_MAX_GLOBAL));
55/// ```
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct SseLimits {
58    /// Simultaneous streams one principal may hold.
59    pub per_principal: Option<usize>,
60    /// Simultaneous streams the whole server may hold.
61    pub global: Option<usize>,
62}
63
64impl Default for SseLimits {
65    fn default() -> Self {
66        Self {
67            per_principal: Some(DEFAULT_SSE_MAX_PER_PRINCIPAL),
68            global: Some(DEFAULT_SSE_MAX_GLOBAL),
69        }
70    }
71}
72
73/// Open stream counters, shared by every clone of an [`SseLimiter`].
74#[derive(Debug, Default)]
75struct Counts {
76    total: usize,
77    per_principal: HashMap<Uuid, usize>,
78}
79
80/// Counts open SSE streams and refuses new ones past the [`SseLimits`].
81///
82/// Cheap to clone: clones share the same counters.
83///
84/// # Examples
85///
86/// ```
87/// use ironflow_api::sse_limiter::SseLimiter;
88/// use uuid::Uuid;
89///
90/// # fn example() -> Result<(), ironflow_api::error::ApiError> {
91/// let limiter = SseLimiter::default();
92/// let _connection = limiter.try_acquire(Uuid::now_v7())?;
93/// # Ok(())
94/// # }
95/// ```
96#[derive(Debug, Clone, Default)]
97pub struct SseLimiter {
98    limits: SseLimits,
99    counts: Arc<Mutex<Counts>>,
100}
101
102impl SseLimiter {
103    /// Create a limiter enforcing `limits`.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use ironflow_api::sse_limiter::{SseLimiter, SseLimits};
109    ///
110    /// let limiter = SseLimiter::new(SseLimits {
111    ///     per_principal: Some(2),
112    ///     global: Some(100),
113    /// });
114    /// ```
115    pub fn new(limits: SseLimits) -> Self {
116        Self {
117            limits,
118            counts: Arc::new(Mutex::new(Counts::default())),
119        }
120    }
121
122    /// Reserve a stream slot for `principal`.
123    ///
124    /// The slot is held by the returned [`SseConnection`] and released when
125    /// it is dropped.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ApiError::SseConnectionLimit`] when the server or the
130    /// principal already holds as many streams as its limit allows.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use ironflow_api::sse_limiter::{SseLimiter, SseLimits};
136    /// use uuid::Uuid;
137    ///
138    /// # fn example() -> Result<(), ironflow_api::error::ApiError> {
139    /// let limiter = SseLimiter::new(SseLimits {
140    ///     per_principal: None,
141    ///     global: Some(1),
142    /// });
143    /// let _connection = limiter.try_acquire(Uuid::now_v7())?;
144    /// assert!(limiter.try_acquire(Uuid::now_v7()).is_err());
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub fn try_acquire(&self, principal: Uuid) -> Result<SseConnection, ApiError> {
149        let mut counts = self.lock();
150
151        if self.limits.global.is_some_and(|max| counts.total >= max) {
152            return Err(ApiError::SseConnectionLimit);
153        }
154        let held = counts.per_principal.get(&principal).copied().unwrap_or(0);
155        if self.limits.per_principal.is_some_and(|max| held >= max) {
156            return Err(ApiError::SseConnectionLimit);
157        }
158
159        counts.total += 1;
160        counts.per_principal.insert(principal, held + 1);
161
162        Ok(SseConnection {
163            limiter: self.clone(),
164            principal,
165        })
166    }
167
168    /// Streams currently open on the whole server.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ironflow_api::sse_limiter::SseLimiter;
174    ///
175    /// assert_eq!(SseLimiter::default().open_connections(), 0);
176    /// ```
177    pub fn open_connections(&self) -> usize {
178        self.lock().total
179    }
180
181    // A panic while holding the lock leaves the counters consistent (every
182    // update is a single statement), so a poisoned lock is still usable.
183    fn lock(&self) -> MutexGuard<'_, Counts> {
184        self.counts.lock().unwrap_or_else(PoisonError::into_inner)
185    }
186
187    fn release(&self, principal: Uuid) {
188        let mut counts = self.lock();
189        counts.total = counts.total.saturating_sub(1);
190        if let Some(held) = counts.per_principal.get_mut(&principal) {
191            *held = held.saturating_sub(1);
192            if *held == 0 {
193                counts.per_principal.remove(&principal);
194            }
195        }
196    }
197}
198
199/// A reserved SSE stream slot, released when dropped.
200///
201/// Keep it alive for as long as the stream it guards: move it into the
202/// stream so the slot is freed when the client disconnects or the stream
203/// ends.
204///
205/// # Examples
206///
207/// ```
208/// use ironflow_api::sse_limiter::SseLimiter;
209/// use uuid::Uuid;
210///
211/// # fn example() -> Result<(), ironflow_api::error::ApiError> {
212/// let limiter = SseLimiter::default();
213/// let connection = limiter.try_acquire(Uuid::now_v7())?;
214/// assert_eq!(limiter.open_connections(), 1);
215/// drop(connection);
216/// assert_eq!(limiter.open_connections(), 0);
217/// # Ok(())
218/// # }
219/// ```
220#[derive(Debug)]
221pub struct SseConnection {
222    limiter: SseLimiter,
223    principal: Uuid,
224}
225
226impl Drop for SseConnection {
227    fn drop(&mut self) {
228        self.limiter.release(self.principal);
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn limiter(per_principal: Option<usize>, global: Option<usize>) -> SseLimiter {
237        SseLimiter::new(SseLimits {
238            per_principal,
239            global,
240        })
241    }
242
243    #[test]
244    fn per_principal_cap_refuses_the_next_stream() {
245        let limiter = SseLimiter::default();
246        let user = Uuid::now_v7();
247
248        let held: Vec<_> = (0..DEFAULT_SSE_MAX_PER_PRINCIPAL)
249            .map(|_| limiter.try_acquire(user).expect("under the cap"))
250            .collect();
251
252        let refused = limiter.try_acquire(user);
253        assert!(matches!(refused, Err(ApiError::SseConnectionLimit)));
254        let other = limiter.try_acquire(Uuid::now_v7());
255        assert!(other.is_ok(), "another principal is unaffected");
256        assert_eq!(held.len(), DEFAULT_SSE_MAX_PER_PRINCIPAL);
257    }
258
259    #[test]
260    fn global_cap_refuses_every_principal() {
261        let limiter = limiter(None, Some(2));
262        let _a = limiter.try_acquire(Uuid::now_v7()).expect("first");
263        let _b = limiter.try_acquire(Uuid::now_v7()).expect("second");
264
265        let refused = limiter.try_acquire(Uuid::now_v7());
266        assert!(matches!(refused, Err(ApiError::SseConnectionLimit)));
267    }
268
269    #[test]
270    fn dropping_a_connection_frees_its_slot() {
271        let limiter = limiter(Some(1), Some(1));
272        let user = Uuid::now_v7();
273
274        let connection = limiter.try_acquire(user).expect("first");
275        assert!(limiter.try_acquire(user).is_err());
276        drop(connection);
277        assert!(limiter.try_acquire(user).is_ok());
278    }
279
280    #[test]
281    fn unlimited_when_limits_are_none() {
282        let limiter = limiter(None, None);
283        let user = Uuid::now_v7();
284
285        let held: Vec<_> = (0..50)
286            .map(|_| limiter.try_acquire(user).expect("unlimited"))
287            .collect();
288        assert_eq!(limiter.open_connections(), held.len());
289    }
290
291    #[test]
292    fn zero_limit_refuses_everything() {
293        let limiter = limiter(Some(0), None);
294        assert!(limiter.try_acquire(Uuid::now_v7()).is_err());
295    }
296
297    #[test]
298    fn counters_return_to_zero() {
299        let limiter = SseLimiter::default();
300        let user = Uuid::now_v7();
301
302        let a = limiter.try_acquire(user).expect("a");
303        let b = limiter.try_acquire(user).expect("b");
304        let c = limiter.try_acquire(Uuid::now_v7()).expect("c");
305        assert_eq!(limiter.open_connections(), 3);
306        drop((a, b, c));
307
308        assert_eq!(limiter.open_connections(), 0);
309        assert!(limiter.lock().per_principal.is_empty());
310    }
311
312    #[test]
313    fn clones_share_counters() {
314        let limiter = limiter(Some(1), None);
315        let clone = limiter.clone();
316        let user = Uuid::now_v7();
317
318        let _connection = limiter.try_acquire(user).expect("first");
319        assert!(clone.try_acquire(user).is_err());
320    }
321
322    #[test]
323    fn connection_is_send_and_static() {
324        fn assert_send_static<T: Send + 'static>() {}
325        assert_send_static::<SseConnection>();
326    }
327}