Programmatic Usage

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.

Annotations are the shortest way to protect a method, but they only see a method call: they cannot see the HTTP status line of the response, they cannot differ per request, and they do not work on code that is not a Spring bean. Using the registries and decorators directly puts the resilience where the failure actually happens — around the HTTP call itself — and works on every client. This page shows how, for the four ways a Spring Boot application calls another service: RestClient, WebClient, the JDK HttpClient and Spring HTTP interface clients (@HttpExchange). It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21, and every example on it was compiled and run against WireMock stubs on 4 October 2026. The Java API is the same on Spring Boot 3.5.x (starter resilience4j-spring-boot3, Java package io.github.resilience4j.springboot3); the two Boot-specific differences are noted where they occur.

The model used here (registries, configurations, decorateXxx, Decorators) is explained in Registries, Configuration & Decorators and is not repeated. How the HTTP clients themselves are built and used is covered by REST APIs and Reactive Programming; this page only adds resilience to them. The setup (starter, spring-boot-starter-aspectj for annotations) is in Getting Started; programmatic use does not need the aspect starter at all.

In the running Bookshelf scenario, bookshelf-service calls pricing-service with RestClient, reviews-service with WebClient, the third-party ISBN-metadata API with the JDK HttpClient, and recommendations-service through an @HttpExchange interface. Each downstream gets its own instances, named after it.

Injecting the registries

With the starter on the classpath, the registries are ordinary Spring beans, created from application.yml at startup (see Registries in Spring Boot). Inject the ones you need and ask them for an instance by name: the name is the key under resilience4j.<module>.instances, and an instance listed in the YAML already exists when your bean is created. This is the configuration used by every example on this page:

Shared HTTP configuration and one instance per downstream (g5.yml)
resilience4j:
  circuitbreaker:
    configs:
      http:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 4
        minimumNumberOfCalls: 4
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        recordResultPredicate: com.example.bookshelf.http.HttpFailureResult
    instances:
      pricing:
        baseConfig: http
      recommendations:
        baseConfig: http
      reviews:
        baseConfig: http
  bulkhead:
    instances:
      pricing:
        maxConcurrentCalls: 10
      recommendations:
        maxConcurrentCalls: 4
        maxWaitDuration: 0
  retry:
    instances:
      pricing:
        maxAttempts: 3
        waitDuration: 100ms
        retryExceptions:
          - java.io.IOException
      recommendations:
        maxAttempts: 1

Source: Resilience4j: Spring Boot 2 & 3 (configs, instances, baseConfig) and the v2.4.0 CommonCircuitBreakerConfigurationProperties source (recordResultPredicate).

In the run, the CircuitBreakerRegistry held exactly pricing, recommendations and reviews after startup, the pricing breaker had the window of 4 from the shared http configuration, and the retry for pricing had 3 attempts. The recordResultPredicate key is explained under Counting 5xx and 429 as failures.

A service that decorates its own calls with the injected registries
@Service
class PricingService {

    private final PricingClient client;
    private final CircuitBreaker breaker;
    private final Retry retry;

    PricingService(PricingClient client, CircuitBreakerRegistry breakers, RetryRegistry retries) {
        this.client = client;
        this.breaker = breakers.circuitBreaker("pricing");     // created from the YAML at startup
        this.retry = retries.retry("pricing");
    }

    Money priceOf(String isbn) {
        // run it now: Retry outside, CircuitBreaker inside
        return Decorators.ofSupplier(() -> client.fetch(isbn))
            .withCircuitBreaker(breaker)
            .withRetry(retry)
            .withFallback(List.of(CallNotPermittedException.class), t -> Money.unknown())
            .get();
    }

    Supplier<Money> priceOfLater(String isbn) {
        // build it now, run it later (decorateSupplier returns a Supplier)
        return CircuitBreaker.decorateSupplier(breaker, () -> client.fetch(isbn));
    }

    CompletionStage<Money> priceAsync(String isbn) {
        // a call that already returns a CompletionStage
        return breaker.executeCompletionStage(() -> client.fetchAsync(isbn));
    }
}

