Direct answer
One rate limit number for an entire API is a beginner mistake. This portfolio's backend uses four separate Bucket4j buckets: 120 requests/second for generic API traffic, 6 requests/10 seconds for AI endpoints, 30/minute for one sensitive download, 5/hour for token issuance. An unrelated cheap endpoint still drained the wrong bucket in production once: a flood of CSP reports exhausted the shared quota and blocked legitimate users' email verification with 429s.
"Rate limit: 100 requests per minute" is the laziest sentence you can write in an API spec. A single number applied equally to every endpoint treats a GET that reads a cached row the same as a POST that invokes a local LLM with hundreds of milliseconds of latency. In this portfolio's backend the Bucket4j buckets are four, not one, and even with four well-designed buckets I still had a production incident where a low-priority endpoint drained a critical one's quota.
Four buckets, four different product decisions
Rate limiting is not an isolated technical problem, it is a product
decision disguised as configuration. In the backend,
RateLimitConfig defines four policies with distinct
semantics, each for a specific business reason:
- Generic API: 120 requests/second, refill 2 tokens/s per IP. Generous, because it covers the vast majority of cheap endpoints (reading articles, tool catalog, search).
- AI: 6 requests/10 seconds, refill 1 token/10s per IP. An order of magnitude tighter, because every request invokes Ollama on-prem and consumes real compute on the same VPS that also serves application traffic.
- Analyzer report download: 30 requests/minute per IP, a dedicated bucket. Introduced after noticing that a report download gated only by a random UUIDv4 risked inheriting either the AI bucket (too tight, causing false 429s on legitimate downloads) or the API bucket (too loose, offering little protection against probing).
- Knowledge Pack download token: 5 tokens/hour, keyed by authenticated userId, not IP. The only one of the four buckets keyed by identity instead of network address.
| Bucket | Capacity | Refill | Key |
|---|---|---|---|
| Generic API | 120 | 2 tok/s | Client IP |
| AI (Ollama) | 6 | 1 tok/10s | Client IP |
| Analyzer report download | 30 | 30 tok/min | Client IP |
| Knowledge Pack download token | 5 | 5 tok/h | userId |
RateLimitConfig.java, production Spring Boot 3.4 backend. Caffeine store, max 10,000 keys, eviction after 1h of inactivity.Routing to the right bucket is where the bug is born
Having four buckets is not enough, every request also needs to be
routed to the correct one, and this is where the code gets
complicated fast. The backend classifies a path as "AI" not just
by prefix (/api/v1/ai/**), but with an explicit list of
fiscal paths that share the AI bucket because they call Ollama
(/fiscal/regime/compare, /fiscal/chat,
/business-tools/ai-boost, the sector analyzers, the trip
planner), plus substring rules for dynamic ProjectHub paths
(/project-hub/workspaces/{slug}/ai/*). Every time I add
a new endpoint that calls Ollama, I have to remember to add it
explicitly to this list, otherwise it falls into the generic
120-requests/second bucket and becomes an economic abuse vector on AI
inference disguised as normal API traffic.
The reverse case, equally insidious, was the report download: it
initially inherited the AI bucket by path-categorization mistake
(shared prefix), with the result that legitimate users downloading
their own report got 429s after a few attempts, because they
consumed the same scarce quota reserved for Ollama calls. The fix
(CR-01 in the code) explicitly excludes that path before
even checking whether it starts with /api/v1/ai, and
assigns it a dedicated 30/minute bucket, neither too tight nor too
loose for the use case.
Postmortem: when the wrong bucket blocks the right endpoint
The most instructive production case was not intentional abuse, but
legitimate browser behavior draining a shared resource. During the
Content Security Policy rollout in Report-Only mode, every
prerendered page loaded by the browser generated roughly 20
POST /csp-report requests per single page-load (expected
CSP violations, not malicious). Those requests shared the same
generic 120/s API bucket also used by the email-verification
endpoint. The CSP-report flood exhausted the IP-scoped quota enough
to trigger 429s on legitimate email-verification requests from the
same client too. The fix was excluding /csp-report from
the rate limiter entirely, because it is a browser telemetry
endpoint, not an application endpoint, and it must never compete for
the same quota as real user actions.
The general lesson, not specific to CSP: any endpoint the browser calls automatically, without an explicit user action (telemetry, beacons, reports), must be kept out of rate limiting shared with user-triggered endpoints, or isolated in its own dedicated bucket. If it shares the quota with a real user action, the browser's automatic volume always wins, and the real user pays the price for an error they did not cause.
Trade-off: IP-based versus identity-based
Three of the four buckets are keyed by IP, one by userId. IP-based keying has a known limit: behind shared corporate NAT or CGNAT mobile networks, many legitimate users can share the same IP and therefore the same bucket, increasing the risk of 429s on aggregated innocuous traffic. Identity-based (userId) keying requires prior authentication, so it cannot be used on public pre-login endpoints (login itself, or email verification). The practical choice in the backend was IP-based everywhere except where the endpoint already requires an authenticated user performing a high-value action (issuing a download token): there, identity is a more precise signal than IP and the added complexity is worth it.
When one generic bucket is genuinely enough
Not every backend needs four buckets. If there is no endpoint invoking an AI model or a server-side expensive operation, a single well-sized generic bucket is sufficient and multi-bucket routing complexity is unjustified. The real signal that differentiation is needed is the presence of endpoints with asymmetric compute or network cost relative to the rest of the API, not the absolute number of exposed endpoints.
Frequently asked questions
Why should an API have multiple rate-limit buckets instead of just one?
Different endpoints have different costs. An endpoint invoking an AI model consumes far more real compute than a cached read, so it deserves a much tighter quota. A single shared bucket treats every endpoint the same and does not protect the most expensive resources from abuse.
What can cause a CSP-report flood against a rate limiter?
During a Content Security Policy rollout in Report-Only mode, every loaded page automatically generates several POST requests to the report endpoint for each observed CSP violation, even expected and non-malicious ones. If this endpoint shares a bucket with real application endpoints, it can exhaust their quota.
How do you prevent a browser telemetry endpoint from blocking real user endpoints?
By explicitly excluding automatic telemetry endpoints (CSP reports, beacons, analytics) from the shared rate limiter, or by assigning them a separate dedicated bucket. An endpoint called automatically by the browser generates volume that does not reflect a real user's intent, and it should never compete for the same quota.
Is IP-based or userId-based rate limiting better?
It depends on context. IP-based rate limiting works everywhere, even pre-login, but suffers behind shared NAT where multiple real users share the same address. UserId-based rate limiting is more precise but requires prior authentication, so it is suited only to high-value actions performed by already-logged-in users.
When is a single generic rate-limit bucket enough for a whole API?
When there are no endpoints with significantly higher compute or network cost than the rest of the API, such as AI model calls or heavy server-side processing. In that case, a single well-sized bucket is sufficient and multi-bucket routing adds complexity without real benefit.
Commenti
Caricamento commenti...
Sign in to comment