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}