Rate Limiter, Bulkhead and Time Limiter

Rate Limiter

A rate limiter on the caller side protects a service you call (for example a bank API that allows 20 calls per second). Resilience4j splits time into cycles of limit-refresh-period and allows limit-for-period calls per cycle; extra calls wait up to timeout-duration, then fail with RequestNotPermitted. (The gateway's token bucket from the API Gateway module limits incoming traffic; this limits outgoing calls.)

resilience4j:
  ratelimiter:
    instances:
      bankApi:
        limit-for-period: 20
        limit-refresh-period: 1s
        timeout-duration: 0      # do not wait, fail at once

Bulkhead

Ships have watertight compartments (bulkheads) so one leak does not sink the ship. A bulkhead limits how many calls to one dependency run at the same time, so a slow payment provider cannot use all 200 Tomcat threads; product pages keep working. A simulated timeline with max 2 concurrent payment calls:

Example

import java.util.*;

public class Main {
    record Call(String name, int startMs, int durationMs) {}

    public static void main(String[] args) {
        int maxConcurrent = 2;
        List<Integer> inFlightEndTimes = new ArrayList<>();
        List<Call> calls = List.of(
            new Call("charge order-1001", 0, 300),
            new Call("charge order-1002", 50, 300),
            new Call("charge order-1003", 100, 300),
            new Call("charge order-1004", 320, 100),
            new Call("charge order-1005", 360, 100));

        for (Call c : calls) {
            inFlightEndTimes.removeIf(end -> end <= c.startMs());   // finished calls free a slot
            if (inFlightEndTimes.size() < maxConcurrent) {
                inFlightEndTimes.add(c.startMs() + c.durationMs());
                System.out.printf("t=%3dms %s -> ACCEPTED (in flight: %d)%n", c.startMs(), c.name(), inFlightEndTimes.size());
            } else {
                System.out.printf("t=%3dms %s -> REJECTED BulkheadFullException (in flight: %d) -> \"payment pending\"%n",
                        c.startMs(), c.name(), inFlightEndTimes.size());
            }
        }
    }
}

Output

t=  0ms charge order-1001 -> ACCEPTED (in flight: 1)
t= 50ms charge order-1002 -> ACCEPTED (in flight: 2)
t=100ms charge order-1003 -> REJECTED BulkheadFullException (in flight: 2) -> "payment pending"
t=320ms charge order-1004 -> ACCEPTED (in flight: 2)
t=360ms charge order-1005 -> ACCEPTED (in flight: 2)
resilience4j:
  bulkhead:
    instances:
      paymentService:
        max-concurrent-calls: 10
        max-wait-duration: 0       # reject at once when full
@Bulkhead(name = "paymentService", fallbackMethod = "paymentPending")
@CircuitBreaker(name = "paymentService")
public PaymentResponse charge(ChargeRequest req) {
    return paymentClient.charge("order-" + req.orderId(), req);
}

PaymentResponse paymentPending(ChargeRequest req, Throwable ex) {
    return PaymentResponse.pending(req.orderId());   // confirm later by event / webhook
}

Time Limiter

@TimeLimiter cancels a call that takes too long. It works only on async return types (CompletableFuture, Mono, Flux). For normal blocking Feign calls, the Feign read-timeout does this job.

@TimeLimiter(name = "productService")
@CircuitBreaker(name = "productService", fallbackMethod = "cachedProductAsync")
public CompletableFuture<ProductDto> getProductAsync(Long id) {
    return CompletableFuture.supplyAsync(() -> productClient.getProduct(id), executor);
}
resilience4j:
  timelimiter:
    instances:
      productService:
        timeout-duration: 2s
        cancel-running-future: true