Skip to main content

systemprompt_api/services/middleware/
rate_limit.rs

1//! Router extension traits for rate limiting and authenticated route groups.
2//!
3//! `RouterExt::with_auth` attaches authentication and authorization in one
4//! call: it requires an `AuthzPolicy`, so a route group cannot be mounted
5//! authenticated-but-unauthorized — omitting the policy is a compile error.
6//!
7//! `RouterExt::with_rate_limit` mounts two throttles: the in-process governor
8//! keyed by verified identity or trusted client IP, which smooths bursts per
9//! replica, and a database-backed window keyed by verified identity only,
10//! which bounds a caller's budget across every replica of the deployment.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use crate::services::middleware::authz::{AuthzPolicy, authz_gate};
16use crate::services::middleware::client_addr::resolve_client_ip;
17use crate::services::middleware::context::{
18    A2AContextMiddleware, McpContextMiddleware, PublicContextMiddleware, UserOnlyContextMiddleware,
19};
20use axum::Router;
21use axum::extract::{ConnectInfo, Request, State};
22use axum::http::{StatusCode, header};
23use axum::middleware::Next;
24use axum::response::{IntoResponse, Response};
25use chrono::{DateTime, Utc};
26use ipnet::IpNet;
27use std::future::Future;
28use std::net::SocketAddr;
29use std::sync::Arc;
30use systemprompt_extension::LoaderError;
31use systemprompt_models::auth::UserType;
32use systemprompt_models::config::RateLimitConfig;
33use systemprompt_models::{Config, RequestContext};
34use systemprompt_runtime::AppContext;
35use systemprompt_users::UserRateLimitBucketRepository;
36
37const GLOBAL_WINDOW_SECS: i64 = 10;
38
39#[derive(Clone, Debug)]
40pub struct RateLimitState {
41    config: RateLimitConfig,
42    trusted_proxies: Arc<Vec<IpNet>>,
43    buckets: Arc<UserRateLimitBucketRepository>,
44}
45
46impl RateLimitState {
47    #[must_use]
48    pub fn new(config: &Config, buckets: Arc<UserRateLimitBucketRepository>) -> Self {
49        Self {
50            config: config.rate_limits,
51            trusted_proxies: Arc::new(config.trusted_proxies.clone()),
52            buckets,
53        }
54    }
55
56    pub fn from_context(ctx: &AppContext) -> Result<Self, LoaderError> {
57        let buckets = crate::repository::user_rate_limit_buckets(ctx.db_pool()).map_err(|e| {
58            LoaderError::InitializationFailed {
59                extension: "rate_limit".to_owned(),
60                message: e.to_string(),
61            }
62        })?;
63        Ok(Self::new(ctx.config(), buckets))
64    }
65}
66
67#[derive(Clone, Debug)]
68struct GlobalUserLimit {
69    buckets: Arc<UserRateLimitBucketRepository>,
70    scope: &'static str,
71    budget: i64,
72}
73
74fn window_start(now: DateTime<Utc>) -> DateTime<Utc> {
75    let secs = now.timestamp();
76    let start = secs - secs.rem_euclid(GLOBAL_WINDOW_SECS);
77    DateTime::from_timestamp(start, 0).unwrap_or(now)
78}
79
80fn too_many_requests(now: DateTime<Utc>, start: DateTime<Utc>) -> Response {
81    let elapsed = now.timestamp() - start.timestamp();
82    let retry_after = (GLOBAL_WINDOW_SECS - elapsed).max(1);
83    (
84        StatusCode::TOO_MANY_REQUESTS,
85        [(header::RETRY_AFTER, retry_after.to_string())],
86        "rate limit exceeded",
87    )
88        .into_response()
89}
90
91// Why: the governor above already refuses local bursts, so this layer only
92// has to bound the sum across replicas. It fails open on a database fault:
93// an HTTP throttle protects capacity, not data, and the ban gate ahead of it
94// is the one that stays closed.
95async fn global_user_rate_limit(
96    State(limit): State<GlobalUserLimit>,
97    req: Request,
98    next: Next,
99) -> Response {
100    let user_id = req
101        .extensions()
102        .get::<RequestContext>()
103        .filter(|ctx| ctx.auth.user_type != UserType::Anon)
104        .map(|ctx| ctx.user_id().clone());
105    let Some(user_id) = user_id else {
106        return next.run(req).await;
107    };
108
109    let now = Utc::now();
110    let start = window_start(now);
111    match limit.buckets.hit(&user_id, limit.scope, start).await {
112        Ok(hits) if hits > limit.budget => {
113            tracing::debug!(
114                user_id = %user_id,
115                scope = limit.scope,
116                hits,
117                budget = limit.budget,
118                "global user rate limit exceeded"
119            );
120            too_many_requests(now, start)
121        },
122        Ok(_) => next.run(req).await,
123        Err(err) => {
124            tracing::warn!(
125                user_id = %user_id,
126                scope = limit.scope,
127                error = %err,
128                "global user rate limit unavailable; admitting request"
129            );
130            next.run(req).await
131        },
132    }
133}
134
135#[derive(Clone, Debug)]
136pub struct IdentityOrTrustedIpKey {
137    trusted_proxies: Arc<Vec<IpNet>>,
138}
139
140impl IdentityOrTrustedIpKey {
141    const fn new(trusted_proxies: Arc<Vec<IpNet>>) -> Self {
142        Self { trusted_proxies }
143    }
144}
145
146impl tower_governor::key_extractor::KeyExtractor for IdentityOrTrustedIpKey {
147    type Key = String;
148
149    fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, tower_governor::GovernorError> {
150        // Why: an anonymous context's user id is a hash of the User-Agent and
151        // Accept-Language headers, so a caller who rotates either would mint a fresh
152        // bucket per request. Only a signature-verified identity is safe to key on.
153        if let Some(ctx) = req.extensions().get::<RequestContext>()
154            && ctx.auth.user_type != UserType::Anon
155        {
156            return Ok(format!("u:{}", ctx.user_id()));
157        }
158
159        resolve_client_ip(
160            req.headers(),
161            req.extensions().get::<ConnectInfo<SocketAddr>>(),
162            &self.trusted_proxies,
163        )
164        .map(|ip| format!("ip:{ip}"))
165        .ok_or(tower_governor::GovernorError::UnableToExtractKey)
166    }
167}
168
169pub trait ContextLayer: Clone + Send + Sync + 'static {
170    fn handle(self, req: Request, next: Next) -> impl Future<Output = Response> + Send;
171}
172
173impl ContextLayer for PublicContextMiddleware {
174    async fn handle(self, req: Request, next: Next) -> Response {
175        Self::handle(&self, req, next).await
176    }
177}
178
179impl ContextLayer for UserOnlyContextMiddleware {
180    async fn handle(self, req: Request, next: Next) -> Response {
181        Self::handle(&self, req, next).await
182    }
183}
184
185impl ContextLayer for A2AContextMiddleware {
186    async fn handle(self, req: Request, next: Next) -> Response {
187        Self::handle(&self, req, next).await
188    }
189}
190
191impl ContextLayer for McpContextMiddleware {
192    async fn handle(self, req: Request, next: Next) -> Response {
193        Self::handle(&self, req, next).await
194    }
195}
196
197pub trait RouterExt<S>: Sized {
198    fn with_rate_limit(
199        self,
200        limits: &RateLimitState,
201        per_second: u64,
202        scope: &'static str,
203    ) -> Result<Self, LoaderError>;
204
205    fn with_auth<L: ContextLayer>(self, auth: L, policy: AuthzPolicy) -> Self;
206}
207
208impl<S> RouterExt<S> for Router<S>
209where
210    S: Clone + Send + Sync + 'static,
211{
212    fn with_rate_limit(
213        self,
214        limits: &RateLimitState,
215        per_second: u64,
216        scope: &'static str,
217    ) -> Result<Self, LoaderError> {
218        let rate_config = &limits.config;
219        if rate_config.disabled {
220            return Ok(self);
221        }
222
223        // Why: a truncating `as u32` turns any product that is a multiple of 2^32 into
224        // a zero burst, which `finish()` reports only by returning `None` —
225        // silently leaving the route unlimited. Saturate and clamp so the quota
226        // is always representable.
227        let burst = per_second.saturating_mul(rate_config.burst_multiplier);
228        let burst_u32 = u32::try_from(burst).unwrap_or(u32::MAX).max(1);
229        let per_second_clamped = per_second.max(1);
230
231        let rate_limit = tower_governor::governor::GovernorConfigBuilder::default()
232            .per_second(per_second_clamped)
233            .burst_size(burst_u32)
234            .key_extractor(IdentityOrTrustedIpKey::new(Arc::clone(
235                &limits.trusted_proxies,
236            )))
237            .use_headers()
238            .finish()
239            .ok_or_else(|| LoaderError::InitializationFailed {
240                extension: "rate_limit".to_owned(),
241                message: format!(
242                    "rate limit rejected for {per_second_clamped}/s with burst {burst_u32}"
243                ),
244            })?;
245
246        let window_secs = u64::try_from(GLOBAL_WINDOW_SECS).unwrap_or(u64::MAX);
247        let budget = burst.saturating_mul(window_secs);
248        let global = GlobalUserLimit {
249            buckets: Arc::clone(&limits.buckets),
250            scope,
251            budget: i64::try_from(budget).unwrap_or(i64::MAX),
252        };
253
254        Ok(self
255            .layer(axum::middleware::from_fn_with_state(
256                global,
257                global_user_rate_limit,
258            ))
259            .layer(tower_governor::GovernorLayer::new(rate_limit)))
260    }
261
262    fn with_auth<L: ContextLayer>(self, auth: L, policy: AuthzPolicy) -> Self {
263        self.layer(axum::middleware::from_fn(move |req, next| async move {
264            authz_gate(policy, req, next).await
265        }))
266        .layer(axum::middleware::from_fn(move |req, next| {
267            let auth = auth.clone();
268            async move { auth.handle(req, next).await }
269        }))
270    }
271}