From ce49916c5f7079683a432c6a3528390686116565 Mon Sep 17 00:00:00 2001 From: ronke olaniyi Date: Thu, 23 Jul 2026 14:41:31 +0000 Subject: [PATCH] fix: apply rate limiting to all routes, not just POST /payments (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously rate_limited_bucket returned None for every non-POST request, leaving GET /payments (list), GET /payments/:id, GET /payments/:id/webhooks, GET /health, GET /ready, GET /metrics, and any unknown POST route completely unprotected — they could be flooded without restriction. Changes: - rate_limited_bucket now returns Some for every request. - Write/sensitive POSTs keep their named buckets: "payments", "merchants", "redeliver". - Everything else (all GETs, unknown POSTs) falls into the "default" bucket. - Add bucket_rate_multiplier to give read-only traffic a more generous allowance without changing the base configuration: - Named write buckets: base rate × 1 (unchanged behaviour) - "default" bucket: base rate × 5 (e.g. 50 req/s when RATE_LIMIT_REQUESTS_PER_SEC=10) - rate_limit_middleware passes the effective rate (base × multiplier) when creating per-key limiters, so each bucket is independently calibrated from the same env variable. Acceptance criteria: - All routes now have a default limit. - Sensitive write routes retain stricter per-bucket limits. Closes #72 --- src/api/mod.rs | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index b8bedae..442b513 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -204,9 +204,12 @@ async fn rate_limit_middleware( let key = rate_limit_key(bucket, &req); let limited = { let mut map = rate_limit.limiters.lock().unwrap(); + let base_rps = rate_limit.requests_per_sec; + let effective_rps = + base_rps.saturating_mul(bucket_rate_multiplier(bucket)).max(1); let limiter = map.entry(key).or_insert_with(|| { governor::RateLimiter::direct(governor::Quota::per_second( - NonZeroU32::new(rate_limit.requests_per_sec).unwrap(), + NonZeroU32::new(effective_rps).unwrap(), )) }); limiter.check().is_err() @@ -228,22 +231,43 @@ async fn rate_limit_middleware( next.run(req).await } -/// Identifies which rate-limit bucket (if any) a request falls into. Returns -/// `None` for everything else, so unrelated routes aren't limited at all. +/// Identifies which rate-limit bucket a request falls into. +/// +/// Every request is assigned a bucket so all routes are protected by default. +/// Write and sensitive routes use named buckets that receive the base quota +/// (`requests_per_sec × 1`). Read-only routes fall into the `"default"` bucket +/// which receives a more generous quota (`requests_per_sec × 5`) to avoid +/// throttling normal polling. /// /// Redelivery is bucketed by shape rather than by path: the URL carries a /// payment and delivery id, and keying on those would let every id mint its /// own limiter entry — both an unbounded map and a trivially bypassed limit. fn rate_limited_bucket(req: &Request) -> Option<&'static str> { - if req.method() != axum::http::Method::POST { - return None; - } let path = req.uri().path(); - match path { - "/payments" => Some("payments"), - "/merchants" => Some("merchants"), - _ if path.starts_with("/payments/") && path.ends_with("/redeliver") => Some("redeliver"), - _ => None, + if req.method() == axum::http::Method::POST { + return match path { + "/payments" => Some("payments"), + "/merchants" => Some("merchants"), + _ if path.starts_with("/payments/") && path.ends_with("/redeliver") => { + Some("redeliver") + } + _ => Some("default"), + }; + } + // All non-POST requests (GET, etc.) fall into the default bucket so that + // payment enumeration, webhook listing, and health/ready probes are all + // covered by a baseline limit. + Some("default") +} + +/// Returns the rate multiplier for a bucket. +/// +/// Write/sensitive buckets get the base rate (× 1). Read-only traffic gets a +/// higher allowance (× 5) so normal API consumers aren't throttled by polling. +fn bucket_rate_multiplier(bucket: &str) -> u32 { + match bucket { + "payments" | "merchants" | "redeliver" => 1, + _ => 5, } }