Redis Rate Limiter
Build a rate limiter on top of Redis that supports two classic algorithms —
fixed window (atomic INCR + EXPIRE) and sliding window (a sorted set
of request timestamps) — and wire it into a Spring Boot app as a global MVC
interceptor that returns a proper 429 Too Many Requests with a Retry-After
header.
If you’ve reached for express-rate-limit in Node or a golang.org/x/time/rate
limiter in Go, this is the distributed version: the counter lives in Redis, so
every instance of your service shares one limit per client.
What you’ll build
Section titled “What you’ll build”- A
FixedWindowRateLimiterbacked by RedisINCR+EXPIRE. - A
SlidingWindowRateLimiterbacked by a Redis sorted set (ZSET). - A Spring MVC interceptor that applies a limit to every
/api/**request. - Client identification by API key (
X-API-Keyheader) or IP address. - A
429response with aRetry-Afterheader and a JSON error body. - Atomic rate-limit checks using Redis Lua scripts (no race between the read and the write).
The endpoints are simple: /api/public (limited by IP), /api/protected
(limited by API key), and /api/status.
| Method | URL | Description |
|---|---|---|
| GET | /api/public | Public endpoint (rate limited by IP) |
| GET | /api/protected | Protected endpoint (rate limited by API key) |
| GET | /api/status | Check the service status |
The worked solution
Section titled “The worked solution”A standard Spring Boot single-module project. The rate-limit logic lives in
ratelimit/, the HTTP glue in interceptor/ and controller/.
Directoryrate-limiter/
- build.gradle.kts Spring Boot + Data Redis
- settings.gradle.kts project name
Directorysrc/main/
Directorykotlin/com/example/
- Application.kt Spring Boot entry point
Directoryconfig/
- RedisConfig.kt exposes a
StringRedisTemplatebean
- RedisConfig.kt exposes a
Directoryratelimit/
- RateLimiter.kt the interface + result type
- FixedWindowRateLimiter.kt INCR + EXPIRE via Lua
- SlidingWindowRateLimiter.kt sorted-set sliding window via Lua
Directorycontroller/
- ApiController.kt the three demo endpoints
Directoryinterceptor/
- RateLimitInterceptor.kt applies the limiter to
/api/**
- RateLimitInterceptor.kt applies the limiter to
Directoryresources/
- application.yml port + Redis connection
The contract
Section titled “The contract”Both limiters implement one interface. isAllowed takes a client key, a
limit, and a window length in seconds, and returns whether the request is
allowed plus how many requests remain and how long to wait. RateLimitResult
is a plain data class — the same shape you’d return from a TS function or a
Go struct.
package com.example.ratelimit
interface RateLimiter { /** * @param key unique identifier (e.g. IP address or API key) * @param limit maximum number of requests allowed in the window * @param windowSeconds duration of the rate-limit window in seconds */ fun isAllowed(key: String, limit: Int, windowSeconds: Int): RateLimitResult}
data class RateLimitResult( val allowed: Boolean, val remaining: Int, val retryAfterSeconds: Long = 0)Fixed window: INCR + EXPIRE
Section titled “Fixed window: INCR + EXPIRE”The fixed-window algorithm buckets time into fixed slices of windowSeconds. We
derive the current bucket from the clock (epochSeconds / windowSeconds) and
build a key like rate:fixed:{client}:{bucket}. Then we INCR that key — the
first hit in a bucket returns 1, so that’s when we set the EXPIRE. If the
count exceeds limit, the request is rejected until the bucket rolls over.
The catch: a plain INCR followed by a separate EXPIRE is two round-trips,
and if the process dies between them you get a key that never expires (a leaked
counter). The fix is a Lua script — Redis runs it atomically as a single
operation, so INCR and EXPIRE either both happen or neither does.
The script increments, sets the TTL on first increment, reads the remaining TTL,
and returns both the count and the TTL so we can compute Retry-After:
-- KEYS[1] = the bucket key ARGV[1] = window secondslocal count = redis.call('INCR', KEYS[1])if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1])endlocal ttl = redis.call('TTL', KEYS[1])return {count, ttl}In Kotlin we wrap that script in a DefaultRedisScript and run it with
redisTemplate.execute(...). Note the @Component annotation — Spring injects
the StringRedisTemplate (configured in RedisConfig) automatically.
package com.example.ratelimit
import org.springframework.data.redis.core.StringRedisTemplateimport org.springframework.data.redis.core.script.DefaultRedisScriptimport org.springframework.stereotype.Component
/** * Fixed Window Rate Limiter using Redis INCR + EXPIRE, made atomic with a * Lua script (one round-trip, no leaked counters). */@Componentclass FixedWindowRateLimiter( private val redisTemplate: StringRedisTemplate) : RateLimiter {
// INCR the bucket; set the TTL only on the first hit; return count + ttl. private val script = DefaultRedisScript( """ local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end local ttl = redis.call('TTL', KEYS[1]) return {count, ttl} """.trimIndent(), List::class.java )
override fun isAllowed(key: String, limit: Int, windowSeconds: Int): RateLimitResult { // Bucket the clock into fixed windows of `windowSeconds`. val window = System.currentTimeMillis() / (windowSeconds * 1000L) val redisKey = "rate:fixed:$key:$window"
val result = redisTemplate.execute( script, listOf(redisKey), windowSeconds.toString() )
val count = (result?.get(0) as Long).toInt() val ttl = (result[1] as Long)
val allowed = count <= limit val remaining = (limit - count).coerceAtLeast(0) return RateLimitResult( allowed = allowed, remaining = remaining, retryAfterSeconds = if (allowed) 0 else ttl.coerceAtLeast(0) ) }}Sliding window: a sorted set of timestamps
Section titled “Sliding window: a sorted set of timestamps”The sliding window keeps one Redis sorted set per client at
rate:sliding:{client}, where every member is a request and its score is the
request timestamp in millis. On each request we:
- Remove every member older than
now - windowSeconds * 1000(ZREMRANGEBYSCORE). - Count what’s left (
ZCARD). - If the count is already at
limit, reject — without adding the new request. - Otherwise add the current request (
ZADD) and refresh the key’s TTL.
Because the cleanup, the count, and the add must not interleave with another request, the whole thing is one Lua script. The member needs to be unique per request (two requests in the same millisecond would collide on score alone), so we pass a unique member id as an argument:
-- KEYS[1] = the ZSET key-- ARGV: now(ms), windowMs, limit, member, ttlSecondslocal now = tonumber(ARGV[1])local window = tonumber(ARGV[2])local limit = tonumber(ARGV[3])
-- Drop entries that have slid out of the window.redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)local count = redis.call('ZCARD', KEYS[1])
if count >= limit then -- Oldest surviving entry tells us when a slot frees up. local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') return {0, count, oldest[2]}end
redis.call('ZADD', KEYS[1], now, ARGV[4])redis.call('EXPIRE', KEYS[1], ARGV[5])return {1, count + 1, 0}The Kotlin side computes the unique member, runs the script, and turns the
returned {allowed, count, oldestScore} triple into a RateLimitResult. When
rejected, Retry-After is how long until the oldest entry slides out:
package com.example.ratelimit
import org.springframework.data.redis.core.StringRedisTemplateimport org.springframework.data.redis.core.script.DefaultRedisScriptimport org.springframework.stereotype.Componentimport java.util.UUID
/** * Sliding Window Rate Limiter using a Redis sorted set of request timestamps. * More accurate than fixed window — no boundary-burst problem. */@Componentclass SlidingWindowRateLimiter( private val redisTemplate: StringRedisTemplate) : RateLimiter {
private val script = DefaultRedisScript( """ local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) local count = redis.call('ZCARD', KEYS[1]) if count >= limit then local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') return {0, count, oldest[2]} end redis.call('ZADD', KEYS[1], now, ARGV[4]) redis.call('EXPIRE', KEYS[1], ARGV[5]) return {1, count + 1, 0} """.trimIndent(), List::class.java )
override fun isAllowed(key: String, limit: Int, windowSeconds: Int): RateLimitResult { val now = System.currentTimeMillis() val windowMs = windowSeconds * 1000L val redisKey = "rate:sliding:$key" val member = "$now-${UUID.randomUUID()}" // unique per request
val result = redisTemplate.execute( script, listOf(redisKey), now.toString(), windowMs.toString(), limit.toString(), member, windowSeconds.toString() )
val allowed = (result?.get(0) as Long) == 1L val count = (result[1] as Long).toInt() val oldestScore = (result[2] as? String)?.toLongOrNull() ?: now
val retryAfter = if (allowed) 0L else ((oldestScore + windowMs - now) / 1000).coerceAtLeast(1) return RateLimitResult( allowed = allowed, remaining = (limit - count).coerceAtLeast(0), retryAfterSeconds = retryAfter ) }}Wiring it in: the MVC interceptor
Section titled “Wiring it in: the MVC interceptor”A Spring MVC HandlerInterceptor runs before every matched controller method —
think Express middleware or a Go http.Handler wrapper. In preHandle we
identify the client, call the limiter, attach the X-RateLimit-* headers, and
short-circuit with a 429 (returning false) if the limit is blown.
The extractClientKey helper prefers the X-API-Key header and falls back to
the client IP (honouring X-Forwarded-For when behind a proxy).
package com.example.interceptor
import com.example.ratelimit.FixedWindowRateLimiterimport com.fasterxml.jackson.databind.ObjectMapperimport jakarta.servlet.http.HttpServletRequestimport jakarta.servlet.http.HttpServletResponseimport org.springframework.http.HttpStatusimport org.springframework.http.MediaTypeimport org.springframework.stereotype.Componentimport org.springframework.web.servlet.HandlerInterceptorimport org.springframework.web.servlet.config.annotation.InterceptorRegistryimport org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Componentclass RateLimitInterceptor( private val rateLimiter: FixedWindowRateLimiter, private val objectMapper: ObjectMapper) : HandlerInterceptor {
companion object { private const val DEFAULT_LIMIT = 10 private const val DEFAULT_WINDOW_SECONDS = 60 }
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { val clientKey = extractClientKey(request) val result = rateLimiter.isAllowed(clientKey, DEFAULT_LIMIT, DEFAULT_WINDOW_SECONDS)
response.setHeader("X-RateLimit-Limit", DEFAULT_LIMIT.toString()) response.setHeader("X-RateLimit-Remaining", result.remaining.toString())
if (!result.allowed) { response.status = HttpStatus.TOO_MANY_REQUESTS.value() response.setHeader("Retry-After", result.retryAfterSeconds.toString()) response.contentType = MediaType.APPLICATION_JSON_VALUE response.writer.write( objectMapper.writeValueAsString( mapOf( "error" to "Too Many Requests", "message" to "Rate limit exceeded. Try again in ${result.retryAfterSeconds} seconds.", "retryAfter" to result.retryAfterSeconds ) ) ) return false } return true }
private fun extractClientKey(request: HttpServletRequest): String { // Prefer API key if present, otherwise fall back to IP address. val apiKey = request.getHeader("X-API-Key") if (!apiKey.isNullOrBlank()) { return "apikey:$apiKey" } val ip = request.getHeader("X-Forwarded-For")?.split(",")?.firstOrNull()?.trim() ?: request.remoteAddr return "ip:$ip" }}
@Componentclass WebConfig(private val rateLimitInterceptor: RateLimitInterceptor) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(rateLimitInterceptor) .addPathPatterns("/api/**") }}The interceptor injects the FixedWindowRateLimiter by type. To switch the whole
app to the sliding-window strategy, change that one constructor parameter to
SlidingWindowRateLimiter (both are @Components implementing the same
RateLimiter interface) — or inject the RateLimiter interface and select the
bean with @Qualifier.
Supporting pieces
Section titled “Supporting pieces”RedisConfig exposes the StringRedisTemplate the limiters depend on, and
application.yml sets the port and Redis connection.
package com.example.config
import org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.data.redis.connection.RedisConnectionFactoryimport org.springframework.data.redis.core.StringRedisTemplate
@Configurationclass RedisConfig { @Bean fun stringRedisTemplate(connectionFactory: RedisConnectionFactory): StringRedisTemplate { return StringRedisTemplate(connectionFactory) }}server: port: 8083
spring: data: redis: host: localhost port: 6379 timeout: 5000Run it
Section titled “Run it”-
Start Redis from the shared infra (the app talks to it on
localhost:6379):Terminal window cd shared-infradocker compose up -d redis -
Start the app — it listens on port
8083:Terminal window ./gradlew bootRun -
Hammer the public endpoint past its limit (10 requests/minute by IP) and watch the
429kick in:Terminal window for i in $(seq 1 12); doecho "Request $i:"curl -s -w "\nHTTP Status: %{http_code}\n" http://localhost:8083/api/publicecho "---"done -
After the 10th request you’ll get
429 Too Many Requests— check theRetry-Afterheader. The protected endpoint is keyed by API key instead:Terminal window curl -H "X-API-Key: my-key" http://localhost:8083/api/protected