use axum::http::HeaderMap;
#[cfg(not(target_arch = "wasm32"))]
use axum::http::HeaderName;
use crate::email;
use crate::ports::{Decision, RateLimiter};
use std::sync::Arc;
use std::time::Duration;
#[must_use]
pub fn client_ip(headers: &HeaderMap) -> Option<String> {
if let Some(ip) = headers
.get("cf-connecting-ip")
.and_then(|v| v.to_str().ok())
{
return Some(ip.trim().to_owned());
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(first) = headers
.get(HeaderName::from_static("x-forwarded-for"))
.and_then(|v| v.to_str().ok())
.and_then(|list| list.split(',').next())
.map(str::trim)
.filter(|first| !first.is_empty())
{
return Some(first.to_owned());
}
None
}
#[must_use]
pub fn rate_limit_keys(remote_ip: Option<&str>, email: Option<&str>) -> Vec<String> {
let mut keys = vec![format!("ip:{}", remote_ip.unwrap_or("unknown"))];
if let Some(email) = email {
keys.push(format!("email:{}", email::normalize(email)));
}
keys
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitFailure {
FailOpen,
FailClosed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RateLimit {
Allowed,
Denied { retry_after: Option<Duration> },
}
pub async fn check_rate_limit(
limiter: Option<&Arc<dyn RateLimiter>>,
keys: &[String],
on_failure: RateLimitFailure,
) -> RateLimit {
let Some(limiter) = limiter else {
return RateLimit::Allowed;
};
for key in keys {
match limiter.limit(key).await {
Ok(Decision { ok: true, .. }) => {}
Ok(Decision {
ok: false,
retry_after,
}) => {
return RateLimit::Denied { retry_after };
}
Err(err) => {
if on_failure == RateLimitFailure::FailClosed {
tracing::warn!(error = %err, key = %key, "rate limiter unavailable; denying");
return RateLimit::Denied { retry_after: None };
}
tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
}
}
}
RateLimit::Allowed
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::header;
#[test]
fn keys_are_ip_then_normalized_email() {
let keys = rate_limit_keys(Some("203.0.113.7"), Some(" Nick@Example.COM "));
assert_eq!(keys, ["ip:203.0.113.7", "email:nick@example.com"]);
}
#[test]
fn missing_ip_falls_back_to_unknown() {
assert_eq!(rate_limit_keys(None, None), ["ip:unknown"]);
}
#[test]
fn prefers_cf_connecting_ip() {
let mut headers = HeaderMap::new();
headers.insert(
"cf-connecting-ip",
header::HeaderValue::from_static("198.51.100.9"),
);
headers.insert(
header::HeaderName::from_static("x-forwarded-for"),
header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
);
assert_eq!(client_ip(&headers).as_deref(), Some("198.51.100.9"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_honors_forwarded_for_first_hop() {
let mut headers = HeaderMap::new();
headers.insert(
header::HeaderName::from_static("x-forwarded-for"),
header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
);
assert_eq!(client_ip(&headers).as_deref(), Some("1.2.3.4"));
}
struct StubLimiter {
ok: bool,
transport_error: bool,
}
#[async_trait::async_trait]
impl RateLimiter for StubLimiter {
async fn limit(&self, _key: &str) -> Result<Decision, crate::ports::RateLimitError> {
if self.transport_error {
return Err(crate::ports::RateLimitError::Transport("down".to_owned()));
}
Ok(Decision {
ok: self.ok,
retry_after: (!self.ok).then(|| Duration::from_secs(7)),
})
}
}
fn limiter(ok: bool, transport_error: bool) -> Arc<dyn RateLimiter> {
Arc::new(StubLimiter {
ok,
transport_error,
})
}
fn ip_key() -> Vec<String> {
vec!["ip:203.0.113.7".to_owned()]
}
#[pollster::test]
async fn all_keys_passing_allows() {
let keys = vec!["ip:203.0.113.7".to_owned(), "email:a@b.co".to_owned()];
let result = check_rate_limit(
Some(&limiter(true, false)),
&keys,
RateLimitFailure::FailClosed,
)
.await;
assert!(matches!(result, RateLimit::Allowed), "{result:?}");
}
#[pollster::test]
async fn a_denied_key_carries_its_retry_after() {
let result = check_rate_limit(
Some(&limiter(false, false)),
&ip_key(),
RateLimitFailure::FailClosed,
)
.await;
assert!(
matches!(&result, RateLimit::Denied { retry_after } if *retry_after == Some(Duration::from_secs(7))),
"{result:?}"
);
}
#[pollster::test]
async fn transport_errors_resolve_at_the_call_site() {
let open = check_rate_limit(
Some(&limiter(true, true)),
&ip_key(),
RateLimitFailure::FailOpen,
)
.await;
assert!(matches!(open, RateLimit::Allowed), "{open:?}");
let closed = check_rate_limit(
Some(&limiter(true, true)),
&ip_key(),
RateLimitFailure::FailClosed,
)
.await;
assert!(
matches!(&closed, RateLimit::Denied { retry_after: None }),
"{closed:?}"
);
}
#[pollster::test]
async fn no_limiter_is_a_composition_choice_not_a_failure() {
let result = check_rate_limit(None, &ip_key(), RateLimitFailure::FailClosed).await;
assert!(matches!(result, RateLimit::Allowed), "{result:?}");
}
}