Production Tuning & Anti-patterns

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Every other page of this section explains how one pattern works. This page is about the day after the demo: how to choose the numbers, how the patterns interact when several of them guard the same call, what an operator does at three in the morning, and which mistakes show up again and again in reviews. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026: the configuration, the code and the Actuator requests below were compiled and run in a sample Spring Boot 4.1.1 application on JDK 21, and the property names are identical on Spring Boot 3.5.x with resilience4j-spring-boot3. Package names in the code are the Bookshelf ones (com.example.bookshelf). The properties are explained on the page of each module, and are only used here: CircuitBreaker, Retry, Bulkhead, TimeLimiter, RateLimiter and Observability.

The production checklist for Bucket4j rate limits is in Bucket4j: Production Checklist.

Choose numbers from traffic, not from defaults

The Resilience4j defaults are generic: a count-based window of 100 calls, a failure threshold of 50 percent, a slow-call threshold of 60 seconds, and a maxAttempts of 3. They say nothing about your dependency. Every number below comes from three measurements per backend, taken from your metrics over a representative week: the request rate, the latency percentiles (p50, p99, p99.9) and the baseline error rate while the dependency is healthy.

From the error budget to the thresholds

A service-level objective of 99.9 percent leaves an error budget of 0.1 percent of the requests. The budget answers "how many errors are normal", and so tells you where "abnormal" starts. A circuit breaker is not the place that enforces the SLO: it is a fuse that must stay closed during ordinary noise and open early enough to protect the caller during a real outage. The rules of thumb used in the example are:

  • failureRateThreshold far above the baseline error rate (here 100 times, 30 percent against 0.3 percent), so a bad minute of ordinary errors never opens the breaker, and far below 100 percent, so the breaker acts while there is still something to protect.

  • slowCallDurationThreshold slightly above the p99 of the healthy dependency, so that by construction about one percent of healthy calls are slow, and slowCallRateThreshold well above that percentage. The default of 60 seconds turns slow-call detection off in practice.

  • A call timeout (the HTTP read timeout or the TimeLimiter) that is larger than slowCallDurationThreshold, so that a slow call is measured as slow before it is cut, and a small multiple of the p99 (here about 2.8 times), so that the timeout does not cut healthy tail calls.

  • minimumNumberOfCalls large enough that one failure in a quiet period is not "100 percent failures", and small enough that the breaker decides within seconds. With 50 requests per second, 100 calls are two seconds of traffic.

  • A time-based window for a high-traffic backend (the last 20 seconds, about 1000 calls) and a count-based window for a low-traffic one (the last 20 calls), because a count-based window of 20 calls would span a few hundred milliseconds at 50 requests per second and would react to every blip.

  • waitDurationInOpenState about the time the dependency needs to recover: a pod restart or an autoscaling step (10 seconds), or the time before a third-party quota resets (30 seconds). Waiting less only fills the half-open probes with failures.

Table 1. Measurements and the values derived from them for the two Bookshelf backends
Backend Measured Derived setting Why

pricing-service

50 req/s, p50 40 ms, p99 180 ms, baseline errors 0.3 %

slowCallDurationThreshold: 250ms, slowCallRateThreshold: 40

250 ms is about 1.4 times the p99; healthy traffic has about 1 % slow calls, so 40 % is a real change.

failureRateThreshold: 30, TIME_BASED window of 20 s, minimumNumberOfCalls: 100

100 times the baseline error rate; 1000 calls per window; a decision after two seconds of traffic.

read timeout and TimeLimiter of 500ms

2.8 times the p99, above the slow-call limit, and also the longest any caller can wait.

maxConcurrentCalls: 25, maxWaitDuration: 0

Little’s law: 50 req/s times 0.5 s is 25 calls in flight when every call runs into the timeout.

isbn (third-party API)

2 req/s, p99 280 ms, hard quota

COUNT_BASED window of 20, minimumNumberOfCalls: 10, slowCallDurationThreshold: 300ms, waitDurationInOpenState: 30s

Low traffic, so a count window reacts in seconds; a long wait protects the quota.

The concurrency figure is Little’s law: the number of calls in flight equals the arrival rate times the time each call takes. At the healthy p99 of 180 ms the same traffic needs 9 permits (50 times 0.18); at the timeout it needs 25, and a bulkhead of 25 permits is exactly the capacity the service would consume during an outage that makes every call run to its timeout, while any more than that is rejected immediately.

The Bookshelf tuning, as configuration
management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,circuitbreakerevents,retries,bulkheads
  endpoint:
    health:
      probes:
        enabled: true
      show-details: always
  health:
    circuitbreakers:
      enabled: true

