All articles

// Knowledge.log — 技術記事

Rate Limiting on Spring Boot 4.1 with Resilience4j: Surviving Traffic Spikes Without Redis

In-memory rate limiting with Resilience4j on Spring Boot 4.1: RateLimiterConfig, a deterministic concurrency test, and a 429 with a real Retry-After.

A misbehaving client enters a retry storm, or someone decides to scrape your API at 3am. CPU and memory stay bored, but the database connection pool runs dry and the instance starts handing out 500s to everyone, including the clients that did nothing wrong. Nothing on the request path was capping how many calls per second a single client could make.

This article builds an in-memory rate limiter with Resilience4j — no Redis, no Testcontainers, no gateway. The goal is per-instance: protect the process that's up right now, not build a cluster-wide quota. And the part tutorials usually skip — testing this under concurrency deterministically, and returning a 429 with a Retry-After that's calculated, not guessed — is the main focus here.

Everything shown in code was run on this host: Linux, Temurin 25.0.4 LTS, Maven 3.9.12.

Prerequisites and versions

The tested set on this machine, no major mixing:

  • Java 25.0.4 Temurin LTS. Spring Boot 4.1.1 requires Java 17 and is compatible up to Java 26 (system requirements), so 25 sits comfortably inside the range.
  • Spring Boot 4.1.1 (not 4.2.0-M1 — that's a milestone).
  • spring-boot-starter-webmvc 4.1.1 as the primary web starter — that's what the official 4.1.1 tutorial itself uses, not the classic starter-web from older majors.
  • io.github.resilience4j:resilience4j-spring-boot4:2.4.0 — the starter built for Boot 4, not resilience4j-spring-boot3, which exists but targets a different generation of the framework.
  • spring-boot-starter-aspectj 4.1.1 for AOP. Copy spring-boot-starter-aop from an older tutorial and you'll hit a 404 — that artifact stopped existing as of 4.1.1.
  • spring-boot-starter-actuator 4.1.1 to expose metrics.

The full pom.xml from the test project:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>
<properties>
  <java.version>25</java.version>
  <resilience4j.version>2.4.0</resilience4j.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aspectj</artifactId>
  </dependency>
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot4</artifactId>
    <version>${resilience4j.version}</version>
  </dependency>
</dependencies>

Reference docs for the module: RateLimiter, the getting-started guide, and the Boot 4 starter README pinned at the version used here: resilience4j-spring-boot4 README, tag v2.4.0.

In-memory is not the same thing as distributed

Resilience4j's RateLimiterRegistry lives inside the JVM. The default implementation, AtomicRateLimiter, keeps the cycle and active permits in an atomic reference inside the process. There's no coordination between instances — each replica has its own counter, starting from zero.

That's enough when the goal is protecting the local process: keeping a spike from exhausting this instance's Tomcat threads or connection pool, regardless of how many replicas exist. That's the scenario this article covers.

It's not enough when you need a global quota — per API key, per tenant, per IP — that holds across the whole cluster. With N replicas behind a load balancer, a limitForPeriod of 10 turns into up to 10×N permits per cycle, one per instance. Sticky sessions don't fix that: they increase the odds a client always lands on the same instance, but they don't create a global budget. The first node failure, rolling restart, or non-sticky LB and the "one client, one limiter" assumption falls apart.

If the requirement is a cluster-wide quota, the correct answer is a shared backend (Redis, for example) — out of scope here, deliberately, since this article stays free of extra infrastructure. The practical recommendation: start in-memory to protect the process; move to a distributed limiter the day "per client" needs to mean "per client, across the whole cluster," not "per client, on this instance."

RateLimiterConfig: what each parameter actually limits

Three parameters do the heavy lifting, and the names invite the wrong mental model:

ParameterDefaultWhat it actually controls
limitForPeriod50How many permits exist within one cycle. It's a burst budget per cycle, not a sliding average.
limitRefreshPeriod500 nanosecondsThe cycle's length. At the start of each cycle, the limiter resets permits back to limitForPeriod.
timeoutDuration5 secondsHow long acquirePermission() can block the calling thread waiting for a permit before giving up.

The first thing that trips up anyone configuring this from YAML without checking the source docs: the default limitRefreshPeriod is 500 nanoseconds, not 500 ms. A limitRefreshPeriod: 500 with no unit in the YAML, carried over from the millisecond habits of other configs, does not do what it looks like it does.

The second one is timeoutDuration. With the 5s default, a Tomcat thread sits blocked for up to 5 seconds waiting for a permit before failing — fine for async processing, terrible for a synchronous HTTP API that needs to respond fast even when it's rejecting the call. For a fast-failing 429, set Duration.ZERO: acquirePermission() returns true or false immediately, no thread parking.

The bean used in this demo:

@Configuration
public class RateLimitConfig {

    @Bean
    public AtomicLong limiterClock() {
        return new AtomicLong(0L);
    }

    @Bean
    public RateLimiter apiRateLimiter(AtomicLong limiterClock) {
        RateLimiterConfig cfg = RateLimiterConfig.custom()
                .limitForPeriod(10)
                .limitRefreshPeriod(Duration.ofSeconds(1))
                .timeoutDuration(Duration.ZERO)
                .build();
        return RateLimiter.of("api", cfg, limiterClock::get);
    }
}

The AtomicLong limiterClock instead of System::nanoTime is what makes the limiter testable without depending on wall-clock time — more on that in the testing section. In production you'd swap the supplier for System::nanoTime (or just use the annotated/YAML form below, which does that under the hood).

The declarative form (YAML), equivalent in intent to the bean above, for anyone who'd rather configure through application.yml than a programmatic bean:

resilience4j.ratelimiter:
  instances:
    api:
      limitForPeriod: 10
      limitRefreshPeriod: 1s
      timeoutDuration: 0
      registerHealthIndicator: false

With that config in place, the annotation works directly on a controller or service:

@RateLimiter(name = "api") // io.github.resilience4j.ratelimiter.annotation.RateLimiter
@GetMapping("/resource")
public String resource() { return "ok"; }

Two caveats about this form, which the demo sidesteps by going programmatic:

  • @RateLimiter is proxy-based AOP. Calling the annotated method from inside the same class (self-invocation) skips the proxy entirely and silently ignores the limit — the method runs as if the limiter didn't exist. Annotate the external entry point (the controller) instead, or, if the logic is spread out, inject the RateLimiter/RateLimiterRegistry and call acquirePermission() explicitly, like the demo does.
  • The correct AOP starter for Boot 4.1 is spring-boot-starter-aspectj; without it on the classpath, the annotation simply isn't intercepted and the method runs with no limit at all, with nothing warning you about it.

Production note for both cases: an integration test that forces the 11th request and confirms a 429 (next section) catches both self-invocation and a missing AOP starter before either reaches the cluster.

The controller, and the exception that needs to become a 429

The entry point is simple: ask the limiter, and if it says no, throw Resilience4j's standard exception.

@RestController
public class OrderController {

    private final RateLimiter apiRateLimiter;

    public OrderController(RateLimiter apiRateLimiter) {
        this.apiRateLimiter = apiRateLimiter;
    }

    @GetMapping("/orders")
    public String orders() {
        if (!apiRateLimiter.acquirePermission()) {
            throw RequestNotPermitted.createRequestNotPermitted(apiRateLimiter);
        }
        return "ok";
    }
}

RequestNotPermitted is a plain RuntimeException, with no HTTP status baked in and no Retry-After. Neither Boot 4's autoconfigure nor the Resilience4j starter maps this to a 429 — RateLimiterAutoConfiguration registers the registry, the AOP aspect, and the actuator endpoints, but not an HTTP response. Leave that exception unhandled and it becomes a 500 (Spring's generic error), which is misleading: the client is being throttled, not breaking the server.

