Skip to main content

Multi-Bucket Rate Limiting: Why One Number for the Whole API Is a Mistake

Four separate Bucket4j buckets (API, AI, report download, token) versus one shared number. Routing to the right bucket is where real bugs are born: the postmortem of the CSP-report flood that blocked email verification with 429s.

What do you think about this article?

Multi-Bucket Rate Limiting: Why One Number for the Whole API Is a Mistake
DevSecOpsfedericocalo.dev

At a Glance

Four separate Bucket4j buckets (API, AI, report download, token) versus one shared number. Routing to the right bucket is where real bugs are born: the postmortem of the CSP-report flood that blocked email verification with 429s.

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.
Configuration of the four production Bucket4j buckets
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
Real values from 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.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects