Rate limiter
@RateLimiter lets through at most a given number of calls per period, and rejects (or delays) the rest.
| Annotation | Position | Return types | Defaults |
|---|---|---|---|
@RateLimiter |
4 of 7, inside CircuitBreaker | sync and CompletionStage |
50 permits per 1 s, no waiting |
Use it when a dependency has a quota (an API that allows 10 requests per second), or when you want to keep one client from flooding a shared service.
Don't use it when what you need to limit is how many calls run at the same time, whatever their rate: that is a bulkhead. And a rate limiter in one JVM limits that JVM only: with several instances of your service, each has its own limit.
Minimal example
import dev.resilience4cata.annotation.RateLimiter;
public interface SearchApi {
@RateLimiter(permits = 10, period = "1s")
String search(String query);
}
public final class SearchApi$Resilient implements SearchApi {
// …
public SearchApi$Resilient(SearchApi delegate, dev.resilience4cata.ResilienceRegistry registry) {
// …
this.rateLimiter0 = registry.rateLimiterPolicy("dev.resilience4cata.examples.docs.ratelimiter.SearchApi#search(java.lang.String)", "", "", r4c$b -> {
r4c$b.permits(10);
r4c$b.period(java.time.Duration.ofNanos(1000000000L));
});
}
@Override
public java.lang.String search(java.lang.String p0) {
final dev.resilience4cata.generated.RateLimiterPolicy r4c$rateLimiter = this.rateLimiter0;
if (!r4c$rateLimiter.tryAcquire()) {
throw r4c$rateLimiter.rejection();
}
return this.delegate.search(p0);
}
}
Abridged: the real file uses fully qualified names.
How it behaves
Think of the permits as evenly spaced: with permits = 10 and period = "1s", one permit becomes available every 100 ms. Permits you don't use are saved, up to permits of them, so after a quiet moment a burst of up to 10 calls passes at once; then calls pass at the steady rate, one every 100 ms.
flowchart TD
call["Call arrives"] --> free{"Permit available now?"}
free -- yes --> run["Take it, run the method"]
free -- no --> wait{"Next permit due<br/>within maxWait?"}
wait -- no --> reject["RateLimitExceededException<br/>(method not called)"]
wait -- yes --> reserve["Reserve that permit,<br/>wait until it is due"] --> run
- Rejection. With
maxWait = "0"(the default), a call with no permit available fails at once withdev.resilience4cata.RateLimitExceededException(aRuntimeExceptionwithinstanceId(); one per limiter, no stack trace). The method is not called. - Waiting. With
maxWait> 0, a call whose permit comes withinmaxWaitreserves it and waits until it is due; a call that would have to wait longer is rejected at once, without waiting at all. Waiters know their slot, so they don't compete when it arrives. - Sync waits sleep on the caller's thread. If the thread is interrupted while waiting, the permit is given back, the interrupt flag is set again and the call is rejected.
- Bursts. Because unused permits build up to a full burst, a window of one
periodcan see up to twicepermitscalls: a full burst, then the steady rate. This is the same as Resilience4j's fixed cycles. - There is no background thread refilling permits: each call computes its own from the clock.
- Disabled (
enabled = "false"): every call passes.
Under the hood this is GCRA, the meter form of a token bucket: the whole state is one number, the time at which the next permit is due, moved forward by period / permits per call.
Attributes
| Attribute | Type | Default | Config key | Meaning |
|---|---|---|---|---|
permits |
int |
50 |
defaults.rateLimiter.permits |
Calls allowed per period, and the largest burst. At least 1. |
period |
duration | 1s |
defaults.rateLimiter.period |
Length of the period. Greater than 0, and at least 1 ns per permit. |
maxWait |
duration | 0 (reject at once) |
defaults.rateLimiter.maxWait |
How long a call may wait for its permit. |
name |
String |
one limiter per method | annotation only | Instance id; methods with the same name share one limiter and its permits. |
config |
String |
default |
annotation only | Named configuration to start from. |
locked |
boolean |
false |
annotation only | Keeps the values set in the annotation: instances.<id>.* keys cannot change them. See Locking an instance. |
enabled |
String |
"true" |
defaults.rateLimiter.enabled |
"" (inherit), "true" or "false". |
The defaults differ from Resilience4j's (50 permits per 500 ns, 5 s wait): waiting is opt-in here, as in the bulkhead. Keys exist under configs.<name>. and instances.<id>. too; see Configuration.
Sharing and instance ids
A quota usually covers a whole API, not one method. Give the methods the same name and they share one limiter:
@RateLimiter(name = "geocoding", permits = 5, period = "1s", maxWait = "500ms")
String geocode(String address);
@RateLimiter(name = "geocoding", permits = 5, period = "1s", maxWait = "500ms")
String reverse(double latitude, double longitude);
Here geocode and reverse share 5 permits per second. When the burst is used up, the next call waits 200 ms for its permit (1 s / 5), within the 500 ms allowed; a call that would have to wait longer than 500 ms is rejected at once. Methods that share a name must resolve to the same configuration, or wrapping fails with R4C-REG-008. Without name, the id is <interface>#<method>(<parameter types>).
Handle
registry.rateLimiter(id) returns a RateLimiterHandle:
RateLimiterHandle limiter = registry.rateLimiter("geocoding");
int now = limiter.availablePermits(); // calls that would pass right now without waiting
RateLimiterSnapshot snapshot = limiter.snapshot(); // instance, availablePermits, permits
| Method | What it does |
|---|---|
id() |
The name, or the derived id |
config() |
The resolved RateLimiterConfig |
availablePermits() |
Calls that would pass right now without waiting, between 0 and permits; a snapshot that concurrent calls change at once |
snapshot() |
A RateLimiterSnapshot(instance, availablePermits, permits) |
The limiter exists once its interface has been wrapped or its pipeline built; an unknown id throws R4C-REG-009. There is no registry.rateLimiters(): list them with registry.snapshot().rateLimiters().
With other policies
The rate limiter is the fourth layer: inside CircuitBreaker, around Timeout and Bulkhead.
- Calls rejected by an open breaker never get here, so they don't use up permits.
- Its rejections are
Exceptions, so by default an outer@CircuitBreakerrecords them as failures (your own limiter can open your breaker: addRateLimitExceededExceptionto itsignoreOnif that is not what you want), and an outer@Retryretries them after its backoff delay (add it toabortOnto fail fast). - Each retry attempt takes a permit of its own.
- The time spent waiting for a permit is not part of the
@Timeout, which is inside.
Async
For a method that returns CompletionStage<T> or CompletableFuture<T>, a rejection is a failed future, and a call that waits for its permit is scheduled to start when it is due, without blocking any thread:
@RateLimiter(name = "geocoding-async", permits = 5, period = "1s", maxWait = "500ms")
CompletableFuture<String> geocodeAsync(String address);
Cancelling the future of a call that still waits for its permit gives the permit back, and the call never starts; a call that already started keeps its permit (cancellation). See Async methods.
Same thing with Pipeline
Pipeline<String> search = Pipeline.builder(registry, "search", String.class)
.rateLimiter(rl -> rl.permits(10).period(Duration.ofSeconds(1)))
.build();
String results = search.execute(() -> backend.search("resilience"));
The id "search" is the instance id: registry.rateLimiter("search") returns the limiter. See Pipeline API.
Events and snapshot
| Event | When | Fields |
|---|---|---|
RateLimiterEvent.Rejected |
A call was rejected (no permit within maxWait, or an interrupted wait) |
instance, nanoTime |
Admitted calls publish nothing. registry.snapshot().rateLimiters() lists a RateLimiterSnapshot per limiter. See Observability.
Testing it with ManualClock
SearchApi search = Resilient.of(SearchApi.class, query -> "results for " + query, registry);
for (int i = 0; i < 10; i++) {
search.search("q" + i); // a full burst of 10 is available at once
}
assertThatThrownBy(() -> search.search("one too many"))
.isInstanceOf(RateLimitExceededException.class);
clock.advance(Duration.ofMillis(100)); // permits come back one every 1s / 10
assertThat(search.search("next")).isEqualTo("results for next");
assertThatThrownBy(() -> search.search("again")).isInstanceOf(RateLimitExceededException.class);
On a ManualClock, permits come back only when the clock moves, and a call that waits for its permit advances the clock instead of sleeping.
Small print
periodmust be at least 1 ns per permit (permits = 1_000_000_000needsperiod≥ 1 s), else the configuration is invalid (R4C-REG-001/R4C-REG-003).- The limit is per JVM and per registry: two registries, or two instances of your service, each have their own.
- Every call reads
System.nanoTime()once. On the Apple M1 laptop used for the first benchmarks, that read was most of the limiter's cost (Rate limiter benchmarks). - When admitted calls arrive faster than the limiter's one shared value can move between CPU cores, a caller that loses the race parks for the shortest possible time before trying again; on Linux a platform thread's shortest park is about 50 µs. This only happens far above any rate a limiter is normally configured for.
- Related errors:
R4C-ATR-001(duration syntax),R4C-ATR-002(period = "0"),R4C-ATR-003(permitsbelow 1),R4C-ATR-006(enabled),R4C-REG-004(unknownconfig),R4C-REG-008(sharedname, different config),R4C-REG-009(unknown id).
What it costs
Measured On AWS Graviton3 (
c7g.xlarge, JDK 21), with 4 threads sharing one limiter, an admitted call costs 187 ns and allocates nothing, against 275 ns and 40 B for Resilience4j 2.4.0; a rejected call costs 34.7 ns, 0 B, against 1599 ns and 505 B. On 1 thread, an admitted call costs 41.8 ns. Benchmarks: MVP completion