resilience4j:
  circuitbreaker:
    instances:
      pricing:                              # 50 req/s, p50 40 ms, p99 180 ms, baseline errors 0.3 %
        registerHealthIndicator: true
        slidingWindowType: TIME_BASED
        slidingWindowSize: 20               # seconds: about 1000 calls at 50 req/s
        minimumNumberOfCalls: 100           # a decision needs at least 2 s of traffic
        failureRateThreshold: 30            # 100 times the baseline error rate
        slowCallDurationThreshold: 250ms    # about 1.4 x p99, so healthy calls are almost never slow
        slowCallRateThreshold: 40
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 10
      isbn:                                 # third-party API, 2 req/s, p99 280 ms, hard quota
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20
        minimumNumberOfCalls: 10
        failureRateThreshold: 50
        slowCallDurationThreshold: 300ms
        slowCallRateThreshold: 50
        waitDurationInOpenState: 30s
  retry:
    configs:
      default:
        maxAttempts: 2
        ignoreExceptions:
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException
          - io.github.resilience4j.bulkhead.BulkheadFullException
    instances:
      pricing:
        baseConfig: default
        waitDuration: 100ms
      isbn:
        baseConfig: default
        waitDuration: 200ms
  bulkhead:
    instances:
      pricing:
        maxConcurrentCalls: 25              # 50 req/s x 0.5 s timeout
        maxWaitDuration: 0
  timelimiter:
    instances:
      pricing:
        timeoutDuration: 500ms
      isbn:
        timeoutDuration: 350ms
spring:
  http:
    clients:
      connect-timeout: 200ms
      read-timeout: 500ms

Source: Resilience4j: CircuitBreaker and Spring Boot: Endpoints. Loaded in a Spring Boot 4.1.1 test: the registries returned a 20 s TIME_BASED window, a 250 ms slow-call threshold, a 10 s open wait, two attempts with a 100 ms (pricing) and 200 ms (isbn) wait, 25 permits with a zero wait, and the 500 ms and 350 ms limits. Two details are deliberate. The retry ignores CallNotPermittedException and BulkheadFullException, which a Retry retries by default (see Bulkhead): retrying a rejection only repeats the rejection. And the spring.http.clients. keys are the plural ones of Spring Boot 4; the singular spring.http.client. keys have no effect on Boot 4.1.1 (see TimeLimiter).

Two cautions apply to every table of this kind. The numbers are a starting point that must be revisited when traffic or the dependency changes: schedule the review with the dashboards from Observability. And the p99 has to be measured where the call is made (the client side, including queueing and connection setup), not read from the dependency’s own dashboard.

One instance per backend

A Resilience4j instance is a named piece of state: a sliding window, a bucket of permits, a semaphore. The state is what makes it useful, and it is also why instances must not be shared between things that fail differently. Give every dependency its own CircuitBreaker, Bulkhead and Retry name (pricing, isbn, recommendations), not one called backend or default. If recommendations and pricing share a breaker, the outage of the first one fills the window with failures and opens a breaker that then rejects the healthy pricing calls too: the pattern that was supposed to contain the failure spreads it.

Sharing one breaker between two backends, and the fix
CircuitBreakerConfig TIGHT = CircuitBreakerConfig.custom()
    .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
    .slidingWindowSize(4).minimumNumberOfCalls(4).failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofSeconds(30)).build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(TIGHT);
CircuitBreaker shared = registry.circuitBreaker("backend");          // anti-pattern: one name for everything
for (int i = 0; i < 4; i++) {                                         // only recommendations fails
    try {
        shared.executeSupplier(() -> { throw new IllegalStateException("recommendations down"); });
    } catch (IllegalStateException expected) {
        // counted as a failure
    }
}
// shared is OPEN: the healthy pricing call is rejected with CallNotPermittedException
shared.executeSupplier(() -> "price 19.99");

CircuitBreaker pricing = registry.circuitBreaker("pricing");        // one instance per backend
pricing.executeSupplier(() -> "price 19.99");                       // unaffected

Source: Resilience4j: CircuitBreaker and the CircuitBreakerRegistry at v2.4.0. In the test the shared breaker opened after four failures and rejected the next call with CallNotPermittedException, while the separate pricing breaker answered. The granularity can be finer than the service: a dependency that has a fast endpoint and a slow one (a search and a bulk export on the same host) needs one breaker for each, or the slow one dictates the state of the fast one. The reverse also holds: one breaker per method of a client that talks to a single host multiplies state without adding isolation, which is what the OpenFeign integration does by default (see Gateway & OpenFeign).