Source: Resilience4j: CircuitBreaker examples (executeSupplier, decorateSupplier, executeCompletionStage) and the v2.4.0 CircuitBreaker source.

Look the instance up once, in the constructor, and keep it in a field. Calling breakers.circuitBreaker("pricing") on every request works (the registry returns the same object) but spreads names through the code, and a misspelled name silently creates a new instance from the default configuration instead of failing. The same applies to the other registries; on a name that the YAML does not declare, retries.retry("pricng") quietly gives you a three-attempt retry from the default.

Where to put the decoration

An HTTP client has three places where a decorator can go, and the choice decides what the breaker can see.

Layer What the decorator sees Typical use

Around your method (executeSupplier) → client.fetch(isbn)

The method’s return value or its exception. With RestClient and @HttpExchange a 5xx has already been turned into HttpServerErrorException, so you classify exceptions.

Business-level calls, mixed sources (a cache, then HTTP), code that is not an HTTP client.

Inside the client (interceptor, filter, wrapper)

The raw response, before Spring maps a status to an exception. You can classify by status code and read headers.

Every call made through that client, including calls made by code that does not know about resilience.

Around the transport (a custom ClientHttpRequestFactory, a Netty handler)

Connections and bytes.

Rarely worth it; use the client’s own timeouts and connection pool instead.

This page uses the second layer for HTTP, one decorated client per downstream service, because it is the only layer where a 503 is still a response and not an exception. The next figure shows the chain for the Bookshelf pricing call.

flowchart LR S[PricingService] --> C[RestClient pricing] C --> I[Interceptor for pricing] subgraph chain [One chain per downstream] direction LR R[Retry pricing] --> B[Bulkhead pricing] --> CB[CircuitBreaker pricing] end I --> R CB --> H[HTTP call to pricing-service] H --> RS{{ClientHttpResponse}} RS --> P{status 5xx or 429?} P -->|yes| F[recorded as a failure] P -->|no| OK[recorded as a success] F --> RET[response returned unchanged] OK --> RET RET --> M[RestClient maps 4xx and 5xx to exceptions] M --> S

Counting 5xx and 429 as failures

By default a circuit breaker records a call as a failure when it throws. An HTTP client that returns a response object for a 503 or a 429 did not throw, so the breaker sees a success: the verified run sent six 503 answers through a RestClient interceptor with a default breaker and the breaker stayed CLOSED with four recorded successes and no failures. recordResultPredicate closes that gap: it is evaluated on the value the decorated call returns, and a match records a failure (the event carries a ResultRecordedAsFailureException) while the value is still returned unchanged. The circuit-breaker page explains the rule in What counts as a failure; here it is applied to HTTP.

The predicate is a Predicate<Object>, so one class can serve every client in the application: it matches the response type each of them produces.

One predicate for every HTTP client: 5xx and 429 are failures
public class HttpFailureResult implements Predicate<Object> {

    @Override
    public boolean test(Object result) {
        int status = switch (result) {
            case ClientHttpResponse response -> statusOf(response);          // RestClient interceptor
            case ClientResponse response -> response.statusCode().value();   // WebClient filter
            case HttpResponse<?> response -> response.statusCode();          // JDK HttpClient
            case ResponseEntity<?> entity -> entity.getStatusCode().value();
            default -> -1;
        };
        return status >= 500 || status == 429;
    }

    private static int statusOf(ClientHttpResponse response) {
        try {
            return response.getStatusCode().value();
        } catch (IOException e) {
            return 599;   // an unreadable status line is a failure
        }
    }
}

Source: Resilience4j: CircuitBreaker (recordResultPredicate) and the v2.4.0 CircuitBreakerConfig source (recordResult).

Two ways to attach it. In YAML, name the class under recordResultPredicate, as in the configuration above; the class is created by reflection through its public no-argument constructor, so it is a plain object, not a Spring bean. In Java, pass an instance to the builder:

The same predicate on a programmatically built configuration
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .slidingWindowSize(4).minimumNumberOfCalls(4).failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .recordResult(new HttpFailureResult())          // 5xx and 429 on the returned response
    .build();
