Skip to content

feat:Implement Rate Limiting with Per-User/Per-Issuer Buckets and Met… - #35

Merged
MaryammAli merged 4 commits into
Proof-Stell:mainfrom
SheyeJDev:feat/Implement-Rate-Limiting-with-Per-User-Per-Issuer-Buckets-and-Metrics
Jun 27, 2026
Merged

feat:Implement Rate Limiting with Per-User/Per-Issuer Buckets and Met…#35
MaryammAli merged 4 commits into
Proof-Stell:mainfrom
SheyeJDev:feat/Implement-Rate-Limiting-with-Per-User-Per-Issuer-Buckets-and-Metrics

Conversation

@SheyeJDev

Copy link
Copy Markdown
Contributor

feat(rate-limit): two-tier per-issuer rate limiting with Prometheus observability

Problem

The existing rate limiter (MetricsRateLimiter in src/rate_limit.rs) is a single, unkeyed token bucket shared across every caller. This means:

  • A single high-traffic issuer can exhaust the global quota, starving all other callers for the remainder of the refill window.
  • No visibility into per-caller consumption. When the limit fires we know that it fired but not who caused it.
  • No Retry-After semantics. Callers that are rejected get an opaque governor::NotUntil guard with no standardised way to surface the wait time in an HTTP response.
  • No quota introspection. There is no way for a caller to check how many tokens they have left before they hit a wall.

This PR addresses all four gaps.


Solution overview

Replace the single global limiter with a two-tier architecture:

Incoming request
        │
        ▼
┌─────────────────────────┐  exhausted   ┌──────────────────────────────────┐
│   Tier 1 – Global       │─────────────►│  HTTP 429                        │
│   Shared token bucket   │              │  Retry-After: <N>                │
│   (all issuers share)   │              │  rate_limit_rejections_total      │
└────────────┬────────────┘              │    {issuer="..", tier="global"}   │
             │ ok                        └──────────────────────────────────┘
             ▼
┌─────────────────────────┐  exhausted   ┌──────────────────────────────────┐
│   Tier 2 – Per-Issuer   │─────────────►│  HTTP 429                        │
│   DashMap-keyed bucket  │              │  Retry-After: <N>                │
│   (one per address)     │              │  rate_limit_rejections_total      │
└────────────┬────────────┘              │    {issuer="..", tier="issuer"}   │
             │ ok                        └──────────────────────────────────┘
             ▼
       handler logic
       rate_limit_hits_total{issuer=".."}

The global tier preserves the existing worst-case throughput cap. The per-issuer tier adds fairness. Both must pass before a request proceeds.


Files changed

src/rate_limit.rs — complete rewrite

New public surface:

// Two-tier limiter — the primary new type
pub struct PerIssuerRateLimiter { … }

impl PerIssuerRateLimiter {
pub fn new(config: RateLimitConfig, metrics: Option<Arc<MetricsRegistry>>) -> Self;
pub fn from_config(cfg: &AppConfig, metrics: Option<Arc<MetricsRegistry>>) -> Self;

/// Non-blocking check. Returns Ok(()) or a typed error with retry timing.
pub fn check(&amp;self, issuer: &amp;str) -&gt; Result&lt;(), RateLimitError&gt;;

/// Async blocking wait until both tiers permit the request.
pub async fn until_ready(&amp;self, issuer: &amp;str);

/// Returns remaining quota + reset timestamp without consuming a token.
pub fn status(&amp;self, issuer: &amp;str) -&gt; RateLimitStatus;

/// Evicts stale issuer entries (call from a background task).
pub fn evict_stale(&amp;self);

/// Number of issuers currently tracked in the metadata map.
pub fn tracked_issuers(&amp;self) -&gt; usize;

}

// Typed error — carries retry timing for Retry-After header
pub enum RateLimitError {
GlobalExhausted { retry_after: Duration },
IssuerExhausted { issuer: String, retry_after: Duration },
}

impl RateLimitError {
pub fn retry_after_secs(&self) -> u64; // for Retry-After header
pub fn reason(&self) -> &'static str; // for HTTP 429 body
}

// Quota snapshot returned by status()
pub struct RateLimitStatus {
pub issuer: String,
pub remaining: u32,
pub reset_at: u64, // Unix timestamp when bucket fully refills
pub global_throttled: bool,
}

// Configuration — fed from AppConfig or constructed directly in tests
pub struct RateLimitConfig {
pub global_per_second: u32,
pub global_burst: u32,
pub per_issuer_per_second: u32,
pub per_issuer_burst: u32,
pub issuer_ttl_seconds: u64,
}

