refactor(rate_limit): refresh crate across blacklist, middleware and metrics
zhenyi committed
Modified lib/rate_limit/blacklist.rs lib/rate_limit/blacklist.rs +27 −13 View lib/rate_limit/blacklist.rs
@@ -4,12 +4,11 @@
44use cache::RedisCache;
55use uuid::Uuid;
66
7use crate::metrics;
8
79/// Redis-backed deny-list for access tokens.
8///
9/// Two levels of revocation:
1010/// - per-token: `auth:deny:jti:{jti}` — a single access token is dead.
1111/// - per-user: `auth:deny:user:{sub}` — all tokens for a user are dead
12/// (used on password change, suspension, full session revocation).
1312#[derive(Clone)]
1413pub struct AccessTokenBlacklist {
1514 kv: Arc<RedisCache>,
@@ -21,13 +20,23 @@
2120 }
2221
2322 pub async fn revoke_jti(&self, jti: Uuid, ttl_seconds: u64) -> Result<(), RedisError> {
24 let key = format!("auth:deny:jti:{}", jti.simple());
25 self.kv.set_bytes(&key, b"1".to_vec(), ttl_seconds).await
23 let result: Result<(), RedisError> = async {
24 let key = format!("auth:deny:jti:{}", jti.simple());
25 self.kv.set_bytes(&key, b"1".to_vec(), ttl_seconds).await
26 }
27 .await;
28 metrics::record_blacklist_op("revoke_jti", &result);
29 result
2630 }
2731
2832 pub async fn revoke_user(&self, user_id: Uuid, ttl_seconds: u64) -> Result<(), RedisError> {
29 let key = format!("auth:deny:user:{}", user_id.simple());
30 self.kv.set_bytes(&key, b"1".to_vec(), ttl_seconds).await
33 let result: Result<(), RedisError> = async {
34 let key = format!("auth:deny:user:{}", user_id.simple());
35 self.kv.set_bytes(&key, b"1".to_vec(), ttl_seconds).await
36 }
37 .await;
38 metrics::record_blacklist_op("revoke_user", &result);
39 result
3140 }
3241
3342 pub async fn is_revoked(
@@ -35,12 +44,17 @@
3544 jti: Uuid,
3645 sub: Uuid,
3746 ) -> Result<bool, RedisError> {
38 let jti_key = format!("auth:deny:jti:{}", jti.simple());
39 let user_key = format!("auth:deny:user:{}", sub.simple());
40 let jti_hit = self.kv.get_bytes(&jti_key).await?.is_some();
41 if jti_hit {
42 return Ok(true);
47 let result: Result<bool, RedisError> = async {
48 let jti_key = format!("auth:deny:jti:{}", jti.simple());
49 let user_key = format!("auth:deny:user:{}", sub.simple());
50 let jti_hit = self.kv.get_bytes(&jti_key).await?.is_some();
51 if jti_hit {
52 return Ok(true);
53 }
54 Ok(self.kv.get_bytes(&user_key).await?.is_some())
4355 }
44 Ok(self.kv.get_bytes(&user_key).await?.is_some())
56 .await;
57 metrics::record_blacklist_op("is_revoked", &result);
58 result
4559 }
4660}
No newline at end of file
Modified lib/rate_limit/config.rs lib/rate_limit/config.rs +4 −8 View lib/rate_limit/config.rs
@@ -2,14 +2,10 @@
22
33#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RateLimitConfig {
5 /// Default per-api request limit within the window.
6 pub default_limit: u64,
7 /// Sliding window length in seconds.
8 pub window_secs: i64,
9 /// Name of the dedicated rate-limit cookie (server-side partition key).
10 pub cookie_name: String,
11 /// Whether the rate-limit cookie should carry the Secure flag.
12 pub cookie_secure: bool,
5 pub default_limit: u64,
6 pub window_secs: i64,
7 pub cookie_name: String,
8 pub cookie_secure: bool,
139}
1410
1511impl Default for RateLimitConfig {
Modified lib/rate_limit/lib.rs lib/rate_limit/lib.rs +1 −0 View lib/rate_limit/lib.rs
@@ -1,5 +1,6 @@
11pub mod blacklist;
22pub mod config;
3pub mod metrics;
34pub mod middleware;
45
56pub use blacklist::AccessTokenBlacklist;
Added lib/rate_limit/metrics.rs lib/rate_limit/metrics.rs +36 −0 View lib/rate_limit/metrics.rs
@@ -0,0 +1,36 @@
1use std::sync::Once;
2
3use cache::redis::RedisError;
4use metrics::{counter, describe_counter};
5
6pub const OUTCOME_SUCCESS: &str = "success";
7pub const OUTCOME_ALLOWED: &str = "allowed";
8pub const OUTCOME_THROTTLED: &str = "throttled";
9
10static DESCRIBE_ONCE: Once = Once::new();
11
12pub fn ensure_described() {
13 DESCRIBE_ONCE.call_once(describe_all);
14}
15
16fn describe_all() {
17 describe_counter!(
18 "rate_limit_requests_total",
19 "Rate-limit middleware decisions partitioned by outcome (allowed, throttled)."
20 );
21 describe_counter!(
22 "rate_limit_blacklist_ops_total",
23 "Access-token blacklist operations partitioned by op and outcome (success, error)."
24 );
25}
26
27pub(crate) fn record_request(outcome: &'static str) {
28 ensure_described();
29 counter!("rate_limit_requests_total", "outcome" => outcome).increment(1);
30}
31
32pub(crate) fn record_blacklist_op<T>(op: &'static str, result: &Result<T, RedisError>) {
33 ensure_described();
34 let outcome = if result.is_ok() { OUTCOME_SUCCESS } else { "error" };
35 counter!("rate_limit_blacklist_ops_total", "op" => op, "outcome" => outcome).increment(1);
36}
No newline at end of file
Modified lib/rate_limit/middleware.rs lib/rate_limit/middleware.rs +3 −15 View lib/rate_limit/middleware.rs
@@ -13,25 +13,18 @@
1313use uuid::Uuid;
1414
1515use crate::config::RateLimitConfig;
16use crate::metrics;
1617
1718pub const X_RATE_LIMIT_LIMIT: &str = "x-ratelimit-limit";
1819pub const X_RATE_LIMIT_REMAINING: &str = "x-ratelimit-remaining";
1920pub const X_RATE_LIMIT_RESET: &str = "x-ratelimit-reset";
2021pub const RETRY_AFTER: &str = "retry-after";
2122
22/// Supplies the Redis handle and settings used by the rate-limit middleware.
23///
24/// Implemented by the domain services (e.g. `AuthService`) so the middleware
25/// can run with `from_fn_with_state` against their router state while keeping
26/// an explicit, per-API partition key derived from the request path.
2723pub trait RateLimitProvider: Send + Sync + Clone + 'static {
2824 fn rate_limit_kv(&self) -> Arc<RedisCache>;
2925 fn rate_limit_settings(&self) -> Arc<RateLimitConfig>;
3026}
3127
32/// The resolved rate-limit client id (the `rl_id` cookie value) for the
33/// current request. Injected into request extensions by [`ratelimit`] so
34/// handlers can bind issued PoW challenges to the same client.
3528#[derive(Debug, Clone)]
3629pub struct ClientId(pub String);
3730
@@ -58,13 +51,6 @@
5851 }
5952}
6053
61/// Sliding-window rate-limit middleware.
62///
63/// Redis ZSET layout: `rate:{api}:{rl_id}` where `api` is the request path
64/// (per-API isolation, no global limit) and `rl_id` is the dedicated
65/// rate-limit cookie value. ZSET member = `{request_id}:{url}`, score =
66/// `unix_seconds(req_time)`. Entries older than `window_secs` are pruned
67/// before counting, so only requests within the last window are considered.
6854pub async fn ratelimit<S: RateLimitProvider>(
6955 State(svc): State<S>,
7056 mut request: Request,
@@ -97,9 +83,11 @@
9783
9884 let mut response: Response;
9985 if count > settings.default_limit {
86 metrics::record_request(metrics::OUTCOME_THROTTLED);
10087 response = (StatusCode::TOO_MANY_REQUESTS, Json(json!({ "error": "rate limit exceeded" })))
10188 .into_response();
10289 } else {
90 metrics::record_request(metrics::OUTCOME_ALLOWED);
10391 request.extensions_mut().insert(ClientId(rl_id.clone()));
10492 response = next.run(request).await;
10593 }