Adaptive limit
@AdaptiveLimit limits how many calls run at the same time, like a bulkhead, but finds the limit by itself: it raises it while the dependency's latency holds and lowers it when latency rises or calls time out.
| Annotation | Position | Return types | Defaults |
|---|---|---|---|
@AdaptiveLimit |
5 of 7, inside RateLimiter | sync and CompletionStage |
Gradient2, starts at 20, kept between 1 and 200, windows of at least 1 s and 10 calls |
Use it when a dependency's capacity is unknown or changes (it is shared, it autoscales, its load depends on other clients), so that any fixed @Bulkhead(maxConcurrent = …) would be either too low on a good day or too high on a bad one. The limit settles near the concurrency the dependency can serve without queueing, and drops when it starts to queue: excess calls are rejected at once instead of piling up behind the slow ones.
Don't use it when you know the right number (a connection pool of 10, a licence for 4 sessions): a bulkhead is simpler and predictable. If what you need is calls per second, use a rate limiter.
Minimal example
import dev.resilience4cata.annotation.AdaptiveLimit;
public interface SearchClient {
@AdaptiveLimit
String search(String query);
}
public final class SearchClient$Resilient implements SearchClient {
// …
public SearchClient$Resilient(SearchClient delegate, dev.resilience4cata.ResilienceRegistry registry) {
// …
this.adaptiveLimit0 = registry.adaptiveLimitPolicy("dev.resilience4cata.examples.docs.adaptivelimit.SearchClient#search(java.lang.String)", "", "", null);
}
@Override
public java.lang.String search(java.lang.String p0) {
final dev.resilience4cata.generated.AdaptiveLimitPolicy r4c$adaptiveLimit = this.adaptiveLimit0;
final long r4c$start = r4c$adaptiveLimit.acquire();
final java.lang.String r4c$result;
try {
r4c$result = this.delegate.search(p0);
} catch (java.lang.Throwable r4c$e) {
r4c$adaptiveLimit.onFailure(r4c$start, r4c$e);
throw r4c$e;
}
r4c$adaptiveLimit.onSuccess(r4c$start);
return r4c$result;
}
}
Abridged: the real file uses fully qualified names.
How it behaves
flowchart TD
arrives["Call arrives"] --> free{"Fewer calls running<br/>than the current limit?"}
free -- no --> reject["LimitExceededException<br/>(method not called)"]
free -- yes --> run["Take a slot, note the time,<br/>run the method"]
run --> outcome{"How did it end?"}
outcome -- "returned" --> sample["Free the slot,<br/>add its latency to the window"]
outcome -- "failed with a dropOn failure" --> drop["Free the slot,<br/>add its latency as a drop"]
outcome -- "any other failure" --> ignore["Free the slot only"]
sample --> over{"Window over and<br/>enough calls in it?"}
drop --> over
over -- yes --> recompute["Recompute the limit"]:::signal
- Slots. A call takes a slot before it runs and gives it back when it returns or throws. At most the current limit calls hold a slot at the same time. The limit starts at
initialLimitand always stays betweenminLimitandmaxLimit. - Rejection. A call that finds every slot taken fails at once with
dev.resilience4cata.LimitExceededException(aRuntimeExceptionwithinstanceId(); one per instance, no stack trace). The method is not called. There is no waiting: a full limit always rejects. - Samples. Each call that returns is a sample: its latency, from taking the slot to the end of the call. A failure listed in
dropOnis a sample too, marked as a drop: the dependency is overloaded. Any other failure (a 404, a validation error) frees the slot and says nothing about load, so it is not a sample. - Windows. Samples are grouped in windows of at least
window(1 s) and at leastminWindowSamplescalls (10). When a call completes after the window is over and the window has enough samples, that call's thread recomputes the limit from the window's average latency, the most calls it saw in flight, and whether any call was dropped. A window that is over with too few calls is extended by one morewindow, and keeps its samples: they count when it ends. So with little traffic the limit moves slowly (and is checked once per window, not on every call), and with none it does not move. - Lower limit, calls in flight. When the limit goes down below the calls already running, nothing is interrupted: new calls are rejected until enough of them end.
- Disabled (
enabled = "false"): every call passes and nothing is measured.
The algorithms
Both follow Netflix's concurrency-limits library (Gradient2Limit and AIMDLimit, version 0.5.4), applied once per window.
GRADIENT2 (the default) compares the window's latency with a long-term average of the latency, an exponential average over longWindow windows (600). While the window's latency stays within rttTolerance times the long-term average (1.5×), the limit grows a little each window, by a fifth (smoothing) of queueSize (4): about 0.8 per window. When latency rises above that, the limit shrinks in proportion, by at most half at a time. It does not grow while fewer than half of its slots are in use, since latency then says nothing about the limit. After a long overload, the long-term average comes down faster once latency is back to normal, so the limit recovers. It learns a new normal: latency that stays high for many windows becomes the long-term average, and the limit grows again from there.
AIMD (additive increase, multiplicative decrease) ignores latency. After a window with a drop, the limit is multiplied by backoffRatio (0.9); after a window without a drop in which at least half the slots were in use, it grows by one. Use it when latency is too noisy to mean anything, or when your overload signal is a timeout or a full bulkhead downstream.
@AdaptiveLimit(name = "orders-aimd", algorithm = LimitAlgorithm.AIMD, initialLimit = 10,
dropOn = {java.net.SocketTimeoutException.class, dev.resilience4cata.CallTimeoutException.class})
String cancel(String order);
Attributes
| Attribute | Type | Default | Config key | Meaning |
|---|---|---|---|---|
algorithm |
LimitAlgorithm |
GRADIENT2 |
defaults.adaptiveLimit.algorithm |
GRADIENT2 or AIMD, above. INHERIT means unset. |
initialLimit |
int |
20 |
defaults.adaptiveLimit.initialLimit |
The limit before the first window ends. |
minLimit |
int |
1 |
defaults.adaptiveLimit.minLimit |
The lowest the limit goes. At least 1. |
maxLimit |
int |
200 |
defaults.adaptiveLimit.maxLimit |
The highest the limit goes. |
window |
duration | 1s |
defaults.adaptiveLimit.window |
How long a window lasts at least, from 10ms to 1h. |
dropOn |
exception classes | CallTimeoutException, BulkheadFullException, TimeoutException |
defaults.adaptiveLimit.dropOn |
Failures that mean the dependency is overloaded. |
ignoreOn |
exception classes | none | defaults.adaptiveLimit.ignoreOn |
Failures that are never samples; they win over dropOn. |
name |
String |
one limit per method | annotation only | Instance id; methods with the same name share one limit and its slots. |
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.adaptiveLimit.enabled |
"" (inherit), "true" or "false". |
@AdaptiveLimit(name = "orders", initialLimit = 10, maxLimit = 100, window = "500ms")
@Timeout("2s") // inside the limit: a timeout is a drop, and the limit backs off
String place(String order);
The algorithms' own tuning has no annotation attribute, to keep the annotation small: minWindowSamples (10), Gradient2's smoothing (0.2), rttTolerance (1.5), longWindow (600) and queueSize (4), and AIMD's backoffRatio (0.9). Set them in a named configuration:
ResilienceRegistry registry = ResilienceRegistry.builder()
.adaptiveLimit("backend", a -> a.algorithm(AdaptiveLimitConfig.Algorithm.AIMD)
.backoffRatio(0.8)
.minWindowSamples(20))
.build();
or in external configuration, with @AdaptiveLimit(config = "backend") on the methods:
defaults.adaptiveLimit.maxLimit=100
configs.backend.adaptiveLimit.algorithm=aimd
configs.backend.adaptiveLimit.backoffRatio=0.8
configs.backend.adaptiveLimit.minWindowSamples=20
instances.orders.adaptiveLimit.rttTolerance=2
Every key, with its valid values, is in the configuration reference.
On a class
On an annotated class, at type level or on methods, the generated subclass applies the limit (annotated classes):
@AdaptiveLimit(maxLimit = 50)
public class InventoryService {
public int stock(String sku) {
return 7;
}
}
Sharing and instance ids
Without name, every method has its own limit, with the id <interface>#<method>(<parameter types>). Methods that call the same dependency should usually share one: give them the same name. They must resolve to the same configuration, or wrapping fails with R4C-REG-008.
Handle
registry.adaptiveLimit(id) returns an AdaptiveLimitHandle:
AdaptiveLimitHandle limit = registry.adaptiveLimit("orders");
int current = limit.limit(); // how many calls may run at once now
int running = limit.inFlight(); // calls inside right now
AdaptiveLimitSnapshot snapshot = limit.snapshot(); // limit, inFlight, bounds, windows, latencies
| Method | What it does |
|---|---|
id() |
The name, or the derived id |
config() |
The resolved AdaptiveLimitConfig |
limit() |
The current limit |
inFlight() |
Calls holding a slot right now |
snapshot() |
An AdaptiveLimitSnapshot(instance, algorithm, limit, inFlight, minLimit, maxLimit, windows, windowLatency, longTermLatency), plus availableSlots(). windowLatency is the last window's average; longTermLatency is Gradient2's long-term average (zero with AIMD) |
The limit exists once its interface has been wrapped or its pipeline built; an unknown id throws R4C-REG-009. List them with registry.snapshot().adaptiveLimits().
With other policies
The adaptive limit is inside the rate limiter and outside the timeout and the bulkhead:
- A
@Timeoutinside is the overload signal. A call that times out fails withCallTimeoutException, which is indropOnby default: the latency of that call (the timeout) goes into the window, and AIMD backs off. The same goes for a full@Bulkheadinside (BulkheadFullException). - Outer policies see
LimitExceededException. It is anException: by default an outer@Retryretries it after its delay (add it toabortOnto fail fast) and an outer@CircuitBreakerrecords it as a failure (add it toignoreOnif a full limit should not open the breaker). A@Hedgestops starting attempts when one is rejected by the limit, as for an open breaker or a full bulkhead. - Each attempt is measured on its own: a retry's delay is outside the limit, and holds no slot.
- Rejected calls already took a rate-limiter permit, since the rate limiter is outside.
Async
For a method that returns CompletionStage<T> or CompletableFuture<T>, the call holds its slot until its stage completes, and its latency is measured to that completion:
@AdaptiveLimit(name = "orders-async", initialLimit = 2)
CompletableFuture<String> placeAsync(String order);
A full limit returns a failed future at once. Cancelling the returned future cancels the stage and frees the slot without a sample: a cancelled call says nothing about the dependency (cancellation). See Async methods.
Same thing with Pipeline
Pipeline<String> search = Pipeline.builder(registry, "search", String.class)
.adaptiveLimit(a -> a.maxLimit(100))
.timeout(t -> t.timeout(Duration.ofSeconds(2)))
.build();
String results = search.execute(() -> backend.search("java"));
The id "search" is the instance id: registry.adaptiveLimit("search") returns it. See Pipeline API.
Events, snapshot and metrics
| Event | When | Fields |
|---|---|---|
AdaptiveLimitEvent.Rejected |
A call was rejected: the limit was full | instance, nanoTime, limit |
AdaptiveLimitEvent.LimitChanged |
A window ended and the limit moved | instance, nanoTime, from, to, windowLatencyNanos |
Admitted calls publish nothing, and a window that keeps the limit publishes nothing. The metrics modules report rejections in r4c.calls.rejected with kind = adaptivelimit, the moves in r4c.adaptivelimit.limit.changes, and the gauges r4c.adaptivelimit.limit and r4c.adaptivelimit.concurrent.calls. See Observability.
Testing it with ManualClock
On a ManualClock, latencies are what the clock says and windows end when the clock passes them, so the limit moves the same way on every run. Pending stages hold their slots, which makes an async limit easy to fill:
ManualClock clock = new ManualClock();
ResilienceRegistry registry = ResilienceTesting.registry(clock)
.adaptiveLimitDefaults(a -> a.minWindowSamples(1)) // end a window after a single call
.build();
OrderClient orders = Resilient.of(OrderClient.class, backend, registry);
AdaptiveLimitHandle limit = registry.adaptiveLimit("orders-async");
CompletableFuture<String> a = orders.placeAsync("a"); // pending: holds a slot
CompletableFuture<String> b = orders.placeAsync("b"); // pending: holds the other
assertThat(orders.placeAsync("c")).failsWithin(Duration.ZERO) // full: rejected at once
.withThrowableThat().withCauseInstanceOf(LimitExceededException.class);
clock.advance(Duration.ofSeconds(1)); // the calls took 1 s; the window is over
pending.get("a").complete("placed a"); // ends the window
pending.get("b").complete("placed b");
assertThat(limit.snapshot().windows()).isEqualTo(1);
assertThat(limit.snapshot().windowLatency()).isEqualTo(Duration.ofSeconds(1));
The adaptive limit schedules nothing, so it never leaves tasks pending on the clock.
Small print
- Fail-fast only. A full limit rejects at once; there is no
maxWaityet. Put a@Retryoutside if callers should try again a little later. - No
VEGASyet. Netflix's third algorithm is not implemented;LimitAlgorithmleaves room for it. - Two clock reads per call, when the call takes its slot and when it ends, through the registry's ticker. That is the cost of measuring latency; a
@CircuitBreakerwithslowCallDurationreads the clock for its own measurement. - Gradient2 does not go much below twice
queueSize(8 by default) on latency alone: with its gradient at the floor of one half, each window moves the limit to 0.9 × limit + 0.2 ×queueSize. For small limits, set a smallerqueueSizeor useAIMD. - Samples can be lost, never invented: calls record their latency in per-thread stripes without atomic instructions, so two threads that share a stripe and race can lose a sample. The window then has one sample fewer.
- Differences from Netflix's library:
minLimitdefaults to 1 (20 there), so thatAIMDcan go as low as the dependency needs (Gradient2's practical floor comes fromqueueSize, above); a window with too few calls is extended, keeping its samples, instead of being discarded; calls faster than 100 µs are samples too; AIMD has no latency threshold that counts as a drop (use a@Timeoutinside, whoseCallTimeoutExceptionis indropOn). - The limit is per JVM and per registry.
- Related errors:
R4C-ATR-001(duration syntax),R4C-ATR-003(a bound below 1),R4C-ATR-009(bounds out of order,windowout of range),R4C-REG-001(invalid tuning in code),R4C-REG-004(unknownconfig),R4C-REG-008(sharedname, different config),R4C-REG-009(unknown id).
What it costs
Measured AWS
c7g.xlarge(Graviton3): a call costs 86 ns on one thread and 380 ns with four, and allocates nothing; a rejection costs 2.1 ns. Netflix'sSimpleLimiterwith a windowedGradient2Limitcosts 118 ns and 1087 ns, and allocates 40–95 B per call. A fixed@Bulkheadcosts 20.5 ns on one thread: the difference is the twoSystem.nanoTime()reads (about 33 ns each on that machine) that timing every call needs. From Adaptive limit, 0.6.0.