A 429 with a calculated Retry-After, not a fixed one

RFC 6585 §4 defines the 429 status for "too many requests in a given amount of time" and allows (doesn't require) a Retry-After header (RFC text). The syntax of that header — integer seconds or an HTTP date — lives in RFC 9110 §10.2.3 (RFC text). RFC 9110 doesn't define the 429 status itself; it only defines how Retry-After should be written.

The right value isn't a fixed number hardcoded somewhere — it's derived from the limiter's actual state at the moment of rejection. AtomicRateLimiter exposes that through getDetailedMetrics().getNanosToWait(), which returns the estimated nanoseconds until the next permit becomes available. Round up to whole seconds:

@RestControllerAdvice
public class RateLimitAdvice {

    private final RateLimiter apiRateLimiter;

    public RateLimitAdvice(RateLimiter apiRateLimiter) {
        this.apiRateLimiter = apiRateLimiter;
    }

    @ExceptionHandler(RequestNotPermitted.class)
    public ResponseEntity<Void> handle(RequestNotPermitted ex) {
        long nanos = ((AtomicRateLimiter) apiRateLimiter).getDetailedMetrics().getNanosToWait();
        long seconds = nanos <= 0 ? 1L : (nanos + 999_999_999L) / 1_000_000_000L;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header("Retry-After", Long.toString(seconds))
                .build();
    }
}

getNanosToWait() only exists on AtomicRateLimiterMetrics (via getDetailedMetrics()), not on the generic RateLimiter.Metrics interface — swap in SemaphoreBasedRateLimiter and this cast breaks, exactly the kind of detail that only shows up when you read the source, not the high-level docs.

This is what happened in MockMvc on this host: 10 calls to GET /orders returned 200 with body "ok"; the 11th, within the same cycle, returned 429 with Retry-After: 1. After advancing the injected clock (the same AtomicLong limiterClock from the bean) by 1 second, the next call went back to 200. The Spring Boot 4.1.1 context came up in 2.977 s with Java 25.0.4; the full test (RateLimitMvcTest) ran in 4.146 s, 1 test, 0 failures.

@SpringBootTest
@AutoConfigureMockMvc
class RateLimitMvcTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    AtomicLong limiterClock;

    @Test
    void eleventhCallInSameCycleIs429WithRetryAfterFromLimiter() throws Exception {
        limiterClock.set(0L);
        for (int i = 0; i < 10; i++) {
            mockMvc.perform(get("/orders"))
                    .andExpect(status().isOk())
                    .andExpect(content().string("ok"));
        }
        mockMvc.perform(get("/orders"))
                .andExpect(status().isTooManyRequests())
                .andExpect(header().string("Retry-After", "1"));

        limiterClock.addAndGet(Duration.ofSeconds(1).toNanos());
        mockMvc.perform(get("/orders"))
                .andExpect(status().isOk())
                .andExpect(content().string("ok"));
    }
}

