Todos os artigos

// Knowledge.log — 技術記事

Rate limiting no Spring Boot 4.1 com Resilience4j: protegendo a API de picos sem Redis

Configure rate limiting em memória com Resilience4j no Spring Boot 4.1: parâmetros do limiter, teste determinístico sob concorrência e 429 com Retry-After real.

Um client mal comportado entra em retry storm, ou alguém decide fazer scraping da sua API às 3h da manhã. CPU e memória continuam tranquilas, mas o pool de conexões do banco esgota e a instância começa a devolver 500 para todo mundo, inclusive para quem não fez nada de errado. Nada no caminho da requisição limitava quantas chamadas por segundo um único cliente podia fazer.

Este artigo monta um rate limiter em memória com Resilience4j, sem Redis, sem Testcontainers, sem gateway. O objetivo é per-instance: proteger o processo que está de pé agora, não construir uma cota global de cluster. E a parte que normalmente fica de fora dos tutoriais — testar isso sob concorrência de forma determinística, e devolver um 429 com Retry-After calculado, não chutado — é o foco principal.

Tudo que aparece em código foi executado neste host: Linux, Temurin 25.0.4 LTS, Maven 3.9.12.

Pré-requisitos e versões

Conjunto testado nesta máquina, sem misturar majors:

  • Java 25.0.4 Temurin LTS. O Spring Boot 4.1.1 exige Java 17 e é compatível até o Java 26 (system requirements), então 25 está dentro da faixa.
  • Spring Boot 4.1.1 (não use 4.2.0-M1; é milestone).
  • spring-boot-starter-webmvc 4.1.1 como starter web principal — é a forma que o próprio tutorial oficial da 4.1.1 usa, não o starter-web clássico das majors anteriores.
  • io.github.resilience4j:resilience4j-spring-boot4:2.4.0 — o starter dedicado ao Boot 4, não o resilience4j-spring-boot3, que existe mas é para outra geração do framework.
  • spring-boot-starter-aspectj 4.1.1 para AOP. Se você copiar spring-boot-starter-aop de um tutorial antigo, vai levar 404: esse artefato não existe mais a partir da 4.1.1.
  • spring-boot-starter-actuator 4.1.1 para expor métricas.

O pom.xml completo do projeto de teste:

<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>

Documentação de referência do módulo: RateLimiter (https://resilience4j.readme.io/docs/ratelimiter), guia de início (https://resilience4j.readme.io/docs/getting-started-3) e o README do starter Boot 4 na tag da versão usada aqui (https://github.com/resilience4j/resilience4j/blob/v2.4.0/resilience4j-spring-boot4/README.adoc).

Em memória não é o mesmo que distribuído

O RateLimiterRegistry do Resilience4j vive na JVM. A implementação padrão, AtomicRateLimiter, guarda o ciclo e as permissões ativas numa referência atômica dentro do processo. Não há coordenação entre instâncias — cada réplica tem o seu próprio contador, do zero.

Isso é suficiente quando o objetivo é proteger o processo local: impedir que um pico de chamadas esgote as threads do Tomcat ou o pool de conexões desta instância, independentemente de quantas réplicas existem. É o cenário deste artigo.

Não é suficiente quando você precisa de uma cota global — por chave de API, por tenant, por IP — que valha para o cluster inteiro. Com N réplicas atrás de um load balancer, um limitForPeriod de 10 vira até 10×N permissões por ciclo, uma por instância. Sticky sessions não resolvem isso: elas aumentam a chance de um cliente cair sempre na mesma instância, mas não criam um orçamento global. Na primeira falha de nó, rolling restart, ou LB não-sticky, a suposição de "um cliente, um limiter" desmorona.

Se a exigência é cota de cluster, a resposta correta é um backend compartilhado (Redis, por exemplo) — fora do escopo deste artigo, que fica deliberadamente sem infraestrutura extra. A recomendação prática: comece em memória para proteger o processo; migre para um limiter distribuído no dia em que "por cliente" precisar significar "por cliente, em todo o cluster", não "por cliente, nesta instância".

RateLimiterConfig: o que cada parâmetro limita de fato

Três parâmetros fazem o trabalho pesado, e os nomes convidam a interpretações erradas:

ParâmetroDefaultO que controla
limitForPeriod50Quantas permissões existem dentro de um ciclo. É um orçamento de rajada por ciclo, não uma média deslizante.
limitRefreshPeriod500 nanossegundosDuração do ciclo. Ao início de cada ciclo, o limiter reseta as permissões para limitForPeriod.
timeoutDuration5 segundosQuanto tempo acquirePermission() pode bloquear a thread chamadora esperando uma permissão antes de desistir.

O primeiro ponto que costuma pegar quem configura via YAML sem revisar a doc-fonte: o default de limitRefreshPeriod é 500 nanossegundos, não 500 ms. Um limitRefreshPeriod: 500 sem unidade no YAML, herdando o hábito de outros configs em milissegundos, não faz o que parece fazer.

O segundo ponto é timeoutDuration. Com o default de 5s, uma thread do Tomcat fica parada até 5 segundos esperando uma permissão antes de falhar — isso é aceitável para processamento assíncrono, péssimo para uma API HTTP síncrona que precisa responder rápido mesmo quando nega. Para falha rápida com 429, configure Duration.ZERO: acquirePermission() retorna imediatamente true ou false, sem parquear a thread.

O bean usado neste 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);
    }
}

