Skip to main content

ullun/
limits.rs

1//! Resilience limits: request timeouts, load shedding and connection caps
2//!
3//! * [`Timeout`] answers 504 when a handler takes too long
4//! * [`ConcurrencyLimit`] sheds load with 503 + `Retry-After` instead of letting
5//!   work pile up until the process falls over
6//! * [`App::max_connections`](crate::app::App::max_connections),
7//!   [`App::max_connections_per_ip`](crate::app::App::max_connections_per_ip) and
8//!   [`App::header_read_timeout`](crate::app::App::header_read_timeout) bound what
9//!   a single client (or a slowloris) can hold on to
10//!
11//! ```no_run
12//! use std::time::Duration;
13//! use ullun::limits::{ConcurrencyLimit, Timeout};
14//! use ullun::prelude::*;
15//!
16//! # async fn run() {
17//! App::new()
18//!     .header_read_timeout(Duration::from_secs(10))
19//!     .max_connections(10_000)
20//!     .max_connections_per_ip(200)
21//!     .middleware(Timeout::new(Duration::from_secs(15)))
22//!     .middleware(ConcurrencyLimit::new(512))
23//!     .get("/", |_req: Request| async { Ok(Response::text("ok")) })
24//!     .run("127.0.0.1:3000")
25//!     .await
26//!     .unwrap();
27//! # }
28//! ```
29//!
30//! Both middlewares cover the time until the handler returns its [`Response`];
31//! a streaming body (SSE, downloads) is produced afterwards and is not cut off.
32
33use crate::error::{Error, Result};
34use crate::middleware::{Middleware, Next};
35use crate::request::Request;
36use crate::response::Response;
37use async_trait::async_trait;
38use std::collections::HashMap;
39use std::net::IpAddr;
40use std::sync::{Arc, Mutex};
41use std::time::Duration;
42use tokio::sync::Semaphore;
43
44/// Fail requests that run longer than a deadline with 504
45pub struct Timeout {
46    limit: Duration,
47}
48
49impl Timeout {
50    pub fn new(limit: Duration) -> Self {
51        Self { limit }
52    }
53}
54
55#[async_trait]
56impl Middleware for Timeout {
57    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
58        match tokio::time::timeout(self.limit, next.run(req)).await {
59            Ok(result) => result,
60            Err(_) => Ok(Response::from_error(Error::custom(
61                504,
62                "Request timed out",
63            ))),
64        }
65    }
66}
67
68/// Allow at most `max` requests in flight, answer the rest with 503 and `Retry-After`
69///
70/// By default an excess request is rejected at once. [`ConcurrencyLimit::queue_for`]
71/// lets it wait a short while for a free slot first, which smooths bursts without
72/// building an unbounded backlog.
73pub struct ConcurrencyLimit {
74    permits: Arc<Semaphore>,
75    wait: Duration,
76    retry_after: u64,
77}
78
79impl ConcurrencyLimit {
80    pub fn new(max: usize) -> Self {
81        Self {
82            permits: Arc::new(Semaphore::new(max.max(1))),
83            wait: Duration::ZERO,
84            retry_after: 1,
85        }
86    }
87
88    /// Let an excess request wait up to `wait` for a slot before it is rejected
89    pub fn queue_for(mut self, wait: Duration) -> Self {
90        self.wait = wait;
91        self
92    }
93
94    /// Seconds advertised in `Retry-After` (default 1)
95    pub fn retry_after(mut self, seconds: u64) -> Self {
96        self.retry_after = seconds;
97        self
98    }
99
100    /// Requests that currently hold a slot is `max - available_slots()`
101    pub fn available_slots(&self) -> usize {
102        self.permits.available_permits()
103    }
104}
105
106#[async_trait]
107impl Middleware for ConcurrencyLimit {
108    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
109        let permit = match self.permits.clone().try_acquire_owned() {
110            Ok(p) => Some(p),
111            Err(_) if self.wait.is_zero() => None,
112            Err(_) => tokio::time::timeout(self.wait, self.permits.clone().acquire_owned())
113                .await
114                .ok()
115                .and_then(|r| r.ok()),
116        };
117        let Some(_permit) = permit else {
118            return Ok(Response::from_error(Error::custom(
119                503,
120                "Server is busy, try again shortly",
121            ))
122            .with_header("retry-after", self.retry_after.to_string()));
123        };
124        next.run(req).await
125    }
126}
127
128type PerIp = Arc<Mutex<HashMap<IpAddr, usize>>>;
129
130/// Connection caps checked on accept
131pub(crate) struct ConnLimits {
132    total: Option<Arc<Semaphore>>,
133    per_ip: Option<(usize, PerIp)>,
134}
135
136/// Releases a connection's slot when dropped
137pub(crate) struct ConnGuard {
138    _permit: Option<tokio::sync::OwnedSemaphorePermit>,
139    ip: Option<(IpAddr, PerIp)>,
140}
141
142impl Drop for ConnGuard {
143    fn drop(&mut self) {
144        if let Some((ip, map)) = self.ip.take() {
145            let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
146            if let Some(n) = map.get_mut(&ip) {
147                *n -= 1;
148                if *n == 0 {
149                    map.remove(&ip);
150                }
151            }
152        }
153    }
154}
155
156impl ConnLimits {
157    pub(crate) fn new(total: Option<usize>, per_ip: Option<usize>) -> Self {
158        Self {
159            total: total.map(|n| Arc::new(Semaphore::new(n))),
160            per_ip: per_ip.map(|n| (n, Arc::default())),
161        }
162    }
163
164    /// A slot for a new connection from `ip`, or `None` when a cap is reached
165    pub(crate) fn admit(&self, ip: IpAddr) -> Option<ConnGuard> {
166        let permit = match &self.total {
167            Some(sem) => Some(sem.clone().try_acquire_owned().ok()?),
168            None => None,
169        };
170        let ip = match &self.per_ip {
171            Some((max, map)) => {
172                let mut guard = map.lock().unwrap_or_else(|e| e.into_inner());
173                let n = guard.entry(ip).or_insert(0);
174                if *n >= *max {
175                    return None; // dropping `permit` frees the total slot again
176                }
177                *n += 1;
178                Some((ip, map.clone()))
179            }
180            None => None,
181        };
182        Some(ConnGuard {
183            _permit: permit,
184            ip,
185        })
186    }
187}
188
189/// Tell a refused client to come back later, without reading its request
190pub(crate) async fn refuse<S>(mut stream: S)
191where
192    S: tokio::io::AsyncWrite + Unpin,
193{
194    use tokio::io::AsyncWriteExt;
195    let reply = b"HTTP/1.1 503 Service Unavailable\r\nretry-after: 1\r\nconnection: close\r\ncontent-length: 0\r\n\r\n";
196    let _ = tokio::time::timeout(Duration::from_millis(500), async {
197        let _ = stream.write_all(reply).await;
198        let _ = stream.shutdown().await;
199    })
200    .await;
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn ip(n: u8) -> IpAddr {
208        IpAddr::from([10, 0, 0, n])
209    }
210
211    #[test]
212    fn total_cap_releases_on_drop() {
213        let limits = ConnLimits::new(Some(2), None);
214        let a = limits.admit(ip(1)).unwrap();
215        let _b = limits.admit(ip(2)).unwrap();
216        assert!(limits.admit(ip(3)).is_none());
217        drop(a);
218        assert!(limits.admit(ip(3)).is_some());
219    }
220
221    #[test]
222    fn per_ip_cap_is_independent_per_address() {
223        let limits = ConnLimits::new(None, Some(1));
224        let a = limits.admit(ip(1)).unwrap();
225        assert!(limits.admit(ip(1)).is_none());
226        assert!(limits.admit(ip(2)).is_some());
227        drop(a);
228        assert!(limits.admit(ip(1)).is_some());
229    }
230
231    #[test]
232    fn refused_per_ip_does_not_leak_a_total_slot() {
233        let limits = ConnLimits::new(Some(2), Some(1));
234        let _a = limits.admit(ip(1)).unwrap();
235        assert!(limits.admit(ip(1)).is_none());
236        assert!(limits.admit(ip(2)).is_some());
237    }
238
239    #[test]
240    fn no_caps_admit_everything() {
241        let limits = ConnLimits::new(None, None);
242        let _g: Vec<_> = (0..100).map(|_| limits.admit(ip(1)).unwrap()).collect();
243    }
244}