CircuitBreaker breaker = CircuitBreakerRegistry.of(config).circuitBreaker("pricing");

Source: Resilience4j: CircuitBreaker (recordResult on the config builder).

The mapping applies to the breaker only. A 404 or a 400 is the caller’s problem, not the dependency’s, so it is a success for the breaker; in the verified run the sequence 503, 429, 404, 200 gave two failures out of four and opened a breaker with a 50 per cent threshold. A 429 is deliberately a failure here: the downstream is telling you to slow down, and opening the breaker stops you from hammering it. If the upstream sends Retry-After, the limiter pages (Rate Limiter) and the retry page (Retry) show how to honour it.

RestClient: a ClientHttpRequestInterceptor

RestClient runs every request through its ClientHttpRequestInterceptor chain. The interceptor receives the request, the body and an execution; calling execution.execute(request, body) performs the call and returns the raw ClientHttpResponse, before the retrieve() status handlers turn a 4xx or a 5xx into an exception. That makes the interceptor the right place for a breaker, a bulkhead and a retry: they decorate execution.execute with Decorators.ofCheckedSupplier, because the call throws IOException.

An interceptor that owns one downstream’s retry, bulkhead and circuit breaker
public class ResilienceInterceptor implements ClientHttpRequestInterceptor {

    private final CircuitBreaker breaker;
    private final Bulkhead bulkhead;
    private final Retry retry;

    public ResilienceInterceptor(CircuitBreaker breaker, Bulkhead bulkhead, Retry retry) {
        this.breaker = breaker;
        this.bulkhead = bulkhead;
        this.retry = retry;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {
        CheckedSupplier<ClientHttpResponse> call = Decorators
            .ofCheckedSupplier(() -> execution.execute(request, body))
            .withCircuitBreaker(breaker)     // innermost: sees every attempt
            .withBulkhead(bulkhead)
            .withRetry(retry)                // outermost: retries only what the breaker lets through
            .decorate();
        try {
            return call.get();
        } catch (IOException | RuntimeException e) {
            throw e;                         // CallNotPermittedException, BulkheadFullException, I/O errors
        } catch (Throwable t) {
            throw new IllegalStateException(t);
        }
    }
}

Source: Spring Framework: REST Clients (ClientHttpRequestInterceptor) and Resilience4j: CircuitBreaker examples (the Decorators builder).

Building a client with it, and what the run showed:

A RestClient for pricing-service with the interceptor
RestClient pricing = restClientBuilder
    .baseUrl("http://localhost:8081")
    .requestInterceptor(new ResilienceInterceptor(
        breakers.circuitBreaker("pricing"), bulkheads.bulkhead("pricing"), retries.retry("pricing")))
    .build();

Money price = pricing.get().uri("/price/{isbn}", isbn).retrieve().body(Money.class);

Source: Spring Framework: REST Clients. On Spring Boot 4 the injected RestClient.Builder bean comes from spring-boot-starter-restclient; with only spring-boot-starter-web there is none (on Boot 3.5 the web starter is enough).

  • With the HttpFailureResult predicate, the calls /price/503, /price/429, /price/404 and /price/ok threw HttpServerErrorException, HttpClientErrorException.TooManyRequests and HttpClientErrorException.NotFound, and returned the body, as RestClient always does; the breaker recorded two failures and two successes and opened.

  • The next call threw CallNotPermittedException straight out of retrieve().body(…​), not wrapped in a Spring exception, and WireMock received no request: an open breaker really does protect the downstream.

