Stage 7 · Master
Caching & Rate Limiting with Redis
Rate Limiting with Redis
A tenant-scoped token bucket keeps one integration from turning everyone else's API into collateral damage.
Why Process-Local Limits Were Not Enough
Fieldwork is a multi-tenant API, which means the thing we are protecting is not just CPU or database connections in the abstract. We are protecting fairness between tenants. The rejected design was the easiest one to code: attach a local in-memory limiter to each Gin process, keyed by IP address. That is good enough for a demo and wrong for this system. If traffic is distributed across multiple API pods, a noisy tenant gets a fresh allowance on every pod. If several customers sit behind one corporate NAT, they all look like the same IP and one customer gets blamed for another customer's traffic.
The requirement was more specific: one noisy client must not degrade the platform for everyone else, and one noisy user inside a tenant should not be able to starve the rest of that tenant. That led to a two-level design. The primary limiter is tenant-scoped because tenant fairness is the platform concern. On top of that, selected endpoints can opt into a finer-grained key such as tenant plus user or tenant plus API token. We rejected per-route custom algorithms everywhere because the operational cost of reasoning about twenty limiter shapes is worse than a slightly less perfect global model.
| Design | Why it was tempting | Why Fieldwork chose differently |
|---|---|---|
| Per-process in-memory limiter | No network hop; trivial to implement | Not shared across pods, so the real platform limit becomes pod count |
| IP-based limit | Easy to derive before auth | Breaks for shared NATs and says nothing about tenant fairness |
| Tenant-scoped Redis limiter | Requires Redis and key design | Chosen because every API node sees the same bucket and the quota aligns with the business boundary |
If tenant A can consume all shared database and worker capacity, tenant B does not really have isolation. The limiter is one of the mechanisms that makes the tenancy model believable.
Scoping the Bucket
The key shape starts after authentication, not before it. Once the Gin middleware has identified the tenant, it constructs a scope key from the plan and actor. For most API routes the bucket is tenant:<id>:rate:api. For refresh-token rotation and other abuse-prone endpoints, we narrow to tenant:<id>:user:<id>:rate:auth. That was a deliberate rejection of a one-size-fits-all bucket. Authentication endpoints have different burst behavior from task reads. Human users need short bursts when a page loads; machine integrations need a stable sustained rate. One key per tenant is simple, but it lets a single sync job consume the same quota used by interactive traffic.
- Scope the first quota to the tenant because that is the fairness boundary the platform actually promises.
- Add narrower scopes only where one actor can realistically monopolize a tenant-wide bucket.
- Expose remaining quota headers so clients can back off cleanly instead of guessing.
The Redis Token Bucket
We chose a token bucket over a fixed window because humans and integrations both burst. A task board opening in the browser may fire several project, task, and membership requests at once. A fixed window treats that burst as suspicious purely because the clock boundary was unlucky. A token bucket lets us model you may spike briefly, but not forever. Redis gives us a shared state store across API pods, but only if the check-and-update is atomic. That is why the implementation uses a Lua script instead of separate GET and SET calls. Two requests racing on different pods should not both believe they consumed the last token.
Redis-backed rate limits help with fairness and some abuse, but they do not replace WAF rules, auth checks, or audit logging. We are shaping load, not declaring the request trustworthy.
What We Do Not Rate Limit the Same Way
Not every endpoint deserves the same policy. Internal health checks and readiness probes are excluded because they are part of platform operation, not customer traffic. Webhook receivers often get a separate bucket because third-party retry behavior is spiky and not under our control. Large attachment uploads are usually limited by concurrent upload slots instead of simple requests-per-second because one upload request can consume vastly more I/O than one metadata request. This is another reason we rejected a universal global quota. The useful abstraction is common enforcement machinery with route-specific capacity policy, not identical numbers everywhere.
| Traffic type | Policy | Reason |
|---|---|---|
| Interactive API reads | Tenant bucket with moderate burst | Page loads are bursty but short |
| Auth refresh | Tenant + user bucket with low burst | Protect against tight retry loops or token abuse |
| Webhooks | Separate integration bucket | Provider retry patterns differ from human traffic |
| Health probes | Excluded | Platform checks should not consume tenant quota |
The Decision
Fieldwork uses a Redis-backed token bucket keyed primarily by tenant, with narrower tenant-plus-actor scopes on endpoints where one caller could monopolize the tenant's budget. We rejected per-process in-memory limits and IP-based quotas because they do not hold up once traffic is distributed across pods and customers share networks. Redis is not here because rate limiting is fashionable; it is here because the quota has to be consistent across the fleet. The design is intentionally boring: shared state, atomic updates, explicit quota headers, and policies that map to actual platform fairness instead of edge-proxy convenience.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.