Names are also a security and capacity matter. The registry creates an unlisted instance on first use, with the default configuration, and the Actuator write operation does the same for any name it is given. A name built from user input (a tenant id, an ISBN) therefore creates unbounded state; keep the names to a fixed list in the YAML. The configuration model is described in Registries, Configuration & Decorators.

Retry amplification across layers

A retry is multiplication. If the gateway retries three times, the Bookshelf service retries three times, and its pricing client retries three times, then a dependency that always fails receives 3 x 3 x 3 = 27 calls for every request that entered the system, exactly when it is least able to answer them.

flowchart LR U([1 user request]) --> G["gateway
3 attempts"] G -->|3 calls| S["Bookshelf service
3 attempts"] S -->|9 calls| C["pricing client
3 attempts"] C -->|27 calls| P[("pricing-service
already failing")]

The measure is not a theory. Three nested Retry decorators with three attempts each reached the failing supplier exactly 27 times in the test below, and a single user request with two attempts produced two entries in the breaker’s window.

Three layers, three attempts each
AtomicInteger backendCalls = new AtomicInteger();
Supplier<String> backend = () -> { backendCalls.incrementAndGet(); throw new IllegalStateException("503"); };
Supplier<String> client = decorate("client", backend);
Supplier<String> service = decorate("service", client);
Supplier<String> gateway = decorate("gateway", service);
// gateway.get() throws; backendCalls is 27

private static Supplier<String> decorate(String name, Supplier<String> inner) {
    return Retry.decorateSupplier(Retry.of(name, RetryConfig.custom().maxAttempts(3)
        .waitDuration(Duration.ofMillis(1)).build()), inner);
}

Source: Resilience4j: Retry. Run on Resilience4j 2.4.0: backendCalls was 27.

The defences, in order of effectiveness:

  • Retry in one layer only. Decide which tier owns retries (usually the one closest to the dependency, which knows the error semantics) and configure every other tier, including the gateway, a service mesh and the HTTP client’s own retry option, with a single attempt. The same rule applies inside the JVM: Spring Framework 7 @Retryable and Resilience4j @Retry on one method are two layers, and 3 x 3 gave 9 calls in the runs described in Spring Framework Native Resilience.

  • Keep maxAttempts low (two or three) and put a circuit breaker inside the retry (the default aspect order does so), so that once the breaker is open the retries stop at the first rejection. That is why the example above ignores CallNotPermittedException in the retry.

  • Back off with jitter, so that thousands of clients that failed together do not retry together (Retry has the exponential and randomized interval functions).

  • Retry only what the server marks as retryable: connection failures, 503, 429 with Retry-After. A 400, a 404 or a business error will fail the same way every time.

  • Remember that Resilience4j has no retry budget. There is no built-in "at most ten percent of calls may be retries". Where you need it, count retries in a MeterRegistry and alarm on the ratio, or let a proxy that supports retry budgets own the retries.

The retries also inflate the circuit breaker’s statistics, because with Retry outside the breaker every attempt is recorded: the failure rate of a window is a rate of attempts, not of user requests, and the minimumNumberOfCalls of 100 means 50 failed requests when each makes two attempts. Account for it when you read the dashboards.

Never retry what is not idempotent

A retry assumes that running the call twice is the same as running it once. That is true for GET, PUT and DELETE by the definition of HTTP and false for a POST that charges a card, sends an email or creates an order, because a timeout does not mean the call failed: the server may have done the work and lost only the answer.

Retrying a call whose answer was lost charges three times
AtomicInteger charges = new AtomicInteger();
Retry retry = Retry.of("orders", RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(5)).build());
Supplier<String> charge = Retry.decorateSupplier(retry, () -> {
    charges.incrementAndGet();                                         // the payment is taken ...
    throw new IllegalStateException("read timeout");                  // ... but the answer is lost
});
// charge.get() throws after three attempts; charges is 3

Source: RFC 9110: HTTP Semantics (section 9.2.2, idempotent methods) and Resilience4j: Retry. Run: charges was 3. The options are to not retry the operation, to retry it only on failures that prove the server did not start the work (connection refused, a 429 or 503 returned before processing, listed in retryExceptions), or to make the operation idempotent with an idempotency key that the server uses to deduplicate. The theory is in Resilience Fundamentals.

Timeouts: the inner one must be shorter than the outer one