  • A CONNECTION_RESET_BY_PEER fault on the first attempt followed by a good answer was retried by the interceptor (retryExceptions: IOException), so the caller got the body, and the breaker recorded one failure and one success.

Three details are specific to interceptors. The retry replays execution.execute(request, body), which is safe because the body is a byte[]; do not retry non-idempotent requests (see Retry). A retry that is triggered by a result rather than an exception must close the discarded ClientHttpResponse itself, or the connection leaks, which is why the interceptor above retries on IOException only. And the interceptor blocks the calling thread while Retry sleeps, which is cheap on virtual threads and costly on a small platform-thread pool; see Concurrency Alternatives.

WebClient: an ExchangeFilterFunction

WebClient has no interceptor; its extension point is an ExchangeFilterFunction, which wraps next.exchange(request), a Mono<ClientResponse>. The resilience4j-reactor operators (CircuitBreakerOperator, BulkheadOperator, and the others, all applied with transformDeferred) turn the decorators into operators on that Mono; the operator catalogue and the order of operators are in Reactive, Kotlin & RxJava. The operator honours recordResultPredicate, so the same HttpFailureResult works on the ClientResponse.

A filter that decorates every exchange of the reviews-service client
ExchangeFilterFunction resilience = (request, next) -> next.exchange(request)
    .transformDeferred(CircuitBreakerOperator.of(breakers.circuitBreaker("reviews")))
    .transformDeferred(BulkheadOperator.of(bulkheads.bulkhead("reviews")));

WebClient reviews = webClientBuilder
    .baseUrl("http://localhost:8082")
    .filter(resilience)
    .build();

Flux<String> list = reviews.get().uri("/reviews/{isbn}", isbn).retrieve().bodyToFlux(String.class);

Source: Spring Framework: WebClient filters and Resilience4j examples (Reactor operators). On Spring Boot 4 the WebClient.Builder bean comes from spring-boot-starter-webclient.

In the run, /reviews/503 and /reviews/429 reached the caller as WebClientResponseException.ServiceUnavailable and TooManyRequests, two 200 answers followed, and the breaker was OPEN with two recorded failures; the next call failed with CallNotPermittedException and the bulkhead showed all five permits free again, because the permit taken by the outer bulkhead operator is released when the inner breaker rejects the call. With transformDeferred, the operator applied last is the outermost, so the order above makes the bulkhead outer and the breaker inner; swap the two lines to reject before queueing for a permit. Because the filter is applied per exchange, a new subscription is protected on each subscribe; there is nothing to reset.

The JDK HttpClient: wrap the call

java.net.http.HttpClient has no interceptor or filter. Decorate the call yourself, in a small wrapper class that is the only way the rest of the application reaches the downstream. HttpClient.send throws checked exceptions, so use the Checked* decorators (or executeCheckedSupplier); for sendAsync, which returns a CompletableFuture, use executeCompletionStage. In both cases the decorated value is an HttpResponse, and HttpFailureResult already understands it.

Blocking and non-blocking calls to the ISBN-metadata API
class IsbnApi {

    private final HttpClient http = HttpClient.newHttpClient();
    private final CircuitBreaker breaker;
    private final CheckedFunction<HttpRequest, HttpResponse<String>> send;

    IsbnApi(CircuitBreakerRegistry breakers) {
        this.breaker = breakers.circuitBreaker("isbn");
        this.send = CircuitBreaker.decorateCheckedFunction(breaker,
            request -> http.send(request, HttpResponse.BodyHandlers.ofString()));
    }

    HttpResponse<String> lookup(String isbn) throws Throwable {
        return send.apply(HttpRequest.newBuilder(URI.create("https://isbn.example.invalid/v1/" + isbn)).build());
    }

    CompletionStage<HttpResponse<String>> lookupAsync(String isbn) {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://isbn.example.invalid/v1/" + isbn)).build();
        return breaker.executeCompletionStage(
            () -> http.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
    }
}

Source: Java 21 java.net.http.HttpClient and Resilience4j examples (checked decorators, executeCompletionStage).

Unlike RestClient, the JDK client never turns a status into an exception, so every 503 and 429 is a response. In the run, with isbn configured with the HTTP predicate, the sequence 503, 429, 200, 200 returned the four responses to the caller (status codes 503, 429, 200, 200) and left the breaker OPEN with two failures; four 503 answers through sendAsync also opened the breaker, because executeCompletionStage evaluates the predicate when the future completes. Without the predicate neither would have opened it. Use ofString or another handler per call; the predicate looks at the status only and never touches the body.

Spring HTTP interface clients (@HttpExchange)

A @HttpExchange interface is a declarative client: Spring generates an implementation (an HttpServiceProxyFactory proxy) that delegates to a RestClient (or a WebClient). Because the proxy sits on top of the client, you have two places to add resilience, and you choose by the layer table above.

Resilience in the underlying client. Build the proxy on a RestClient that has the interceptor, and every method of the interface is covered, with the response available to the predicate.

An HTTP interface backed by a resilient RestClient
@HttpExchange("/recommendations")
public interface RecommendationsApi {

