Skip to content

Rate Limiting

Rate limiting runs at two layers: the ingress in front of every service, and inside each service through one shared module, github.com/Bengo-Hub/shared-ratelimit.

Ingress level

Every service's ingress carries nginx.ingress.kubernetes.io/limit-rps and limit-connections annotations (for example auth-api 20 rps / 100 connections). These counters live in each ingress-nginx pod, so they are a coarse first line against floods, not an exact limit. See DevOps-K8s Ingress & CORS for the per-service values.

Application level

Every Go service uses shared-ratelimit (v0.2+). There are no local limiters left in the fleet.

Algorithm: GCRA in Redis

Limiter uses GCRA (generic cell rate algorithm) through github.com/go-redis/redis_rate/v10. GCRA is the token bucket expressed as one timestamp per key: it allows a burst up to Burst requests after an idle period, then Limit per Window on average. The whole check-and-consume step is one Lua script that Redis runs atomically on its own clock, so every replica of a service shares one exact limit with one round trip and one small key per subject.

Algorithm Why it is not used
Fixed window INCR Allows twice the limit across a window edge. Still fine for daily quotas (see Quota).
Sliding window log (sorted set) Exact, but one entry per request and three round trips; v0.1 also checked and recorded in separate calls, so parallel requests on different pods all passed.
Sliding window counter Close approximation, but no burst allowance.
Leaky bucket as a queue Delays requests instead of answering 429, wrong for an API.

A concurrency test in the module runs 200 parallel requests through four limiter instances (four pods) sharing one Redis at a limit of 50 and asserts exactly 50 pass.

Client IP

ratelimit.ClientIP takes X-Real-IP, which ingress-nginx sets from CF-Connecting-IP (it only trusts that header from Cloudflare's ranges), then CF-Connecting-IP, then the peer address. X-Forwarded-For is never trusted: Cloudflare appends to whatever the client sent, so its first entry is attacker-controlled. Mount ratelimit.TrustedRealIP instead of chi's middleware.RealIP, which copies client-sent True-Client-IP/X-Forwarded-For into RemoteAddr.

Usage

limiter := ratelimit.NewLimiter(redisClient, log, "myservice")

// General per-IP limit. Mount it AFTER CORS so a 429 still carries CORS headers.
r.Use(limiter.Middleware(ratelimit.IPKey, 300, time.Minute))

// Login, PIN, OTP: per IP and per target identifier.
r.With(limiter.MiddlewareWith(ratelimit.CompositeKey(ratelimit.IPKey, accountKey),
    ratelimit.Options{Name: "login-ip-acct", Limit: 10, Window: 15 * time.Minute})).Post("/login", h)

// Outside HTTP (provider send budgets, workers):
ok, retryAfter := limiter.Allow(ctx, "smtp", ratelimit.Options{Name: "email-provider", Limit: 200, Window: time.Hour}, 1)

Tenant or user keys must come from verified claims (ratelimit.ValueKey), never a raw header: anyone can send X-Tenant-ID and spend another tenant's allowance.

Behavior

Topic Behavior
Headers X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; on 429 also Retry-After and {"error":"rate limit exceeded",...}.
Exempt by default WebSocket upgrades, Accept: text/event-stream, /healthz, /readyz, /livez, /health, /ready, /live, /metrics. Kubelet probes share a node IP and must never get a 429. Add more with Options.Skip (pos-api skips its payment-status and catalog-version polls).
Redis down Each pod falls back to an in-process bucket allowing Limit / ExpectedReplicas (default 2), bounded to 10,000 keys, so throttling continues without blocking real users.
Memory GCRA keys expire when the bucket refills. Redis runs allkeys-lru.

Daily quotas

Quota meters plan features per tenant per UTC day (limit from JWT claims). One Lua call increments, keeps the 25 hour TTL and rolls back on rejection. CheckN(n) is all-or-nothing for a batch, so a send to 20 recipients either fits today's quota or consumes nothing.

Brute-force protection

Flow Protection
auth-api login Per IP (60/min), per IP and account (10 per 15 min), per account across IPs (30/hour).
auth-api one-time codes (OTP, email verify) Atomic compare-and-consume; the 5th wrong guess destroys the code. Sends capped at 5 per 10 minutes.
pos-api, inventory-api PIN 8 per IP per minute on PIN routes; pos counts PIN failures with an SQL increment and locks the member.
library-api PIN and card identify 10 per IP per minute.
ticketing public tickets 5 per IP per hour, 10 per email per day.
marketflow-ai public chat 15 per IP and 100 per tenant per minute.

Non-Go services

TruLoad (.NET) uses ASP.NET's built-in rate limiter, per pod, behind the ingress limit. It runs after authentication so every policy partitions per signed-in user, or per client IP (X-Real-IP) for anonymous calls; the auth policy (per IP, no queue) covers login, 2FA, password and SSO endpoints. Limits come from the database settings and an admin reload applies at once.

ISPBilling (FastAPI) keeps its counters in Redis (app/core/rate_limit.py, one atomic Lua call per hit, so the limit holds across replicas). Credential endpoints (admin login, onboarding codes, hotspot login, voucher redeem, PPPoE login) get two layers: per client IP at RATE_LIMIT_REQUESTS per RATE_LIMIT_WINDOW, kept generous because hotspot customers often share one public IP, and per account or email at 10 per 5 minutes. RATE_LIMIT_ENABLED=false turns it off; a Redis outage lets requests through.