Key design decisions:

  1. Global tier is checked before per-issuer. If the global bucket is saturated the per-issuer check_key call is skipped entirely, so per-issuer remaining counters are not decremented by globally-blocked traffic. This keeps status() readings accurate.

  2. DashMap for zero-lock-contention concurrent access. Governor's RateLimiter::keyed() uses a DefaultKeyedStateStore<String> (internally a DashMap) — one sharded cell per issuer address. A separate DashMap<String, IssuerEntry> tracks last-seen timestamps and approximate remaining-token counts for the status() API without adding a Mutex.

  3. RateLimitError is a proper algebraic type. Downstream HTTP middleware can match on the variant, extract retry_after_secs(), and construct a standards-compliant Retry-After response header without any timestamp arithmetic of its own.

  4. evict_stale() bounds memory in long-running deployments. Issuer entries whose last_seen is older than issuer_ttl_seconds are removed. This should be called by a periodic tokio task (e.g. every 10 minutes). Without eviction, a deployment that sees millions of unique issuer addresses would leak memory indefinitely.

  5. Legacy API retained for backward compatibility. build_rate_limiter() and the DefaultRateLimiter type alias are kept intact. The MetricsRateLimiter wrapper remains but is superseded; it can be removed in a follow-up once all call-sites have migrated.

How to wire into an Axum middleware (follow-up PR):

async fn rate_limit_middleware(
    State(limiter): State<Arc<PerIssuerRateLimiter>>,
    issuer: IssuerExtractor,   // extracts Stellar address from JWT / path
    req: Request,
    next: Next,
) -> Response {
    match limiter.check(&issuer.address) {
        Ok(()) => next.run(req).await,
        Err(e) => (
            StatusCode::TOO_MANY_REQUESTS,
            [(RETRY_AFTER, e.retry_after_secs().to_string())],
            e.reason(),
        ).into_response(),
    }
}

How to surface the status endpoint (follow-up PR):

// GET /v1/rate-limit/:issuer
async fn rate_limit_status(
    State(limiter): State<Arc<PerIssuerRateLimiter>>,
    Path(issuer): Path<String>,
) -> Json<RateLimitStatus> {
    Json(limiter.status(&issuer))
}

src/metrics.rs — new per-issuer counter family

Two new IntCounterVec metrics are introduced alongside the existing counters:

Metric Labels Description
rate_limit_hits_total issuer Every request that passed both rate-limit tiers
rate_limit_rejections_total issuer, tier Every rejected request; tier is "global" or "issuer"

Dependency change

# Cargo.toml
[dependencies]
dashmap = "5"

governor exposes RateLimiter::keyed() and DefaultKeyedStateStore behind its dashmap Cargo feature on some published versions. Verify against the pinned version in Cargo.lock:

governor = { version = "0.x", features = ["dashmap"] }

Distributed consistency (follow-up)

Per-issuer state is currently in-process only (DashMap). In a single-replica deployment this is sufficient. For multi-replica (horizontal scale-out), per-issuer counts will drift between replicas and a single issuer could effectively multiply their quota by the replica count.

The fix is to back the keyed state store with Redis using the existing cache layer. The PerIssuerRateLimiter struct is designed for this: swap DefaultKeyedStateStore<String> for a custom RedisKeyedStateStore that implements the governor::state::StateStore trait. The public API (check, until_ready, status) remains unchanged. Tracked as a follow-up issue.


Checklist

  • Two-tier rate limiter (PerIssuerRateLimiter) — global + per-issuer
  • Global tier enforced first; per-issuer check skipped on global saturation
  • Typed RateLimitError with retry_after_secs() and reason() for HTTP 429
  • status(issuer)RateLimitStatus (remaining quota, reset timestamp)
  • evict_stale() for bounded memory in long-running deployments
  • rate_limit_hits_total{issuer} and rate_limit_rejections_total{issuer,tier} metrics
  • Three new env vars with validation, defaults, and cross-field guard
  • AppConfig::Debug extended (no new secrets, no redaction needed)
  • Legacy build_rate_limiter, rate_limit_tokens_consumed_total, rate_limit_violations_total retained
  • 12 new unit tests in rate_limit.rs; 3 new/updated in metrics.rs; 3 new/updated in config.rs
  • dashmap = "5" added to Cargo.toml
  • Axum middleware integration (follow-up)
  • GET /v1/rate-limit/:issuer status endpoint (follow-up)
  • Redis-backed StateStore for distributed consistency (follow-up)
  • API documentation / OpenAPI spec update (follow-up)
  • Grafana dashboard panels for new metrics (follow-up)

Closes #12

@MaryammAli

Copy link
Copy Markdown
Contributor

@SheyeJDev
run cargo test to fix errors

@SheyeJDev

Copy link
Copy Markdown
Contributor Author

Alrght

On it

@SheyeJDev

Copy link
Copy Markdown
Contributor Author

Build issues resolved

T for thanks

@MaryammAli MaryammAli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
thanks for ur contribution @SheyeJDev

@MaryammAli
MaryammAli merged commit fe84266 into Proof-Stell:main Jun 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Rate Limiting with Per-User/Per-Issuer Buckets and Metrics

4 participants