Every tier that waits needs a timeout, and the timeouts must nest: the call to the dependency must give up before the caller of your service does. If the gateway gives up after 3 seconds while the Bookshelf service is still waiting for its dependency for 5, the service keeps working for a client that is already gone, holds a thread, a connection and a database transaction, and a retrying gateway then adds a second copy of the same work. The work that nobody waits for is pure waste, and at load it is the work that causes the outage.

flowchart TB subgraph GW["gateway timeout: 3000 ms"] subgraph SVC["Bookshelf deadline: 2500 ms"] subgraph PR["pricing, worst case: 1100 ms"] A1["attempt 1: 500 ms"] --> W1["wait 100 ms"] --> A2["attempt 2: 500 ms"] end subgraph IS["isbn, worst case: 900 ms"] B1["attempt 1: 350 ms"] --> W2["wait 200 ms"] --> B2["attempt 2: 350 ms"] end PR --> IS end end

The budget arithmetic is simple and should be written down for each endpoint. The worst case of one decorated call is the number of attempts times the timeout, plus the waits between them. The calls made one after another add up, and the sum has to stay under the tier above, with room to spare.

The worst case and the concurrency at the timeout, as code
public final class TimeoutBudget {

    private TimeoutBudget() {
    }

    /** Worst case of one call decorated with a retry: every attempt runs into its timeout. */
    public static Duration worstCase(int attempts, Duration perAttemptTimeout, Duration waitBetweenAttempts) {
        return perAttemptTimeout.multipliedBy(attempts)
                .plus(waitBetweenAttempts.multipliedBy(attempts - 1));
    }

    /** Calls reaching the backend for one user request when every layer retries. */
    public static long amplification(int... attemptsPerLayer) {
        long calls = 1;
        for (int attempts : attemptsPerLayer) {
            calls *= attempts;
        }
        return calls;
    }

    /** Concurrent calls needed when a backend answers every call only at its timeout (Little's law). */
    public static int concurrencyAtTimeout(int requestsPerSecond, Duration timeout) {
        return (int) Math.ceil(requestsPerSecond * timeout.toMillis() / 1000.0);
    }
}

Source: the arithmetic of Resilience4j: TimeLimiter and Retry. In the test, worstCase(2, 500 ms, 100 ms) is 1100 ms, worstCase(2, 350 ms, 200 ms) is 900 ms (2000 ms together, under the 2500 ms deadline and the 3000 ms gateway timeout), amplification(3, 3, 3) is 27 and concurrencyAtTimeout(50, 500 ms) is 25. The formula was also checked against a clock: a TimeLimiter of 200 ms inside a Retry of three attempts with a 50 ms wait, around a call that never completes, failed after 733 ms where the formula says 700 ms.

Rules that follow from the nesting:

  • Set the timeouts bottom-up. Start from the dependency (read timeout a little above the p99, see the table above), then compute the worst case of the call with its retries, then give the endpoint a deadline above that and the gateway a timeout above the deadline.

  • Use a TimeLimiter or the HTTP client timeout on every remote call. A blocking RestClient call is bounded by spring.http.clients.read-timeout; a CompletionStage or a Mono is bounded by a TimeLimiter. A Resilience4j @TimeLimiter cannot decorate a plain blocking method (see TimeLimiter for the supported return types).

  • Remember that a timeout only stops the waiting. The default cancelRunningFuture interrupts the future, but the remote work usually continues; the dependency has to be protected from that by the bulkhead and by its own limits.

  • Put the TimeLimiter inside the circuit breaker, as the default aspect order does, so that a timeout is recorded as a failure. The next section shows what happens when it is missing.

A breaker around a call that never returns sees nothing until a timeout is added inside it
Supplier<CompletionStage<String>> hanging = () -> new CompletableFuture<>();   // never completes

CircuitBreaker noTimeout = CircuitBreaker.of("noTimeout", TIGHT);
for (int i = 0; i < 4; i++) {
    CircuitBreaker.decorateCompletionStage(noTimeout, hanging).get();
}
// state CLOSED, getNumberOfBufferedCalls() is 0: four calls are in flight and the breaker has recorded nothing

CircuitBreaker withTimeout = CircuitBreaker.of("withTimeout", TIGHT);
TimeLimiter limiter = TimeLimiter.of(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(50)).build());
Supplier<CompletionStage<String>> guarded = CircuitBreaker.decorateCompletionStage(withTimeout,
    limiter.decorateCompletionStage(scheduler, hanging));                      // TimeLimiter inside the breaker
// four calls fail with TimeoutException after 50 ms each; the breaker is OPEN

Source: Resilience4j: TimeLimiter and the resilience4j-circuitbreaker module at v2.4.0. Run: the first breaker stayed CLOSED with no recorded calls while four calls hung; the second one opened after four timeouts.