Notice there's no Thread.sleep anywhere: the clock is injected, so "wait 1 second" means adding 1_000_000_000 nanoseconds to the AtomicLong, not actually stalling the JVM.

A deterministic concurrency test, no sleep-and-pray

The question rate limiter tests usually answer badly: "under real concurrency, is the limit actually honored, or does only the sequential happy path pass?" Thread.sleep waiting for the refresh cycle shows up even in Resilience4j's own internal tests (waitForRefresh uses Thread.sleep(10)), but that leaves the test fragile — if CI is under load, the timing shifts and the test flakes.

The alternative is exactly what RateLimiter.of(name, config, nanoTimeSupplier) was built to enable: you supply the time source. A frozen-at-zero AtomicLong, a limitRefreshPeriod longer than the test itself, timeoutDuration(Duration.ZERO) so no thread ever blocks, and an ExecutorService firing N concurrent calls:

@Test
void concurrentAcquireHonorsLimitWithoutSleep() throws Exception {
    AtomicLong nanoTime = new AtomicLong(0L);
    RateLimiterConfig cfg = RateLimiterConfig.custom()
            .limitForPeriod(5)
            .limitRefreshPeriod(Duration.ofSeconds(60))
            .timeoutDuration(Duration.ZERO)
            .build();
    RateLimiter limiter = RateLimiter.of("burst", cfg, nanoTime::get);

    int workers = 20;
    ExecutorService pool = Executors.newFixedThreadPool(workers);
    List<Callable<Boolean>> tasks = new ArrayList<>();
    for (int i = 0; i < workers; i++) {
        tasks.add(limiter::acquirePermission);
    }
    List<Future<Boolean>> futures = pool.invokeAll(tasks);
    pool.shutdown();
    assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue();

    long allowed = 0, denied = 0;
    for (Future<Boolean> f : futures) {
        if (f.get()) allowed++; else denied++;
    }
    assertThat(allowed).isEqualTo(5);
    assertThat(denied).isEqualTo(15);

    // ... extra denial outside the pool, clock advanced 60s, 5 more permits, then denied again
}

