Configurable Rate Limiter in Spring Boot

Romman SabbirRomman Sabbir
4 min read

Why Do We Need Rate Limiting?

Let’s say you have an API that shortens URLs. Everything is going great until one day, a bot starts hitting your API thousands of times per minute. Your server struggles, real users get slow responses, and your database is overloaded. This is where rate limiting comes in.

Rate limiting helps:
✔️ Prevent abuse (e.g., spammers, bots)
✔️ Ensure fair usage (so one user doesn’t hog resources)
✔️ Protect your backend from overload

What We’re Building

We’ll create a configurable rate limiter where:
✅ You define limits in application.properties (no hardcoded values)
✅ You can apply limits using @RateLimited annotation
✅ It works per IP and per API endpoint
✅ It resets automatically after a set time

1. Define Rate Limits in application.properties

Instead of hardcoding limits in the code, we’ll define them in application.properties:

rate-limiter.enabled=true

# Limits for different API endpoints
rate-limiter.limits.SHORTEN_URL.limit=50
rate-limiter.limits.SHORTEN_URL.timeFrameMinutes=1

rate-limiter.limits.ADVANCED_SHORTEN_URL.limit=30
rate-limiter.limits.ADVANCED_SHORTEN_URL.timeFrameMinutes=2

rate-limiter.limits.LINK_FOLIO.limit=20
rate-limiter.limits.LINK_FOLIO.timeFrameMinutes=5

What’s Happening Here?

  • The shorten URL API (/shorten) allows 50 requests per minute.

  • The advanced shorten URL API allows 30 requests per 2 minutes.

  • The Link Folio API allows 20 requests per 5 minutes.

  • We can change these values anytime without redeploying the app.

2. Load Configurations in a Kotlin Class

Spring Boot provides a way to read these values using @ConfigurationProperties:

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration

@Configuration
@ConfigurationProperties(prefix = "rate-limiter")
class RateLimiterConfig {
    var enabled: Boolean = true
    var limits: Map<String, RateLimitProperties> = emptyMap()

    data class RateLimitProperties(var limit: Int = 0, var timeFrameMinutes: Long = 0)
}

What’s Happening Here?

  • enabled: Turns rate limiting on/off dynamically.

  • limits: A map of API names to their rate limits.

  • Now, we can inject this RateLimiterConfig anywhere in the app.

3. Create a @RateLimited Annotation

To make things easy, let’s create an annotation that we can apply to API methods:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimited(val service: String)

This allows us to do things like:

@RateLimited(service = "SHORTEN_URL")
@PostMapping("/shorten")
fun shortenUrl(@RequestBody request: UrlShortenRequest): ResponseEntity<String> {
    return ResponseEntity.ok("Shortened URL")
}

Now, our rate limiter will know this API should be limited based on the SHORTEN_URL settings.

4. Build the Rate Limiter Logic

We need a service that will track API requests per IP address and per service type.

import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.*
import java.util.concurrent.*

@Service
class RateLimiterService(private val config: RateLimiterConfig) {

    private val requestMap = ConcurrentHashMap<String, ConcurrentHashMap<String, Pair<Int, LocalDateTime>>>()

    fun isRateLimited(identifier: String, service: String): Boolean {
        val limitConfig = config.limits[service] ?: return false
        val currentTime = LocalDateTime.now()

        synchronized(requestMap) {
            val serviceMap = requestMap.getOrPut(identifier) { ConcurrentHashMap() }
            val (requestCount, lastRequestTime) = serviceMap.getOrDefault(service, Pair(0, currentTime))

            if (lastRequestTime.plusMinutes(limitConfig.timeFrameMinutes).isBefore(currentTime)) {
                serviceMap[service] = Pair(0, currentTime)
                return false
            }

            return requestCount >= limitConfig.limit
        }
    }

    fun registerRequest(identifier: String, service: String) {
        val limitConfig = config.limits[service] ?: return

        if (isRateLimited(identifier, service)) {
            throw RateLimitExceededException(identifier, limitConfig.limit)
        }

        val currentTime = LocalDateTime.now()

        synchronized(requestMap) {
            val serviceMap = requestMap.getOrPut(identifier) { ConcurrentHashMap() }
            val (requestCount, _) = serviceMap.getOrDefault(service, Pair(0, currentTime))
            serviceMap[service] = Pair(requestCount + 1, currentTime)

            Timer().schedule(object : TimerTask() {
                override fun run() {
                    synchronized(requestMap) {
                        requestMap[identifier]?.remove(service)
                        if (requestMap[identifier]?.isEmpty() == true) {
                            requestMap.remove(identifier)
                        }
                    }
                }
            }, TimeUnit.MINUTES.toMillis(limitConfig.timeFrameMinutes))
        }
    }
}

What’s Happening Here?

  • Tracks requests in ConcurrentHashMap (thread-safe).

  • If the last request is older than the time limit, it resets the count.

  • If the limit is exceeded, it throws an exception.

  • Uses a timer to automatically remove expired entries.

5. Enforce Rate Limits with Spring AOP

Now, we’ll use Spring AOP to intercept methods with @RateLimited.

First, Add the AOP Dependency

If you haven’t already, add this to build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-aop")
}

Now, Create the AOP Aspect

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes

@Aspect
@Component
class RateLimiterAspect(private val rateLimiterService: RateLimiterService, private val config: RateLimiterConfig) {

    @Around("@annotation(rateLimited)")
    fun enforceRateLimit(joinPoint: ProceedingJoinPoint, rateLimited: RateLimited): Any? {
        if (!config.enabled) return joinPoint.proceed()  // Skip if disabled

        val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
        val ipAddress = request?.remoteAddr ?: "UNKNOWN"

        rateLimiterService.registerRequest(ipAddress, rateLimited.service)

        return joinPoint.proceed()
    }
}

6. Handle Errors Gracefully

If a user exceeds the limit, we should return a HTTP 429 Too Many Requests error.

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(RateLimitExceededException::class)
    fun handleRateLimitExceeded(ex: RateLimitExceededException) =
        ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ex.message)
}

Real-World Example

Let’s say you have a public link shortener service. Without rate limiting, users can:

  • Flood your API with thousands of shorten requests per second.

  • Use bots to create spam links.

  • Crush your database with unlimited writes.

By adding @RateLimited(service = "SHORTEN_URL"), you prevent spam, improve security, and ensure fair access.

Learning

🔹 Now your APIs are protected!
🔹 No more hardcoded limits – just update application.properties.
🔹 Easily apply rate limits with @RateLimited annotation.


That’s it for today. Happy coding…

1
Subscribe to my newsletter

Read articles from Romman Sabbir directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Romman Sabbir
Romman Sabbir

Senior Android Engineer from Bangladesh. Love to contribute in Open-Source. Indie Music Producer.