Load shedding

When demand is higher than capacity, the service has two choices: queue everything and answer everyone late, or refuse early the requests it cannot serve and answer the others on time. Latency grows without bound in the first case, the timeouts above expire, retries arrive, and the service goes down for everybody. Shedding keeps the service in the second case, and three Resilience4j patterns are its tools:

  • Bulkhead with maxWaitDuration: 0 rejects at once when maxConcurrentCalls calls are in flight. A positive wait is a queue, and a queue adds latency; keep it zero in front of an interactive endpoint.

  • RateLimiter with timeoutDuration: 0 rejects at once when the period’s permits are used. The default timeout of five seconds parks the caller for that time before the rejection (see RateLimiter).

  • The open CircuitBreaker rejects without calling the dependency, which sheds the load on the dependency.

The rejection must be turned into an answer the client can act on: 503 Service Unavailable with a Retry-After header, or 429 Too Many Requests when a particular client is over its quota. A @RestControllerAdvice does it in one place:

Turning the three rejection exceptions into 503 with Retry-After
@RestControllerAdvice
class OverloadAdvice {

    @ExceptionHandler({BulkheadFullException.class, RequestNotPermitted.class, CallNotPermittedException.class})
    ResponseEntity<ProblemDetail> shed(RuntimeException e) {
        ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
                "The service is shedding load, retry later");
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, "2")
                .body(body);
    }
}

Source: RFC 9110: HTTP Semantics (503 and Retry-After) and the Spring Framework: Error Responses. Run on Spring Boot 4.1.1 with all 25 permits of the pricing bulkhead taken: GET /books/1/price answered 503 with Retry-After: 2 and the problem detail The service is shedding load, retry later; after the permits were released the same request answered 200.

Shedding only works if the clients cooperate. A client that retries a 503 immediately turns the shedding into a retry storm, which is why the Retry-After header and the backoff with jitter of the previous sections are part of the same design. Also keep the container limits in line: the maxConcurrentCalls of one bulkhead has to be smaller than the thread pool of the server (server.tomcat.threads.max), or the bulkhead never fills and the threads run out first; with virtual threads there is no such limit, and the bulkhead is the only thing that bounds the concurrency of a dependency (see Bulkhead). The Spring Framework 7 @ConcurrencyLimit is the same idea without events or metrics.

A fallback must not hide the outage

A fallback turns an exception into a value, and from that moment the caller, the retry and the circuit breaker outside the fallback see a success. A fallback that returns an empty list or a stale price without a trace makes an outage invisible: no errors in the dashboards, no alerts, and a business that discovers days later that it has been showing wrong prices. Four rules keep a fallback honest:

  • Mark the degraded answer in the result (PriceQuote.degraded) so the caller and the user interface can say so.

  • Count it. A counter of fallback invocations is the metric to alert on, because the breaker’s own failure metrics may be hidden by the fallback.

  • Log it with the cause, at WARN, not at DEBUG.

  • Catch only what you can handle. A fallback for RestClientException and CallNotPermittedException degrades; a BulkheadFullException is not handled, and so reaches the OverloadAdvice above and becomes a 503.

A fallback that degrades visibly
@Service
public class PricingClient {

    private static final Logger log = LoggerFactory.getLogger(PricingClient.class);

    private final PricingBackend backend;
    private final Counter degraded;

    PricingClient(PricingBackend backend, MeterRegistry meters) {
        this.backend = backend;
        this.degraded = Counter.builder("bookshelf.pricing.degraded")
                .description("Answers served by the pricing fallback")
                .register(meters);
    }

    // Retry is outermost, so the fallback sits outside the breaker and every attempt is counted by it.
    @Retry(name = "pricing", fallbackMethod = "degradedPrice")
    @CircuitBreaker(name = "pricing")
    @Bulkhead(name = "pricing")
    public PriceQuote price(String isbn) {
        return backend.fetch(isbn);
    }

    PriceQuote degradedPrice(String isbn, RestClientException cause) {
        return degraded(isbn, cause);
    }

    PriceQuote degradedPrice(String isbn, CallNotPermittedException cause) {
        return degraded(isbn, cause);
    }

    private PriceQuote degraded(String isbn, Exception cause) {
        degraded.increment();
        log.warn("pricing degraded for {}: {}", isbn, cause.toString());
        return new PriceQuote(isbn, 0L, true);
    }
}