Run on this host: 20 threads contending for a limiter with limitForPeriod(5), 5 permits granted and 15 denied — not "roughly 5," exactly 5, because AtomicRateLimiter is genuinely atomic, not an optimistic approximation. One extra sequential call outside the pool confirms the cycle is still exhausted (denied). Adding 60 seconds in nanoseconds to the injected clock releases exactly 5 more permits, and the sixth is denied again. getDetailedMetrics().getNanosToWait() returns a positive value in that state, confirming the limiter knows how much time is left in the cycle. Result: RateLimiterConcurrencyTest, 1 test, 0 failures, 0.086 s — no Thread.sleep, no fragile timing, no reliance on the thread scheduler's mood.

One deliberate detail: Java 25 on this machine exposes StructuredTaskScope, but still as a preview API (javac refuses to compile without --enable-preview). That's why the test above uses plain ExecutorService — no point leaning on a feature that isn't finalized yet for a test that's supposed to be stable in CI.

Metrics to watch in production

Without extra infrastructure, observability comes from the actuator. Resilience4j 2.4.0's RateLimiter module exposes two gauges (source: RateLimiterMetricNames at tag v2.4.0, and the Micrometer guide):

  • resilience4j.ratelimiter.available.permissions — gauge, tag name, available permits right now (can go negative if there's a pending reservation).
  • resilience4j.ratelimiter.waiting_threads — gauge, tag name, threads in this JVM waiting for a permit. Watch the underscore: the high-level docs write waiting.threads with a dot, but the actual constant in the 2.4.0 source uses waiting_threads. If your dashboard can't find the metric, that mismatch is the likely culprit — trust the name the installed binary emits, not the doc prose.

Query it via GET /actuator/metrics/resilience4j.ratelimiter.available.permissions. There's no ready-made "throttled calls" counter in this module — to know how many rejections happened, combine available.permissions hitting zero with waiting_threads climbing, or register an event consumer (getEventPublisher().onFailure(...)) that increments your own counter.

Leave management.health.ratelimiters.enabled (the limiter's health indicator) off, which is the default. Turning it on makes the app's health check report DOWN whenever the limiter is saturated — except saturated is exactly the expected state during a legitimate spike, not an application failure. An orchestrator reacting to that by restarting the instance would make things worse, not better.

Pitfalls, once each

SymptomCauseFixHow to spot it
The limit never seems to kick inlimitRefreshPeriod inherited the 500 ns default (no explicit unit)Always declare a unit (1s, Duration.ofSeconds(1))An MVC test like the one above, running in CI
A Tomcat thread hangs up to 5s before rejectingtimeoutDuration left at its defaultDuration.ZERO for synchronous APIs that must fail fastwaiting_threads climbing, or a thread dump under load
The annotated method ignores the limitSelf-invocation bypassing the AOP proxyAnnotate the external entry point, or call RateLimiterRegistry programmaticallyAn integration test against the real endpoint, not a unit test on the class
500 instead of 429 under loadRequestNotPermitted unhandled@RestControllerAdvice mapping it to 429 + Retry-AfterA test like RateLimitMvcTest above
Health check takes the instance down under a spikeregisterHealthIndicator: trueLeave it off (the default)Actuator metrics, not the health endpoint
"Per client" quota is actually N× bigger in practiceIn-memory limiter, multiple replicasAccept it as a per-instance cap, or move to a distributed backend if the requirement is a cluster-wide quotaPer-instance metrics aggregated with host/pod tags in Prometheus

Recommendation

Reach for in-memory rate limiting with Resilience4j when the problem is protecting the process — this instance's threads, connections, CPU — from a specific client or route, and when "per instance" is an acceptable approximation of "per client." It's cheap, it doesn't introduce new infrastructure, and the deterministic test with ExecutorService + nanoTimeSupplier gives you CI confidence without gambling on timing.

Back off toward a distributed solution the moment the business requirement becomes "X requests per client, across the whole cluster, no exceptions" — at that point sticky sessions and a local limiter stop being a reasonable approximation and start being a promise the architecture can't keep. That's when a shared backend earns its place; until then, a local limiter with the concurrency test above covers the most common scenario: surviving the spike without taking the process down.

javaspring-bootresilience4j

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles