use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use axum::extract::{ConnectInfo, Request};
use axum::http::{HeaderValue, Method, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use dashmap::mapref::entry::Entry;
use dashmap::{DashMap, DashSet};
use governor::clock::{Clock, DefaultClock};
use governor::middleware::StateInformationMiddleware;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::{Quota, RateLimiter};
use opentelemetry::KeyValue;
use opentelemetry::metrics::Counter;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio_util::sync::CancellationToken;
use toolkit::api::{OperationSpec, ThrottlingSpec};
use toolkit_canonical_errors::CanonicalError;
use toolkit_security::SecurityContext;
use crate::config::{ApiGatewayConfig, InFlightLimitZone, KeyType, RateLimitZone, RetryAfter};
use crate::middleware::common;
use crate::middleware::errors::ApiGatewayGatewayError;
type ThrottleKey = (Method, String);
const DEFAULT_IN_FLIGHT_RETRY_AFTER_SECS: u64 = 5;
const KEY_PRUNE_INTERVAL: Duration = Duration::from_secs(10);
type KeyedRateLimiter =
RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock, StateInformationMiddleware>;
struct RateZone {
name: String,
cfg: RateLimitZone,
limiter: KeyedRateLimiter,
policy: HeaderValue,
admitted: DashSet<String>,
admitted_len: AtomicU64,
}
impl RateZone {
fn admit(&self, key: &str) -> bool {
if self.admitted.contains(key) {
return true;
}
if self.admitted_len.load(Ordering::Relaxed) >= self.cfg.max_keys {
return false;
}
if self.admitted.insert(key.to_owned()) {
self.admitted_len.fetch_add(1, Ordering::Relaxed);
}
true
}
fn reset_admitted(&self) {
self.admitted.clear();
self.admitted.shrink_to_fit();
self.admitted_len.store(0, Ordering::Relaxed);
}
}
struct KeyGate {
inflight: Arc<Semaphore>,
backlog: Arc<Semaphore>,
}
impl KeyGate {
async fn acquire(&self, backlog_timeout: Duration) -> Option<OwnedSemaphorePermit> {
if let Ok(permit) = Arc::clone(&self.inflight).try_acquire_owned() {
return Some(permit);
}
let _backlog_slot = Arc::clone(&self.backlog).try_acquire_owned().ok()?;
if let Ok(Ok(permit)) =
tokio::time::timeout(backlog_timeout, Arc::clone(&self.inflight).acquire_owned()).await
{
Some(permit)
} else {
None
}
}
fn try_acquire(&self) -> Option<OwnedSemaphorePermit> {
Arc::clone(&self.inflight).try_acquire_owned().ok()
}
}
struct InFlightZone {
name: String,
cfg: InFlightLimitZone,
keys: DashMap<String, Arc<KeyGate>>,
tracked: AtomicU64,
excluded: HashSet<String>,
}
impl InFlightZone {
fn gate(&self, key: &str) -> Option<Arc<KeyGate>> {
if let Some(existing) = self.keys.get(key) {
return Some(Arc::clone(&existing));
}
if self.tracked.load(Ordering::Relaxed) >= self.cfg.max_keys {
return None;
}
let gate = match self.keys.entry(key.to_owned()) {
Entry::Occupied(e) => Arc::clone(e.get()),
Entry::Vacant(v) => {
self.tracked.fetch_add(1, Ordering::Relaxed);
Arc::clone(&v.insert(Arc::new(KeyGate {
inflight: Arc::new(Semaphore::new(self.cfg.in_flight_limit as usize)),
backlog: Arc::new(Semaphore::new(self.cfg.backlog_limit as usize)),
})))
}
};
Some(gate)
}
fn prune_idle_keys(&self) {
if self.tracked.load(Ordering::Relaxed) >= self.cfg.max_keys {
self.keys.retain(|_, v| Arc::strong_count(v) > 1);
self.tracked
.store(self.keys.len() as u64, Ordering::Relaxed);
}
}
}
struct ThrottlingEntry {
spec: ThrottlingSpec,
rate_zone: Option<Arc<RateZone>>,
inflight_zone: Option<Arc<InFlightZone>>,
}
#[derive(Default)]
struct ThrottlingInner {
routes: HashMap<ThrottleKey, ThrottlingEntry>,
trusted_proxy_hops: usize,
rejections: Option<Counter<u64>>,
dry_run: Option<Counter<u64>>,
}
#[derive(Clone, Default)]
pub struct ThrottlingMap {
inner: Arc<ThrottlingInner>,
}
#[derive(Clone, Default)]
pub struct ThrottlingMapNoAuth {
inner: Arc<ThrottlingInner>,
}
impl ThrottlingMap {
#[cfg(test)]
fn from_specs(specs: &[OperationSpec], cfg: &ApiGatewayConfig) -> Result<Self> {
let mut rate_zones = HashMap::new();
let mut inflight_zones = HashMap::new();
Ok(Self {
inner: Arc::new(build(
specs,
cfg,
true,
&mut rate_zones,
&mut inflight_zones,
)?),
})
}
}
impl ThrottlingMapNoAuth {
#[cfg(test)]
fn from_specs(specs: &[OperationSpec], cfg: &ApiGatewayConfig) -> Result<Self> {
let mut rate_zones = HashMap::new();
let mut inflight_zones = HashMap::new();
Ok(Self {
inner: Arc::new(build(
specs,
cfg,
false,
&mut rate_zones,
&mut inflight_zones,
)?),
})
}
}
pub fn build_maps(
specs: &[OperationSpec],
cfg: &ApiGatewayConfig,
) -> Result<(ThrottlingMap, ThrottlingMapNoAuth, ThrottleKeyPruner)> {
check_dry_run_consistency(specs)?;
let mut rate_zones: HashMap<String, Arc<RateZone>> = HashMap::new();
let mut inflight_zones: HashMap<String, Arc<InFlightZone>> = HashMap::new();
let auth = build(specs, cfg, true, &mut rate_zones, &mut inflight_zones)?;
let noauth = build(specs, cfg, false, &mut rate_zones, &mut inflight_zones)?;
let pruner = ThrottleKeyPruner {
rate_zones: rate_zones.into_values().collect(),
inflight_zones: inflight_zones.into_values().collect(),
};
Ok((
ThrottlingMap {
inner: Arc::new(auth),
},
ThrottlingMapNoAuth {
inner: Arc::new(noauth),
},
pruner,
))
}
#[derive(Clone)]
pub struct ThrottleKeyPruner {
rate_zones: Vec<Arc<RateZone>>,
inflight_zones: Vec<Arc<InFlightZone>>,
}
impl ThrottleKeyPruner {
#[must_use]
pub fn spawn(self, cancel: CancellationToken) -> Option<tokio::task::JoinHandle<()>> {
if self.rate_zones.is_empty() && self.inflight_zones.is_empty() {
return None;
}
Some(tokio::spawn(async move {
let mut ticker = tokio::time::interval(KEY_PRUNE_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
ticker.tick().await;
loop {
tokio::select! {
() = cancel.cancelled() => break,
_ = ticker.tick() => {
for zone in &self.rate_zones {
zone.limiter.retain_recent();
zone.limiter.shrink_to_fit();
zone.reset_admitted();
}
for zone in &self.inflight_zones {
zone.prune_idle_keys();
}
}
}
}
}))
}
}
fn check_dry_run_consistency(specs: &[OperationSpec]) -> Result<()> {
let mut seen: HashMap<(&str, &str), (bool, &OperationSpec)> = HashMap::new();
for spec in specs {
let Some(thr) = spec.throttling.as_ref() else {
continue;
};
let bindings = [
("rate_limit", thr.rate_limit_zone.as_deref()),
("in_flight_limit", thr.in_flight_limit_zone.as_deref()),
];
for (kind, zone) in bindings {
let Some(zone) = zone else { continue };
match seen.get(&(kind, zone)) {
Some((dry_run, first)) if *dry_run != thr.dry_run => bail!(
"throttling: {kind} zone '{zone}' is bound with dry_run={} by {} {} and \
dry_run={} by {} {}; a zone must be all dry-run or all enforced",
dry_run,
first.method,
first.path,
thr.dry_run,
spec.method,
spec.path
),
Some(_) => {}
None => {
seen.insert((kind, zone), (thr.dry_run, spec));
}
}
}
}
Ok(())
}
fn build(
specs: &[OperationSpec],
cfg: &ApiGatewayConfig,
require_ctx: bool,
rate_zones: &mut HashMap<String, Arc<RateZone>>,
inflight_zones: &mut HashMap<String, Arc<InFlightZone>>,
) -> Result<ThrottlingInner> {
let mut routes: HashMap<ThrottleKey, ThrottlingEntry> = HashMap::new();
for spec in specs {
let Some(thr) = spec.throttling.as_ref() else {
continue;
};
if thr.require_security_context != require_ctx {
continue;
}
let rate_zone = if let Some(zone_name) = thr.rate_limit_zone.as_deref() {
let zcfg = cfg.rate_limit_zones.get(zone_name).ok_or_else(|| {
anyhow!(
"throttling: operation {} {} references undefined rate_limit zone '{}'",
spec.method,
spec.path,
zone_name
)
})?;
check_key_type(spec, cfg, require_ctx, zone_name, zcfg.key.key_type)?;
Some(get_or_build_rate_zone(rate_zones, zone_name, zcfg)?)
} else {
None
};
let inflight_zone = if let Some(zone_name) = thr.in_flight_limit_zone.as_deref() {
let zcfg = cfg.in_flight_limit_zones.get(zone_name).ok_or_else(|| {
anyhow!(
"throttling: operation {} {} references undefined in_flight_limit zone '{}'",
spec.method,
spec.path,
zone_name
)
})?;
check_key_type(spec, cfg, require_ctx, zone_name, zcfg.key.key_type)?;
Some(get_or_build_inflight_zone(inflight_zones, zone_name, zcfg))
} else {
None
};
let key = (spec.method.clone(), spec.path.clone());
routes.insert(
key,
ThrottlingEntry {
spec: thr.clone(),
rate_zone,
inflight_zone,
},
);
}
Ok(ThrottlingInner {
routes,
trusted_proxy_hops: cfg.trusted_proxy_hops,
rejections: Some(build_counter(
cfg,
"throttling.rejections",
"Number of requests rejected by enforced throttling (429)",
)),
dry_run: Some(build_counter(
cfg,
"throttling.dry_run_rejections",
"Number of requests that exceeded a throttling limit but were served (dry-run)",
)),
})
}
fn build_counter(cfg: &ApiGatewayConfig, suffix: &str, description: &str) -> Counter<u64> {
let prefix = cfg.metrics.prefix.trim().trim_end_matches('.');
let name = if prefix.is_empty() {
suffix.to_owned()
} else {
format!("{prefix}.{suffix}")
};
let scope = opentelemetry::InstrumentationScope::builder("api-gateway").build();
let meter = opentelemetry::global::meter_with_scope(scope);
meter
.u64_counter(name)
.with_description(description.to_owned())
.build()
}
fn check_key_type(
spec: &OperationSpec,
cfg: &ApiGatewayConfig,
require_ctx: bool,
zone: &str,
kt: KeyType,
) -> Result<()> {
if !matches!(kt, KeyType::Identity) {
return Ok(());
}
if !require_ctx {
bail!(
"throttling: zone '{zone}' is identity-keyed but is referenced by a pre-auth \
(require_security_context=false) operation; identity keying requires authentication"
);
}
if !spec.authenticated {
bail!(
"throttling: zone '{zone}' is identity-keyed but operation {} {} allows anonymous \
access; every anonymous client would share one key",
spec.method,
spec.path
);
}
if cfg.auth_disabled {
bail!(
"throttling: zone '{zone}' is identity-keyed but auth_disabled=true; every request \
would share one key"
);
}
Ok(())
}
fn get_or_build_rate_zone(
zones: &mut HashMap<String, Arc<RateZone>>,
name: &str,
cfg: &RateLimitZone,
) -> Result<Arc<RateZone>> {
if let Some(existing) = zones.get(name) {
return Ok(Arc::clone(existing));
}
let rps = NonZeroU32::new(cfg.rate_limit.rps)
.ok_or_else(|| anyhow!("throttling: rate_limit zone '{name}' has rps = 0"))?;
let burst = NonZeroU32::new(cfg.burst_limit)
.ok_or_else(|| anyhow!("throttling: rate_limit zone '{name}' has burst_limit = 0"))?;
let limiter = RateLimiter::keyed(Quota::per_second(rps).allow_burst(burst))
.with_middleware::<StateInformationMiddleware>();
let policy = HeaderValue::from_str(&format!(
"\"burst\";q={};w={}",
cfg.burst_limit, cfg.rate_limit.rps
))
.context("throttling: failed to build RateLimit-Policy header")?;
let zone = Arc::new(RateZone {
name: name.to_owned(),
cfg: cfg.clone(),
limiter,
policy,
admitted: DashSet::new(),
admitted_len: AtomicU64::new(0),
});
zones.insert(name.to_owned(), Arc::clone(&zone));
Ok(zone)
}
fn get_or_build_inflight_zone(
zones: &mut HashMap<String, Arc<InFlightZone>>,
name: &str,
cfg: &InFlightLimitZone,
) -> Arc<InFlightZone> {
if let Some(existing) = zones.get(name) {
return Arc::clone(existing);
}
let zone = Arc::new(InFlightZone {
name: name.to_owned(),
cfg: cfg.clone(),
keys: DashMap::new(),
tracked: AtomicU64::new(0),
excluded: cfg.excluded_keys.iter().cloned().collect(),
});
zones.insert(name.to_owned(), Arc::clone(&zone));
zone
}
pub async fn throttling_middleware(map: ThrottlingMap, req: Request, next: Next) -> Response {
enforce(&map.inner, req, next).await
}
pub async fn throttling_no_auth_middleware(
map: ThrottlingMapNoAuth,
req: Request,
next: Next,
) -> Response {
enforce(&map.inner, req, next).await
}
async fn enforce(inner: &ThrottlingInner, req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req
.extensions()
.get::<axum::extract::MatchedPath>()
.map_or_else(|| req.uri().path().to_owned(), |p| p.as_str().to_owned());
let path = common::resolve_path(&req, path.as_str());
let key = (method, path);
let Some(entry) = inner.routes.get(&key) else {
return next.run(req).await;
};
let mut rate_headers: Option<RateHeaders> = None;
if let Some(zone) = entry.rate_zone.as_ref() {
let Some(id) = compute_key(zone.cfg.key.key_type, &req, inner.trusted_proxy_hops) else {
return missing_context_response(&key, &zone.name);
};
if zone.admit(&id) {
match zone.limiter.check_key(&id) {
Ok(snapshot) => {
rate_headers = Some(RateHeaders {
policy: zone.policy.clone(),
burst: HeaderValue::from(zone.cfg.burst_limit),
remaining: HeaderValue::from(snapshot.remaining_burst_capacity()),
});
}
Err(not_until) => {
if entry.spec.dry_run {
record_dry_run(inner, &key, &zone.name, "rate_limit", &id);
} else {
let wait = not_until.wait_time_from(zone.limiter.clock().now());
let wait = wait.as_secs() + u64::from(wait.subsec_nanos() > 0);
let retry_after = match zone.cfg.response_retry_after {
RetryAfter::Auto => Some(wait),
RetryAfter::Seconds(n) => Some(n),
};
record_rejection(inner, &key, &zone.name, "rate_limit", &id);
return throttle_response(
zone.cfg.response_status_code,
retry_after,
Some((&zone.policy, zone.cfg.burst_limit)),
"rate_limit",
);
}
}
}
} else if entry.spec.dry_run {
record_dry_run(inner, &key, &zone.name, "max_keys", &id);
} else {
record_rejection(inner, &key, &zone.name, "max_keys", &id);
return throttle_response(
zone.cfg.response_status_code,
Some(KEY_PRUNE_INTERVAL.as_secs()),
Some((&zone.policy, zone.cfg.burst_limit)),
"max_keys",
);
}
}
if let Some(zone) = entry.inflight_zone.as_ref() {
let Some(id) = compute_key(zone.cfg.key.key_type, &req, inner.trusted_proxy_hops) else {
return missing_context_response(&key, &zone.name);
};
if !zone.excluded.contains(&id) {
let Some(gate) = zone.gate(&id) else {
if entry.spec.dry_run {
record_dry_run(inner, &key, &zone.name, "max_keys", &id);
let mut response = next.run(req).await;
apply_rate_headers(&mut response, rate_headers.as_ref());
return response;
}
record_rejection(inner, &key, &zone.name, "max_keys", &id);
return throttle_response(
zone.cfg.response_status_code,
Some(KEY_PRUNE_INTERVAL.as_secs()),
None,
"max_keys",
);
};
let permit = if entry.spec.dry_run {
gate.try_acquire()
} else {
gate.acquire(zone.cfg.backlog_timeout).await
};
let Some(permit) = permit else {
if entry.spec.dry_run {
record_dry_run(inner, &key, &zone.name, "in_flight", &id);
let mut response = next.run(req).await;
apply_rate_headers(&mut response, rate_headers.as_ref());
return response;
}
record_rejection(inner, &key, &zone.name, "in_flight", &id);
let retry_after = zone
.cfg
.backlog_timeout
.as_secs()
.max(DEFAULT_IN_FLIGHT_RETRY_AFTER_SECS);
return throttle_response(
zone.cfg.response_status_code,
Some(retry_after),
None,
"in_flight",
);
};
let mut response = next.run(req).await;
drop(permit);
apply_rate_headers(&mut response, rate_headers.as_ref());
return response;
}
}
let mut response = next.run(req).await;
apply_rate_headers(&mut response, rate_headers.as_ref());
response
}
struct RateHeaders {
policy: HeaderValue,
burst: HeaderValue,
remaining: HeaderValue,
}
fn apply_rate_headers(response: &mut Response, rate_headers: Option<&RateHeaders>) {
let Some(h) = rate_headers else {
return;
};
let headers = response.headers_mut();
headers.insert("RateLimit-Policy", h.policy.clone());
headers.insert("RateLimit-Limit", h.burst.clone());
headers.insert("RateLimit-Remaining", h.remaining.clone());
headers.insert("X-RateLimit-Limit", h.burst.clone());
headers.insert("X-RateLimit-Remaining", h.remaining.clone());
}
fn compute_key(kind: KeyType, req: &Request, trusted_proxy_hops: usize) -> Option<String> {
match kind {
KeyType::Ip => Some(client_ip(req, trusted_proxy_hops)),
KeyType::Identity => req
.extensions()
.get::<SecurityContext>()
.map(|sc| sc.subject_id().to_string()),
}
}
fn missing_context_response(key: &ThrottleKey, zone: &str) -> Response {
tracing::error!(
method = %key.0,
path = %key.1,
zone,
"throttling: identity-keyed zone reached without a security context"
);
CanonicalError::internal("throttling: identity-keyed zone without security context")
.create()
.into_response()
}
fn client_ip(req: &Request, trusted_proxy_hops: usize) -> String {
if trusted_proxy_hops == 0 {
return peer_ip(req);
}
let headers = req.headers();
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
let entries: Vec<&str> = xff
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if let Some(idx) = entries.len().checked_sub(trusted_proxy_hops)
&& let Some(candidate) = entries.get(idx)
&& let Ok(ip) = candidate.parse::<IpAddr>()
{
return ip.to_string();
}
}
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<IpAddr>().ok())
{
return ip.to_string();
}
peer_ip(req)
}
fn peer_ip(req: &Request) -> String {
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map_or_else(|| "unknown".to_owned(), |ci| ci.0.ip().to_string())
}
fn throttle_response(
status: u16,
retry_after_seconds: Option<u64>,
rate_headers: Option<(&HeaderValue, u32)>,
kind: &str,
) -> Response {
let err =
ApiGatewayGatewayError::resource_exhausted(format!("throttling limit exceeded ({kind})"))
.with_quota_violation("throttling", format!("{kind} limit exceeded"));
let err = match retry_after_seconds {
Some(secs) => err.with_quota_violation_retry_after_seconds(secs).create(),
None => err.create(),
};
let mut response = err.into_response();
if let Ok(code) = StatusCode::from_u16(status) {
*response.status_mut() = code;
}
let headers = response.headers_mut();
if let Some((policy, burst_limit)) = rate_headers {
let burst = HeaderValue::from(burst_limit);
headers.insert("RateLimit-Policy", policy.clone());
headers.insert("RateLimit-Limit", burst.clone());
headers.insert("X-RateLimit-Limit", burst);
}
if let Some(secs) = retry_after_seconds
&& let Ok(value) = HeaderValue::from_str(&secs.to_string())
{
headers.insert(header::RETRY_AFTER, value);
}
response
}
fn record_rejection(inner: &ThrottlingInner, key: &ThrottleKey, zone: &str, kind: &str, id: &str) {
if let Some(counter) = inner.rejections.as_ref() {
counter.add(
1,
&[
KeyValue::new("zone", zone.to_owned()),
KeyValue::new("kind", kind.to_owned()),
],
);
}
tracing::info!(
method = %key.0,
path = %key.1,
kind,
zone,
key = %id,
"throttling limit exceeded"
);
}
fn record_dry_run(inner: &ThrottlingInner, key: &ThrottleKey, zone: &str, kind: &str, id: &str) {
if let Some(counter) = inner.dry_run.as_ref() {
counter.add(
1,
&[
KeyValue::new("zone", zone.to_owned()),
KeyValue::new("kind", kind.to_owned()),
],
);
}
tracing::warn!(
method = %key.0,
path = %key.1,
kind,
zone,
key = %id,
"throttling limit exceeded, serving because of dry-run mode"
);
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
#[path = "throttling_tests.rs"]
mod tests;