Source: Resilience4j: Spring Boot 2 & 3 (fallback methods) and Fallbacks & Composition. Run: with the backend failing, the call answered {"isbn":"…​","cents":0,"degraded":true}, the counter bookshelf.pricing.degraded increased and the warning was logged; with the bulkhead full, the same method did not degrade and the caller got the 503 above. The fallback is declared on the outermost annotation (@Retry) so that the retries and the breaker see the real failures; a fallback on the @CircuitBreaker makes the retry see a success and never retry.

Where resilience lives: in code, in the gateway or in a mesh

The same patterns exist outside the JVM. A gateway (Spring Cloud Gateway, Envoy, an ingress) can time out, retry, rate-limit and open a circuit for a route, and a service mesh such as Istio does it for every call between pods with sidecar proxies and no code change. The table in Resilience Fundamentals compares them; for production the decision comes down to what each location knows:

Location Strong at Weak at

In the code (Resilience4j, Spring Framework 7)

Business knowledge: which exceptions count, which results are failures, what the fallback returns, which calls are idempotent. Per-method granularity and typed results.

Every service has to adopt it and keep it configured; polyglot fleets get different behaviour per language; changes ship with a release.

Gateway

Protecting the edge: global and per-client rate limits, route timeouts, one fallback response per route.

Knows nothing about the internal calls after the edge, and sees HTTP status codes, not business outcomes.

Mesh or sidecar

Uniform timeouts, retries and outlier detection for all pods and all languages, changed by configuration with no release; mutual TLS and metrics for free.

Does not know the body of a call, so cannot tell an idempotent POST from a dangerous one, and cannot run a fallback.

The practical rule is one owner for each concern. Let the mesh or the gateway own the uniform ones (connection limits, outlier ejection, the outer timeout), keep the semantic ones in code (the fallback, the exception classification, the bulkhead around a specific client), and make sure that only one of them retries, because otherwise the multiplication of the retry section returns with two layers of owners that do not know about each other. The gateway side is covered in Gateway & OpenFeign. How the platform reports a service as unhealthy, and what it does about it, is the subject of Health Probes & Lifecycle, and the mesh layer is introduced in Cluster Networking, CNI and Service Mesh.

Runbook: forcing a breaker open during maintenance

Sometimes the right move is to open the breaker yourself: the pricing database is being migrated, a third party announced maintenance, or an incident needs the traffic to stop before the failure rate says so. A forced-open breaker rejects every call immediately, whatever the dependency does, and the callers fall back or answer 503. Resilience4j exposes the transition over Actuator, and the endpoint is a write operation on production behaviour, so the procedure is: secured requests only, on the management port, from an operator account with the OPS role defined in Observability. The password comes from the environment of the operator’s shell or a secret store; it is never typed into the command, a script or a ticket.

stateDiagram-v2 [*] --> CLOSED CLOSED --> FORCED_OPEN : POST FORCE_OPEN FORCED_OPEN --> CLOSED : POST CLOSE CLOSED --> DISABLED : POST DISABLE DISABLED --> CLOSED : POST CLOSE FORCED_OPEN --> DISABLED : POST DISABLE CLOSED --> CLOSED : reset() in code

Before: list the names

The write operation creates an instance for a name that does not exist, so a typo makes a permanent, empty breaker. List the names first, from the collection endpoint. A GET on /actuator/circuitbreakers/<name> answered 405 in the run: only the collection is readable.

Step 1: list the breakers and their states
curl -s -u "ops:${ACTUATOR_PASSWORD}" http://localhost:8081/actuator/circuitbreakers

Source: Spring Boot: Endpoints. The answer has one object per breaker under circuitBreakers, with state, failureRate, bufferedCalls and notPermittedCalls.

Open it

Step 2: force the breaker open
curl -s -X POST http://localhost:8081/actuator/circuitbreakers/pricing \
     -u "ops:${ACTUATOR_PASSWORD}" \
     -H 'Content-Type: application/json' \
     -d '{"updateState":"FORCE_OPEN"}'
{"circuitBreakerName":"pricing","currentState":"FORCED_OPEN","message":"pricing state has been changed successfully"}

Source: the CircuitBreakerEndpoint at v2.4.0 and the run against the sample application started with ACTUATOR_PASSWORD in its environment. The value of the body is FORCE_OPEN, an instruction; the state that results is called FORCED_OPEN. Posting {"updateState":"FORCED_OPEN"}, the name of the state, answered 400 Bad Request, like OPEN and HALF_OPEN, because the body is bound to an enum of three values: CLOSE, FORCE_OPEN and DISABLE. Without credentials the request answered 401.

