Multi Layer Rate Limiter

Problem Statement
Feitoria sits in the critical path for booking operations, and it's called by a mix of internal services like manual automation, cancel
lation, rescheduling and zendesk cases. In cases of failure or due to a retry loop, the endpoint can get hammered with no resistance until something downstream fell over.
Two distinct failure shapes worried us, and neither was covered by the other:
Aggregate overload — total traffic to an endpoint spikes past what the service can handle, even if no single caller looks abusive on its own.
Single-entity abuse — An automation or cancellation gets triggered again and again due to retry causing vendor plugins to be hammered unnecessarily.
What we did about it
We built a rate limiter that enforces limits at two independent layers on any protected endpoint:
A global layer — one shared bucket per endpoint, capping total traffic regardless of who's calling.
An entity layer — a separate bucket per entity per endpoint, so no single booking, customer, or vendor can consume more than its fair share even while the endpoint overall is healthy.
It's applied declaratively with a single annotation — no rate-limiting logic touches the controller body:
@RateLimit(entityParam = "bookingId", entityType = EntityType.BOOKING, perMinute = 150)
@GetMapping("/bookings/{bookingId}")
fun getBooking(@PathVariable bookingId: String): BookingResponse { ... }
Algorithm
The algorithm at play is the Token Bucket Algorithm. It uses tokens in a bucket as "allowances" to request, with a steady refill rate.
Unlike fixed-rate limiters that strictly force a constant, rigid speed, the token bucket algorithm allows for controlled burstiness. If a system is idle, tokens accumulate in the bucket up to the maximum capacity. When a user suddenly sends a large volume of requests (a burst), the system can process them all at once by drawing on the saved tokens. Once the stockpile is depleted, the system limits the user to the continuous, steady refill rate.
Why this algorithm?
We picked token bucket because it's the only common algorithm that gives us sustained rate limit without starving clients. Sliding Window Log was a strong contender but
How Spring intercepts the request
Before our aspect ever runs, Spring MVC's own routing has already done work we can reuse instead of redoing. When HandlerMapping resolves which controller method handles a request, it stashes what it learned directly onto the HttpServletRequest as attributes — the matched route pattern and the resolved path variable values — before handing off to the actual handler invocation.
That's why the aspect never re-parses the URL itself: the endpoint key comes straight from BEST_MATCHING_PATTERN_ATTRIBUTE (/bookings/{bookingId} and the entity ID comes straight from URI_TEMPLATE_VARIABLES_ATTRIBUTE["bookingId"] — both
bookingId = 57765
bookings/{bookingid}: 3145674
/id =
already computed by Spring's own dispatch machinery, just sitting on the request object waiting to be read.
val templateVars = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)
if (templateVars is Map<*, *>) {
val value = templateVars[entityParam] = 321395
if (value != null) return value.toString()
}
How the request gets checked, end to end
Once the proxy hands control to applyRateLimit, four things happen in order before the real controller code ever runs.
1. Building the endpoint key. Read off BEST_MATCHING_PATTERN_ATTRIBUTE as shown above, combined with the HTTP method: GET:/bookings/{bookingId}. /bookings/123 and /bookings/456 land on the same key — the global layer caps traffic to the route, not to any one booking.
2. Checking the global bucket. That key gets prefixed and sent to Redis as ratelimit:api:GET:/bookings/{bookingId}. Out of tokens → the aspect throws immediately, nothing after this runs.
3. Extracting the entity ID and checking the entity bucket. Read off URI_TEMPLATE_VARIABLES_ATTRIBUTE (falling back to matching @PathVariable names on the method signature if that's unavailable), then check a second, independent bucket keyed to both the route and that entity: ratelimit:entity:GET:/bookings/{bookingId}:123. Booking 123 and booking 456 never compete for the same capacity. No matching entity ID → this check is skipped, not denied.
4. The actual Redis check. Both checks funnel into the same logic: build a Bucket4j config (capacity + steady refill rate) for that key, then atomically try to consume one token. One round-trip to Redis returns either how many tokens are left (allowed) or how long until the next refill (denied). Because it's a compare-and-swap operation through Lettuce, this stays correct even when multiple Feitoria pods hit the same key at the same instant.
Key: ratelimit:api:POST:/booking/{id}/automate/internal:2134584
Value: <binary: tokens=5, lastRefillTime=1720512345678000000>
TTL: 3600 seconds
Wrapping up. Once both checks pass, the remaining token counts get written onto the response as X-RateLimit-Api-Remaining and X-RateLimit-Entity-Remaining headers, and only then does proceed() run — the line that finally lets getBooking's real body execute.
Wrapping up
This gives Feitoria a declarative, centrally-owned safety net: engineers opt an endpoint in with one line, and get protection at both the "someone's hammering this route" level and the "this one entity is being hammered" level — without having to think about Redis, Bucket4j, or token buckets at all.

