Stage 7 · Master
Phase 5 — API Gateway
Rate Limiting
Protect the gateway from unauthenticated floods with a coarse IP limit, then give every verified organization its own fair token-bucket budget in Redis so one noisy tenant can never starve every other HOA on the platform.
One Limiter Cannot Serve Both Jobs Well
A single global rate limit has to pick one identity to key on, and neither choice alone is sufficient. Keying by IP alone breaks down the moment two large HOA management companies operate behind the same corporate NAT — they would share one IP-based budget despite being completely unrelated tenants. Keying by org_id alone leaves the gateway defenseless against a flood of entirely unauthenticated requests, which have no org_id yet to key on at all. This module runs both, in the order the middleware lesson already fixed: IP-based pre-auth, then per-org post-auth.
'Fair' specifically means one organization's traffic spike cannot consume budget that belongs to a different organization — this is the core promise a shared multi-tenant gateway makes that a single global limiter cannot. Per-org token buckets, each independently tracked in Redis, are what deliver that isolation.
IP-Based Pre-Auth Limiting — A Cheap First Filter
package ratelimit
import (
"context"
"net"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
// PerIPLimiter runs BEFORE authentication, so it can only key on the
// client's remote IP — deliberately coarse, since its only job is to stop
// an unauthenticated flood from ever reaching the JWT verifier at all.
func PerIPLimiter(client *redis.Client, limit int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
key := "ratelimit:ip:" + ip
count, err := client.Incr(r.Context(), key).Result()
if err == nil && count == 1 {
client.Expire(r.Context(), key, window)
}
if count > int64(limit) {
retryAfter(w, window)
return
}
next.ServeHTTP(w, r)
})
}
}
func retryAfter(w http.ResponseWriter, window time.Duration) {
w.Header().Set("Retry-After", window.String())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"rate_limited","message":"too many requests from this address"}`))
}
func withContext(ctx context.Context) context.Context { return ctx }This tier's limit is intentionally generous — its job is to make an unauthenticated flood expensive for an attacker, not to police legitimate individual users, who are covered far more precisely by the per-org tier once they authenticate.
Per-Org Token Bucket — Fairness the IP Tier Cannot Provide
Once RequireAuth has verified a token, claims.OrgID is a trustworthy key — unlike an IP address, it cannot be shared accidentally by unrelated tenants and cannot be spoofed by a client (the jwt-validation lesson's header-stripping guarantees that). A token bucket per org_id, refilled continuously in Redis, gives each organization a burst allowance and a steady-state rate, independent of every other organization's traffic.
package ratelimit
import (
"net/http"
"time"
"github.com/hoa-platform/backend/services/gateway-service/internal/httpmw"
"github.com/redis/go-redis/v9"
)
const tokenBucketScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillPerSecond = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call("HMGET", key, "tokens", "updated_at")
local tokens = tonumber(bucket[1]) or capacity
local updatedAt = tonumber(bucket[2]) or now
local elapsed = math.max(0, now - updatedAt)
tokens = math.min(capacity, tokens + elapsed * refillPerSecond)
if tokens < 1 then
redis.call("HMSET", key, "tokens", tokens, "updated_at", now)
redis.call("EXPIRE", key, 3600)
return 0
end
tokens = tokens - 1
redis.call("HMSET", key, "tokens", tokens, "updated_at", now)
redis.call("EXPIRE", key, 3600)
return 1
`
// PerOrgFairLimiter must run AFTER RequireAuth — it reads the verified
// org_id from context, never from a client-supplied header, so no tenant
// can borrow or steal another tenant's budget by spoofing an org_id.
func PerOrgFairLimiter(client *redis.Client, capacity int, refillPerSecond float64) func(http.Handler) http.Handler {
script := redis.NewScript(tokenBucketScript)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := httpmw.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
key := "ratelimit:org:" + claims.OrgID
now := float64(time.Now().UnixNano()) / 1e9
allowed, err := script.Run(r.Context(), client, []string{key}, capacity, refillPerSecond, now).Int()
if err != nil {
// Fail open on Redis errors — an unavailable rate limiter should
// degrade to unlimited traffic rather than reject every request platform-wide.
next.ServeHTTP(w, r)
return
}
if allowed == 0 {
retryAfter(w, time.Second)
return
}
next.ServeHTTP(w, r)
})
}
}The token-bucket logic runs as a single Lua script via EVAL so the read-modify-write of tokens and updated_at is atomic — without that, two concurrent requests for the same organization could both read the same stale token count and both be allowed through, silently doubling the effective budget under load.
The Authentication module's session-management lesson chose to fail closed on Redis errors for platform-admin token denylist checks, because a missed revocation is worse than an outage there. Rate limiting makes the opposite tradeoff deliberately: a Redis outage degrading to unlimited traffic is preferable to the entire platform going fully unavailable because its rate limiter's dependency is down. Both choices are documented explicitly rather than left as an accidental side effect of whichever error-handling pattern was easiest to write.
Confirming Fairness Between Two Organizations
Applied exercise
Choose bucket capacity and refill rate for a small HOA versus a large property-management company
The platform currently applies one fixed capacity and refillPerSecond to every organization regardless of size — a 40-unit HOA and a 4,000-unit property-management company sharing multiple boards get an identical budget.
- Propose how org size (e.g., a stored membership count, or a billing tier) could inform a per-org override of capacity and refillPerSecond.
- State where that override value should be looked up — cached in the JWT claims at issuance, or fetched fresh from a store on each request — and justify the tradeoff.
- Explain why simply setting one very high global limit for everyone would defeat the fairness goal this lesson is built around.
Deliverable
A written design for per-org configurable rate limits, with an explicit lookup-timing decision.
Completion checks
- The design correctly identifies that embedding a size-derived limit in the JWT itself would go stale for up to the access token's TTL if org size changes — a real tradeoff worth naming.
- The answer explains that a single generous global limit reintroduces the exact starvation problem two tiers were built to prevent.
Why must the per-org token bucket's read-modify-write of tokens and updated_at execute as a single atomic Lua script rather than separate Redis GET/SET commands?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.