Verify, and tell everybody:

  • A call to the Bookshelf endpoint answered {"isbn":"9780134685991","cents":0,"degraded":true} and the backend was not called (the call counter of the fake backend did not move).

  • GET /actuator/circuitbreakers showed state FORCED_OPEN and notPermittedCalls growing.

  • GET /actuator/circuitbreakerevents/pricing listed the STATE_TRANSITION event "State transition from CLOSED to FORCED_OPEN".

  • /actuator/health stayed UP in the run: the health indicator reports a forced-open breaker as UNKNOWN (see Observability), so do not use the health endpoint to confirm the change.

Three things make the procedure less simple than it looks:

  • The change is local to one JVM. Each replica has its own registry, so the request has to reach every pod. A load-balanced URL changes one pod; script the loop over the pod addresses, or use the declarative alternative below.

  • The change is in memory. A restart, a redeploy or a pod rescheduled in the middle of the maintenance starts the breaker in CLOSED again. For a maintenance that spans a deployment, set initialState: FORCED_OPEN (2.4.0) in the configuration of the instance, deploy, and remove it afterwards (see CircuitBreaker).

  • A forced-open breaker is easy to forget. It rejects everything until somebody closes it. Put the close step in the same ticket, and alert on a breaker that stays in FORCED_OPEN (the resilience4j_circuitbreaker_state gauge).

Close it again

Step 3: return the breaker to normal
curl -s -X POST http://localhost:8081/actuator/circuitbreakers/pricing \
     -u "ops:${ACTUATOR_PASSWORD}" \
     -H 'Content-Type: application/json' \
     -d '{"updateState":"CLOSE"}'
{"circuitBreakerName":"pricing","currentState":"CLOSED","message":"pricing state has been changed successfully"}

Source: the same endpoint and run. The meaning of the three accepted values:

updateState Resulting state Use it for

FORCE_OPEN

FORCED_OPEN: every call is rejected with CallNotPermittedException until somebody changes the state.

Maintenance, or stopping traffic to a dependency before it fails by itself.

CLOSE

CLOSED: calls flow, and the sliding window starts empty. The breaker does not probe the dependency first: the whole traffic returns at once.

Ending a maintenance, or a forced-open breaker; returning from DISABLED.

DISABLE

DISABLED: every call is allowed and nothing is recorded, so the breaker can never open. In the run, 150 failing calls left the breaker DISABLED with 0 buffered calls.

Taking a misbehaving breaker out of the way during an incident; never a normal state.

Because CLOSE sends all the traffic back to a dependency that may still be fragile, close it when the dependency is confirmed healthy, preferably after reducing the incoming load, and keep the bulkhead and the rate limiter in place: they absorb the first wave. In the run, after CLOSE the 100 following failures filled the window and the breaker opened by itself, which is the behaviour you want when the dependency is not really back.

Reset is the fourth way back, and it is not on the Actuator endpoint: CircuitBreaker.reset() returns the breaker to CLOSED and clears its metrics, from code (an admin controller, a test, a scheduled job). In the run it moved an OPEN breaker with 100 buffered calls to CLOSED with 0. There is no HTTP operation for OPEN, HALF_OPEN or reset.

To prevent the misuse of this power, apply the three layers of the observability page: take the write operation away where nobody needs it (management.endpoint.circuitbreakers.access: read-only), give the POST to one role, and put the endpoint on a management port that the platform does not expose to users.

Health: breakers must not restart the pod

An open breaker means "this dependency is failing", not "this process is broken". Wiring that signal to the liveness probe makes Kubernetes kill every healthy pod during a dependency outage, which restarts them into the same outage and adds a cold start to it. The Resilience4j health indicator is not a liveness signal; its own default makes that easy to get right, because the default StatusAggregator ignores the custom CIRCUIT_OPEN status (see Observability). The way to get it wrong is to include the indicator in the liveness group:

Liveness and readiness groups: what not to do
management:
  endpoint:
    health:
      group:
        liveness:
          include: livenessState,circuitBreakers     # anti-pattern: restarts every pod when a dependency is down
        readiness:
          include: readinessState

Source: Spring Boot: Kubernetes probes and Kubernetes: Liveness, Readiness and Startup Probes. Run: with allowHealthIndicatorToFail: true on the pricing breaker and the breaker open, this configuration answered 503 on /actuator/health/liveness. With the default groups (liveness is livenessState only) the same open breaker left /actuator/health/liveness and /actuator/health/readiness at 200, while /actuator/health answered 503 because of allowHealthIndicatorToFail. Leave liveness to the process itself, let the breaker degrade the behaviour and not the probe, and, if a dependency is genuinely required to serve any traffic, consider it in readiness only, with the consequences described in Health Probes & Lifecycle.