    @GetExchange("/{isbn}")
    List<String> forIsbn(@PathVariable String isbn);
}

RestClient client = RestClient.builder().baseUrl(baseUrl)
    .requestInterceptor(new ResilienceInterceptor(
        breakers.circuitBreaker("recommendations"), bulkheads.bulkhead("recommendations"),
        retries.retry("recommendations")))
    .build();

RecommendationsApi api = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client)).build()
    .createClient(RecommendationsApi.class);

Source: Spring Framework: HTTP Interface (HttpServiceProxyFactory, RestClientAdapter).

In the run, forIsbn("ok") returned the list, three 503 answers each threw HttpServerErrorException out of the proxy and opened the breaker (three failures out of four calls), and the next call threw CallNotPermittedException from the proxy.

Resilience around the proxy method. If the client is built elsewhere (a library, a group configured by Spring Boot) and you cannot reach its interceptors, decorate the call instead. The breaker now sees exceptions, not responses, so the 5xx and 429 rule has to be an exception predicate:

Decorating a proxy method, classifying by exception
CircuitBreakerConfig byException = CircuitBreakerConfig.custom()
    .slidingWindowSize(2).minimumNumberOfCalls(2)
    .recordException(t -> t instanceof ResourceAccessException
        || t instanceof HttpServerErrorException
        || t instanceof HttpClientErrorException.TooManyRequests)
    .build();

List<String> recommendations = breaker.executeSupplier(() -> api.forIsbn(isbn));

Source: Resilience4j: CircuitBreaker (recordExceptions, recordFailurePredicate). The equivalent YAML key is recordFailurePredicate.

Two failing calls opened the breaker in the run. Do not decorate both the client and the method for the same instance: each layer would record the same failed response, and the window would fill twice as fast.

One interceptor or filter per downstream

The Bookshelf services have different latencies, quotas and failure modes, so they must not share a breaker: if recommendations-service is down, pricing-service has to keep working. The rule that follows is one decorated client per downstream service, one instance of each pattern named after it. In the code above that is the argument to breakers.circuitBreaker("pricing"); shared settings live in a configs entry (http in the YAML) and each downstream only overrides what differs (a smaller bulkhead for the optional recommendations). Instance names then appear in the Actuator endpoints and in the metrics tags, so a dashboard can show pricing and recommendations separately.

When the clients are @HttpExchange interfaces grouped by Spring, Spring Boot gives you the per-downstream hook for free: a bean of type RestClientHttpServiceGroupConfigurer customizes the RestClient.Builder of each group, and the group name can be the Resilience4j instance name. The group’s base URL is a Boot property.

Registering the interface as a group and adding the interceptor to every group
@Configuration
@ImportHttpServices(group = "recommendations", types = RecommendationsApi.class)
class HttpServiceClients {

    @Bean
    RestClientHttpServiceGroupConfigurer resilientGroups(CircuitBreakerRegistry breakers,
            BulkheadRegistry bulkheads, RetryRegistry retries) {
        return groups -> groups.forEachClient((group, builder) -> builder.requestInterceptor(
            new ResilienceInterceptor(breakers.circuitBreaker(group.name()), bulkheads.bulkhead(group.name()),
                retries.retry(group.name()))));
    }
}

Source: Spring Framework: HTTP Interface (@ImportHttpServices, HttpServiceGroupConfigurer).

spring:
  http:
    serviceclient:
      recommendations:
        base-url: http://localhost:8083

Source: Spring Boot: Calling REST Services (HTTP service clients and groups; the spring.http.serviceclient.* properties).