O AtomicLong limiterClock no lugar de System::nanoTime é a peça que torna o limiter testável sem depender de tempo real — voltamos a isso na seção de testes. Em produção, você trocaria o supplier por System::nanoTime (ou simplesmente usaria a forma anotada/YAML abaixo, que faz isso por baixo dos panos).

A forma que aparece na configuração declarativa (YAML), equivalente em intenção ao bean acima, para quem prefere configurar via application.yml em vez de bean programático:

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

Com essa configuração, a anotação funciona direto no controller ou serviço:

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

Duas ressalvas sobre essa forma anotada, que o demo evita usando o limiter programático:

  • @RateLimiter é AOP baseado em proxy. Chamar o método anotado de dentro da mesma classe (self-invocation) passa por cima do proxy e ignora o limite silenciosamente — o método executa como se o limiter não existisse. Prefira anotar o ponto de entrada externo (controller) ou, se a lógica está espalhada, injete o RateLimiter/RateLimiterRegistry e chame acquirePermission() explicitamente, como faz o demo.
  • O starter AOP correto para Boot 4.1 é spring-boot-starter-aspectj; sem ele no classpath, a anotação simplesmente não é interceptada e o método roda sem limite algum, sem erro nenhum avisando isso.

Observação de produção para os dois casos: um teste de integração que force o 11º request e confirme 429 (seção adiante) detecta tanto o self-invocation quanto a ausência do starter AOP antes que cheguem ao cluster.

Controller e o exceptions que precisam virar 429

O ponto de acesso é simples: pergunta ao limiter, e se ele disser não, lança a exceção padrão do Resilience4j.

@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 é uma RuntimeException comum, sem status HTTP embutido e sem Retry-After. Nem o autoconfigure do Boot 4 nem o starter do Resilience4j mapeiam isso para 429 — a RateLimiterAutoConfiguration registra o registry, o aspecto AOP e os endpoints do actuator, mas não uma resposta HTTP. Sem tratar essa exceção, ela vira 500 (erro genérico do Spring), o que é enganoso: o cliente está sendo limitado, não quebrando o servidor.

429 com Retry-After calculado, não fixo

RFC 6585 §4 define o status 429 para "muitas requisições em um dado intervalo de tempo" e permite (não obriga) o cabeçalho Retry-After (https://www.rfc-editor.org/rfc/rfc6585.html). A sintaxe desse cabeçalho — segundos inteiros ou data HTTP — está em RFC 9110 §10.2.3 (https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after). A RFC 9110 não define o 429; ela só define como o Retry-After deve ser escrito.

O valor certo não é um número fixo escrito no código — é derivado do estado real do limiter no momento da negação. O AtomicRateLimiter expõe isso via getDetailedMetrics().getNanosToWait(), que retorna o tempo estimado em nanossegundos até a próxima permissão ficar disponível. Arredondando para cima em segundos inteiros:

@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() só existe em AtomicRateLimiterMetrics (via getDetailedMetrics()), não na interface genérica RateLimiter.Metrics — se você trocar a implementação para SemaphoreBasedRateLimiter, esse cast quebra, e é exatamente o tipo de detalhe que só aparece revisando o código-fonte, não a doc de alto nível.

Isso funcionou no MockMvc deste host: 10 chamadas a GET /orders retornaram 200 com corpo "ok"; a 11ª, dentro do mesmo ciclo, retornou 429 com Retry-After: 1. Depois de avançar o relógio injetado em 1 segundo (o mesmo AtomicLong limiterClock do bean), a chamada seguinte voltou a 200. Contexto do Spring Boot 4.1.1 subiu em 2,977 s com Java 25.0.4; o teste completo (RateLimitMvcTest) rodou em 4,146 s, 1 teste, 0 falhas.

@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"));
    }
}

Repare que não há Thread.sleep em lugar nenhum: o relógio é injetado, então "esperar 1 segundo" é somar 1_000_000_000 nanossegundos ao AtomicLong, não parar a JVM de verdade.

Teste determinístico sob concorrência, sem sleep de fé

A pergunta que testes de rate limiter costumam responder mal: "sob concorrência real, o limite é honrado, ou é só o cenário sequencial que passa?". Thread.sleep esperando o ciclo de refresh existe até nos testes internos do próprio Resilience4j (waitForRefresh usa Thread.sleep(10)), mas isso deixa o teste fragilizado — se a CI estiver ocupada, o timing muda e o teste fica instável.

A alternativa é a que o próprio RateLimiter.of(name, config, nanoTimeSupplier) foi desenhado para viabilizar: você fornece a fonte de tempo. Um AtomicLong congelado em zero, um limitRefreshPeriod maior que a duração do teste, timeoutDuration(Duration.ZERO) para não bloquear thread nenhuma, e um ExecutorService disparando N chamadas concorrentes:

