pub struct RateLimitInterceptor { /* private fields */ }Expand description
A fixed-window rate limiting ServerInterceptor.
Tracks request counts per caller key using a simple fixed-window counter. When the limit is exceeded, rejects the request with an A2A error.
Caller keys are derived in this order:
CallContext::caller_identity(set by auth interceptors — register them before this interceptor, or it runs first and sees none)- Client IP from
x-forwarded-for, only whenRateLimitConfig::trusted_proxy_hopsis non-zero "anonymous"fallback (shared bucket)
Implementations§
Source§impl RateLimitInterceptor
impl RateLimitInterceptor
Sourcepub fn new(config: RateLimitConfig) -> ServerResult<Self>
pub fn new(config: RateLimitConfig) -> ServerResult<Self>
Creates a new rate limiter with the given configuration.
§Errors
Returns ServerError::InvalidParams if requests_per_window,
window_secs, or max_buckets is zero. A zero window would divide by
zero on every request; a zero limit or bucket cap would reject all
requests.
Counts against a deployment-wide counter instead of this process’s map.
Without this, each replica enforces the configured limit on its own, so
N replicas admit N times it — tests/multi_replica.rs measures two
limiters configured for 5 requests per window admitting 10. With it,
every replica increments the same counter and the limit is the
deployment’s.
§What it costs
A round trip to the counter on every request, where the local path
takes a RwLock. It is not a small difference and it should not be
buried — measured on loopback, release build, best of three runs of
2,000 requests:
| counter | per request |
|---|---|
| in-process (the default) | 0.2us |
PostgresRateLimitCounter (the postgres feature) | 232us (231-239 across runs) |
| the same on a durable pool | 598us |
Three orders of magnitude, and on loopback — a counter across a real network costs whatever that network costs. For scale, a whole JSON-RPC request through this server’s own stack measures ~195us on the same machine, so a shared counter roughly doubles the cost of a request.
That is why this is opt-in rather than the default: a single-replica
deployment gains nothing from it and should not pay it. It is also why
a deployment that needs both a global limit and the last microsecond
should implement RateLimitCounter against an in-memory keyspace —
the trait exists so that is a few lines rather than a fork.
§When the counter is unreachable
The request is counted locally instead, and admitted or rejected on that basis. The failure mode is therefore exactly the behaviour without this method — per-process limiting — rather than an outage or an open door.
Both alternatives are worse in ways worth naming. Failing closed turns a counter blip into a total refusal of service, which makes adding a shared limiter a reliability regression. Failing open removes the limit entirely at the moment an attacker who can reach the database has most to gain from that. Degrading to local counting keeps a real limit in force — the wrong one, by a factor of the replica count, but the same wrong one the deployment ran before it adopted this.
Sourcepub fn with_tenant_config(self, config: PerTenantConfig) -> Self
pub fn with_tenant_config(self, config: PerTenantConfig) -> Self
Enforces TenantLimits::rate_limit_rps alongside the caller limit.
§Two scopes, one limiter
The caller limit and the tenant limit answer different questions — is
this client sending too fast and is this customer using more than
they bought — so a request is counted against both and must pass both.
They share this interceptor’s window, bucket map and
max_buckets budget: a tenant bucket is
an ordinary bucket keyed tenant:<id>, so a deployment with many
tenants should size max_buckets for callers plus tenants.
This is one limiter with two keys, not two limiters. A second limiter with its own window and its own map would let the two disagree about when a window starts, and a request refused by one and admitted by the other is a bug nobody can reproduce.
§The unit is not the same and is converted, not reinterpreted
TenantLimits::rate_limit_rps is documented in requests per
second; RateLimitConfig::requests_per_window is per window. The
tenant’s per-window allowance is therefore rate_limit_rps × window_secs, saturating. Treating the number as a drop-in replacement
for requests_per_window would silently mean something else at every
window length except one second.
A tenant whose rate_limit_rps is None — including the default
limits, for a tenant with no override — is not counted against any
tenant bucket at all, which is what “no tenant-level rate limit” says.
Trait Implementations§
Source§impl Debug for RateLimitInterceptor
impl Debug for RateLimitInterceptor
Source§impl ServerInterceptor for RateLimitInterceptor
impl ServerInterceptor for RateLimitInterceptor
Source§fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>>
fn before<'a>( &'a self, ctx: &'a CallContext, ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>>
impl UnwindSafe for RateLimitInterceptor
Auto Trait Implementations§
impl !Freeze for RateLimitInterceptor
impl !RefUnwindSafe for RateLimitInterceptor
impl Send for RateLimitInterceptor
impl Sync for RateLimitInterceptor
impl Unpin for RateLimitInterceptor
impl UnsafeUnpin for RateLimitInterceptor
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> FutureExt for T
impl<T> FutureExt for T
Source§fn with_context(self, otel_cx: Context) -> WithContext<Self> ⓘ
fn with_context(self, otel_cx: Context) -> WithContext<Self> ⓘ
Source§fn with_current_context(self) -> WithContext<Self> ⓘ
fn with_current_context(self) -> WithContext<Self> ⓘ
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§impl<T> IntoRequest<T> for T
impl<T> IntoRequest<T> for T
Source§fn into_request(self) -> Request<T>
fn into_request(self) -> Request<T>
T in a tonic::Request