Anti-patterns

Table 2. The mistakes, why they hurt and the fix
Anti-pattern What happens Fix

Shared instances: one breaker, bulkhead or retry name for several backends

The failure of one dependency opens the breaker of all (the backend test above), and one slow dependency uses the whole bulkhead.

One named instance per backend, listed in the YAML; no names built from user input.

Retrying non-idempotent calls

Three attempts of a POST charged three times; a timeout does not prove the call failed.

Do not retry them, retry only failures that prove nothing started, or use an idempotency key.

A retry in every layer

Gateway, service and client with three attempts each send 27 calls to a failing dependency.

One retrying tier, a low maxAttempts, a breaker inside the retry, backoff with jitter.

A circuit breaker on every call

One breaker per method or per in-memory call adds state, metrics and noise and protects nothing: a breaker is for a remote dependency that can fail or hang. A breaker on each endpoint of one host fragments the window, so none of them ever reaches minimumNumberOfCalls.

Breakers at the boundary with each remote dependency, sized from its traffic; none around local computation.

A fallback that hides the outage

The fallback returns an empty value and the caller, the retry and the breaker see a success: no alarms, wrong data.

Mark the degraded result, count and log fallbacks, catch only the exceptions you can handle (the PricingClient above).

A circuit breaker without timeouts

A call that hangs is never recorded, so the breaker stays CLOSED while threads pile up (the noTimeout test above).

A read timeout or a TimeLimiter inside the breaker on every remote call.

Timeouts longer than the caller’s

The service keeps working for a client that has gone, and the gateway’s retry doubles the work.

Compute the worst case of each endpoint and keep the inner timeouts below the outer ones.

Health wired to liveness

An open breaker makes the liveness probe fail and the platform restarts every healthy pod.

Liveness is livenessState only; breakers go to dashboards, and at most to readiness.

Thresholds left at the defaults

A 60 s slow-call threshold never fires, a window of 100 calls reacts in minutes on a quiet backend and in milliseconds on a busy one, and a minimumNumberOfCalls of 100 never opens on a backend with 2 calls per minute.

Derive them from the p99, the request rate and the baseline errors, as in the first section, and review them.

Unbounded queues

A bulkhead with a long maxWaitDuration or a rate limiter with the default five-second timeoutDuration parks callers and turns an overload into latency.

Zero wait on interactive paths; reject early and answer 503 with Retry-After.

Trusting an annotation that is not applied

Without spring-boot-starter-aspectj, @CircuitBreaker is a silent no-op on Spring Boot 4: no proxy, no aspect, no error.

Test that the breaker opens (see Testing) and read Getting Started.

A production checklist

  • Every remote call has a timeout, and the timeouts nest from the dependency to the gateway.

  • Every dependency has its own breaker, bulkhead and retry names, set from its p99, request rate and baseline errors.

  • Exactly one tier retries, with a low maxAttempts, jitter, and CallNotPermittedException and BulkheadFullException ignored.

  • Non-idempotent operations are not retried unless they carry an idempotency key.

  • Rejections become 503 or 429 with Retry-After; the fallbacks are visible (marked, counted, logged).

  • The circuitbreakers write operation is secured or read-only, and the runbook lists every pod and the close step.

  • Liveness contains livenessState only.

  • Dashboards and alerts cover the state gauge, the not-permitted calls, the fallback counter and the retry ratio (Observability).

  • The configuration is exercised in tests before each release (Testing).

What changed recently

  • Resilience4j 2.4.0 (14 March 2026): initialState, transitionToStateAfterWaitDuration, the configuration attribute of the annotations and the bulkheadAspectOrder setter are new tools for the runbooks and the ordering above. A TimeLimiter over a CompletionStage now counts a null result as a success (PR 2374). The release notes are in the GitHub release; the full list is in Migration & What’s Changed.

  • Spring Boot 4. The HTTP client timeouts are spring.http.clients.connect-timeout and read-timeout; the singular keys of Boot 3 do not work on 4.1.1. spring-boot-starter-aspectj replaces the AOP starter. The EndpointRequest matcher moved to org.springframework.boot.security.autoconfigure.actuate.web.servlet.

  • Spring Framework 7. @Retryable and @ConcurrencyLimit are part of the framework; they add a second retry tier if combined with @Retry (9 calls), so choose one per method (see Spring Framework Native Resilience).

  • Actuator state-transition text. The circuitbreakerevents message reads "State transition from CLOSED to OPEN" on Boot 4.1.1 and CLOSED_TO_OPEN on Boot 3.5.16; match on the stateTransition structure in alerts, not on the text.