In the run the injected RecommendationsApi bean was the proxy over the group’s client: four 503 answers opened the recommendations breaker and the fifth call threw CallNotPermittedException. This is the Boot 4 and Spring Framework 7 route (@ImportHttpServices, spring.http.serviceclient.*); on Spring Boot 3.5 build the proxy by hand with HttpServiceProxyFactory as in the previous example. For a group named differently from the instance, map one to the other explicitly; and note that retries.retry(group.name()) for a name that is not in the YAML silently creates a retry with the default three attempts, so declare every group in the configuration.

Programmatic or annotations?

Question Prefer annotations when Prefer programmatic use when

What does the code protect?

A Spring bean’s method with a simple return type.

An HTTP client, a library, code outside a Spring bean, or a call made from a lambda.

What must the pattern see?

The method’s exception or return value.

The HTTP response (status, headers) so that 5xx and 429 can be recorded, or a result predicate.

Is the instance fixed?

The instance name is a constant known at compile time.

The instance depends on the request (a tenant, a route) or on configuration read at runtime.

Order of the patterns

The default order (Retry, CircuitBreaker, RateLimiter, TimeLimiter, Bulkhead) is what you want.

You need a different order on one call, or a pattern that has no annotation (cache, hedge) in the same chain.

Proxy limits

The method is public, non-final and called from another bean (no self-invocation).

It is not: the call is a private method, a self-invocation or an object that Spring does not proxy; no proxy is involved at all.

Testing

A Spring context with the aspect starter is needed to exercise the annotation.

Plain unit tests: create the instance with CircuitBreaker.of(…​) and no Spring context.

The two styles combine: annotate the service methods and put the HTTP-level rules in the interceptor, which is what most applications end up doing. The details of annotations, including the aspect order that applies to them, are in Annotations & Aspect Order. Programmatic use does not depend on the aspect starter, so a library that must not force spring-boot-starter-aspectj on its users should use it. Testing the interceptors with WireMock, as the examples here were run, is the subject of Testing.

Pitfalls

Pitfall What happens and what to do

Decorating the client but not telling the breaker about 5xx

RestClient and the JDK client return a response, so the breaker records a success. Verified: six 503 answers left a default breaker CLOSED. Set recordResultPredicate (YAML) or recordResult (Java).

The same failure recorded twice

Decorating both the interceptor and the @HttpExchange method for one instance. Pick one layer per instance.

A mistyped instance name

registry.circuitBreaker("pricng") and retries.retry(…​) create a new instance from the default configuration, with no error. Declare every name in the YAML and keep the lookup in one place.

The interceptor wraps the exception

It does not: CallNotPermittedException and BulkheadFullException reached the caller unwrapped from RestClient, WebClient and the proxy. Handle them (a fallback, a @ExceptionHandler) where you want a degraded answer.

Retrying on a result without closing the response

A discarded ClientHttpResponse holds its connection. Retry on exceptions, or close the response before the next attempt.

Predicate class with injected dependencies

recordResultPredicate classes are built by reflection with a no-argument constructor and are not beans. Keep them stateless, or build the configuration in Java.

Reading the body in a result predicate

The predicate runs before the caller reads the body; consuming it there breaks the response. Look at the status and headers only.

One breaker for several downstreams

One slow dependency opens the circuit for all of them. One instance per downstream, a shared configs entry for the common settings.

Boot 4 starters

RestClient.Builder needs spring-boot-starter-restclient and WebClient.Builder needs spring-boot-starter-webclient. Without them the injection fails, unrelated to Resilience4j.

What changed recently

  • Spring Framework 7 and Spring Boot 4 added @ImportHttpServices, RestClientHttpServiceGroupConfigurer and the spring.http.serviceclient.* properties, so a group of HTTP interface clients can be configured, and wrapped with an interceptor, in one place.

  • Spring Boot 4 split the HTTP clients into their own starters (spring-boot-starter-restclient, spring-boot-starter-webclient); on Boot 3.5 the web and webflux starters provide the builders.

  • Resilience4j 2.1.0 made recordResultPredicate configurable from Spring Boot properties.

  • Resilience4j 2.4.0 adds resilience4j-spring-boot4; the registries, Decorators and the operators used on this page are unchanged from 2.3.x. The release notes are only on the GitHub release page.