@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 fora do pool, avanço do relógio em 60s, mais 5 permissões, nova negação
}

Rodado neste host: 20 threads disputando um limiter com limitForPeriod(5), 5 permissões concedidas e 15 negadas — não "aproximadamente 5", exatamente 5, porque o AtomicRateLimiter é atômico de verdade, não uma aproximação otimista. Uma chamada sequencial extra, fora do pool, confirma que o ciclo continua esgotado (negada). Somar 60 segundos em nanossegundos ao relógio injetado libera exatamente mais 5 permissões, e a sexta volta a ser negada. getDetailedMetrics().getNanosToWait() retorna um valor positivo nesse estado, confirmando que o limiter sabe quanto falta para o próximo ciclo. Resultado: RateLimiterConcurrencyTest, 1 teste, 0 falhas, 0,086 s — sem Thread.sleep, sem timing frágil, sem depender da sorte do agendador de threads.

Um detalhe deliberado: o Java 25 desta máquina expõe StructuredTaskScope, mas ainda como API preview (javac recusa compilar sem --enable-preview). O teste acima usa ExecutorService puro por isso — nada de depender de uma feature ainda não finalizada para um teste que deveria estar estável em CI.

Métricas para observar em produção

Sem servidor extra, a observabilidade vem do actuator. O módulo de RateLimiter do Resilience4j 2.4.0 expõe dois medidores (fonte: RateLimiterMetricNames na tag v2.4.0, e o guia de Micrometer):

  • resilience4j.ratelimiter.available.permissions — gauge, tag name, permissões disponíveis agora (pode ficar negativo se houver reserva pendente).
  • resilience4j.ratelimiter.waiting_threads — gauge, tag name, threads esperando por uma permissão nesta JVM. Atenção ao underscore: a documentação de alto nível escreve waiting.threads com ponto, mas a constante no código-fonte 2.4.0 usa waiting_threads. Se seu dashboard não encontra a métrica, é provavelmente essa a causa — confie no nome do binário instalado, não no texto da doc.

Consulte via GET /actuator/metrics/resilience4j.ratelimiter.available.permissions. Não existe um contador de "chamadas throttled" pronto neste módulo — para saber quantas negações aconteceram, combine available.permissions chegando a zero com waiting_threads subindo, ou registre um consumidor de eventos (getEventPublisher().onFailure(...)) que incremente seu próprio contador.

Deixe management.health.ratelimiters.enabled (o indicador de saúde do limiter) desligado, que é o default. Ligá-lo faz o health check da aplicação reportar DOWN quando o limiter está saturado — só que saturado é exatamente o estado esperado durante um pico legítimo, não uma falha da aplicação. Um orquestrador que reagisse a isso reiniciando a instância pioraria o problema, não resolveria.

Armadilhas, uma vez cada

SintomaCausaCorreçãoComo observar
Limite nunca aparece a atuarlimitRefreshPeriod herdou default de 500 ns (sem unidade explícita)Sempre declarar unidade (1s, Duration.ofSeconds(1))Teste de MVC como o acima, em CI
Thread do Tomcat trava até 5s antes de negartimeoutDuration no defaultDuration.ZERO para APIs síncronas que devem falhar rápidowaiting_threads subindo, ou thread dump sob carga
Método anotado ignora o limiteSelf-invocation bypassando o proxy AOPAnotar o ponto de entrada externo, ou chamar RateLimiterRegistry programaticamenteTeste de integração no endpoint real, não unitário na classe
500 em vez de 429 sob picoRequestNotPermitted sem handler@RestControllerAdvice mapeando para 429 + Retry-AfterRateLimitMvcTest como o acima
Health check derruba a instância sob picoregisterHealthIndicator: trueDeixar desligado (default)Métricas do actuator, não o health endpoint
Cota "por cliente" na prática é N× maiorLimiter em memória, múltiplas réplicasAceitar como cap por instância, ou migrar para backend distribuído se a exigência for cota de clusterMétricas por instância agregadas com a tag de host/pod no Prometheus

Recomendação

Adote rate limiting em memória com Resilience4j quando o problema é proteger o processo — threads, conexões, CPU desta instância — de um cliente ou rota específicos, e quando "por instância" é uma aproximação aceitável de "por cliente". É barato, não introduz infraestrutura nova, e o teste determinístico com ExecutorService + nanoTimeSupplier dá confiança de CI sem depender de sorte de timing.

Recue para uma solução distribuída no momento em que o requisito de negócio vire "X requisições por cliente, em todo o cluster, custe o que custar" — nesse ponto, sticky session e limiter local deixam de ser uma aproximação razoável e passam a ser uma promessa que a arquitetura não cumpre. Aí sim vale trazer um backend compartilhado; até lá, memória local com o teste de concorrência acima cobre o cenário mais comum: sobreviver ao pico sem derrubar o processo.

javaspring-bootresilience4j

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos