Gateway & OpenFeign

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.

Two places in a Spring system call other services on behalf of a user: the gateway in front of them and the HTTP clients between them. Both can be wrapped in a circuit breaker without touching business code. This page covers the Spring Cloud Gateway CircuitBreaker filter on its two stacks (WebFlux and Web MVC), the edge rate limiter that ships with the WebFlux gateway, the circuit-breaker support of Spring Cloud OpenFeign, and the plain Resilience4jFeign module for Feign clients that do not use Spring Cloud. Everything on it that could be run was run against a stub backend, and the real status codes are reported in tables. What could not be run is stated in the text.

The page was written against Resilience4j 2.4.0, Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Cloud 2025.1.3 (Spring Cloud Gateway 5.0.3, Spring Cloud OpenFeign 5.0.3, Spring Cloud CircuitBreaker 5.0.3, Feign 13.6.1) and Java 21, and verified on 4 October 2026. Compared with a Spring Boot 3 service: the artifact and property names of the gateway were renamed (spring-cloud-starter-gateway-server-webflux and spring.cloud.gateway.server.webflux.routes here, which the Spring Cloud 2025.1 reference uses), the Resilience4j starter is resilience4j-spring-boot4, and Spring Cloud CircuitBreaker still pins Resilience4j 2.3.0 (see Spring Cloud CircuitBreaker). The Boot 3 forms were not run for this page. The vocabulary (fallback, timeout, bulkhead) is in Resilience Fundamentals; the circuit breaker itself is in CircuitBreaker.

In the running Bookshelf scenario the gateway sits in front of bookshelf-service, and bookshelf-service calls pricing-service with a Feign client. A slow or failing pricing-service must not take either of them down. The role of a gateway and when to split it into several is in API Gateway and Backends for Frontends; this page is only about the resilience filters.

What the gateway filter is

The gateway filter is not Resilience4j code. It is a thin adapter on the Spring Cloud CircuitBreaker abstraction: it asks a ReactiveCircuitBreakerFactory (WebFlux) or a CircuitBreakerFactory (Web MVC) for a breaker named by the route and runs the proxied call inside it. Resilience4j is the implementation that the starter puts behind the factory. Three consequences follow and the rest of the page demonstrates them.

  • Configuration is the one of the abstraction: instances in resilience4j.circuitbreaker. and resilience4j.timelimiter. are found by the id the filter passes, and the time limiter is on by default (one second, so a healthy slow route is cut off).

  • The filter decides what counts as a failure: an exception, a timeout and, only if you list them in statusCodes, a response status. An HTTP 500 that is not listed is a successful call as far as the breaker is concerned.

  • A fallback is not a method: it is a forward to another handler of the same gateway application, which can be a controller or another route.

flowchart TD A([Client request]) --> B[Route matched, StripPrefix applied] B --> C{CircuitBreaker pricingRoute} C -->|open| X[CallNotPermittedException] C -->|closed or half open| D[Proxy the call, time limiter 500 ms] D --> E{outcome} E -->|response, status not in statusCodes| R([Response passes through]) E -->|status listed in statusCodes| F[CircuitBreakerStatusCodeException] E -->|timeout| T[TimeoutException] E -->|connection error| N[Netty exception] F --> G T --> G N --> G X --> G{fallbackUri set?} G -->|yes| H[Store the exception in the exchange attribute, forward to /fallback/pricing] H --> I([Fallback handler answers, 200 in the example]) G -->|no, timeout| J([504]) G -->|no, circuit open| K([503]) G -->|no, other, resumeWithoutError false| L([500 from the gateway error handler]) G -->|no, other, resumeWithoutError true| M([empty response])

The figure is the WebFlux filter at Gateway 5.0.3; the Web MVC filter has the same shape, with the differences measured below.

Setup and version alignment

The Gateway starter and the Spring Cloud CircuitBreaker starter are separate dependencies, and the starter differs per stack. The Maven setup below is the WebFlux gateway that was run for this page. The first thing to notice is the order of the two BOM imports and the exclusion: spring-cloud-starter-circuitbreaker-reactor-resilience4j 5.0.3 brings resilience4j-spring-boot3 2.3.0, which is the wrong starter on Boot 4, so the Resilience4j BOM is imported before the Spring Cloud BOM and the Boot 3 starter is excluded. resilience4j-spring-boot4 is not in the Resilience4j BOM and needs its version.

pom.xml of the WebFlux gateway
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.github.resilience4j</groupId>
      <artifactId>resilience4j-bom</artifactId>
      <version>${resilience4j.version}</version>  <!-- 2.4.0 -->
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>${spring-cloud.version}</version>  <!-- 2025.1.3 -->
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
    <exclusions>
      <exclusion>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-spring-boot3</artifactId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot4</artifactId>
    <version>${resilience4j.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aspectj</artifactId>
  </dependency>
</dependencies>

Source: Spring Cloud Gateway: CircuitBreaker GatewayFilter Factory and Spring Cloud CircuitBreaker reference. Run: dependency:tree showed a pure Resilience4j 2.4.0 tree, and the 5 tests of the scenario suite passed on Spring Boot 4.1.1.

The Web MVC gateway swaps two artifacts and nothing else: spring-cloud-starter-gateway-server-webmvc for the gateway, and spring-cloud-starter-circuitbreaker-resilience4j (the blocking starter) for the breaker; the same BOM order and exclusion apply. The Gateway MVC reference page names the reactor starter, which also worked in a test because it carries the blocking factory transitively; the blocking starter is the one to declare. spring-boot-starter-aspectj and the actuator are only there so the Resilience4j starter behaves as on the other pages; the filter itself needs neither.

WebFlux: the CircuitBreaker filter

The filter has four arguments: name (the breaker id; when it is absent the route id is used), fallbackUri, statusCodes and resumeWithoutError. The shortcut CircuitBreaker=pricingRoute sets only the name. Five routes were configured against a WireMock backend with these stubs: /ok answers 200, /boom answers 500, /bad answers 502, /teapot answers 404 and /slow answers after about 1.5 seconds. The breaker is a count-based window of 4 calls with a 500 ms time limiter, set through the Resilience4j starter and picked up by name: the breaker pricingRoute and the time limiter configuration both come from resilience4j.*.

Gateway routes and Resilience4j configuration (WebFlux)
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: pricing
              uri: ${bookshelf.pricing.uri}
              predicates:
                - Path=/pricing/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: pricingRoute
                    fallbackUri: forward:/fallback/pricing
                    statusCodes:
                      - 500
                      - 502
                      - 503
            - id: strict
              uri: ${bookshelf.pricing.uri}
              predicates:
                - Path=/strict/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: strictRoute
                    statusCodes:
                      - 500
                      - 502
            - id: resume
              uri: ${bookshelf.pricing.uri}
              predicates:
                - Path=/resume/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: resumeRoute
                    resumeWithoutError: true
                    statusCodes: 500

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 500ms
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 4
        minimumNumberOfCalls: 4
        failureRateThreshold: 50
        waitDurationInOpenState: 30s

Source: Spring Cloud Gateway: CircuitBreaker GatewayFilter Factory and SpringCloudCircuitBreakerFilterFactory. Run on Spring Boot 4.1.1 and Gateway 5.0.3 (excerpt: the plain, down and downresume routes of the test project are omitted).

fallbackUri: a forward, not a method

fallbackUri accepts only the forward: scheme. When the breaker records a failure or rejects the call, the filter stores the exception in the exchange, resets the response and dispatches the request again to the gateway’s own handlers with the fallback path. The target can be a controller (below) or another gateway route; the fallback path may use URI variables from the route predicate. The fallback handler reads the cause from the attribute ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR.

A fallback controller that reads the execution exception
@RestController
class FallbackController {

    @GetMapping("/fallback/pricing")
    Mono<Map<String, String>> pricing(ServerWebExchange exchange) {
        Throwable cause = exchange.getAttribute(ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR);
        return Mono.just(Map.of(
                "source", "fallback",
                "cause", cause == null ? "none" : cause.getClass().getSimpleName(),
                "price", "unavailable"));
    }
}

Source: Spring Cloud Gateway: CircuitBreaker GatewayFilter Factory. Run: the cause was CircuitBreakerStatusCodeException for a listed status, TimeoutException for a slow backend and CallNotPermittedException when the breaker was open. The response of the forwarded handler is the response the client gets: here 200 with the JSON body, so a client cannot tell a degraded answer from a real one unless the fallback says so. Return an explicit status (503 with a Retry-After, or 200 with a marker field) on purpose.

When the fallback is another route, the FallbackHeaders filter copies the exception into request headers for the next hop. The defaults are Execution-Exception-Type, Execution-Exception-Message, Root-Cause-Exception-Type and Root-Cause-Exception-Message.

A fallback route that proxies a cache service and passes the failure on as headers
- id: books
  uri: ${bookshelf.pricing.uri}
  predicates:
    - Path=/books/**
  filters:
    - StripPrefix=1
    - name: CircuitBreaker
      args:
        name: booksRoute
        fallbackUri: forward:/fallback/proxied
        statusCodes: 500
- id: fallback-proxy
  uri: ${bookshelf.pricing.uri}
  predicates:
    - Path=/fallback/proxied
  filters:
    - SetPath=/cached-books
    - FallbackHeaders

Source: Spring Cloud Gateway: FallbackHeaders GatewayFilter Factory. Run: a request to /books/boom returned the stub’s /cached-books body, and WireMock recorded the four headers on that request, with Execution-Exception-Type equal to the nested class name SpringCloudCircuitBreakerFilterFactory$CircuitBreakerStatusCodeException and Execution-Exception-Message equal to 500 INTERNAL_SERVER_ERROR.

statusCodes and resumeWithoutError

statusCodes turns selected response statuses into failures: after the proxied call completes, if the status is in the set the filter throws CircuitBreakerStatusCodeException, which the breaker records and which then follows the fallback path. Names (INTERNAL_SERVER_ERROR) and numbers (500) are both accepted. Without the setting a 500 is passed straight to the client and the breaker records a success, so the circuit never opens on a backend that answers 500 quickly. resumeWithoutError only matters when there is no fallback: it makes the filter finish with an empty response instead of failing the exchange.

The observed results, WebFlux, Boot 4.1.1, Gateway 5.0.3, Resilience4j 2.4.0 (statuses are what the client received):

Table 1. WebFlux gateway: real status codes (backend stub on localhost)
Route Backend does Client gets Why

pricing (fallback, statusCodes 500 502 503)

200

200, backend body

Success.

pricing

500

200, fallback body, cause CircuitBreakerStatusCodeException

Listed status becomes a failure, then the forward.

pricing

404

404, empty body

404 is not listed: passes through and is a success for the breaker.

pricing

answers after 1.5 s

200, fallback body, cause TimeoutException

The 500 ms time limiter fires.

pricing, after 4 failures

(not called)

200, fallback body, cause CallNotPermittedException

Circuit open: the fallback also covers rejection.

strict (no fallback, statusCodes 500 502)

500

500

Status failure, no fallback, handled by the gateway error handler.

strict

502

500

The original 502 is lost: the exception is turned into the gateway’s own 500.

strict

answers after 1.5 s

504

TimeoutException becomes 504 Gateway Timeout.

strict, circuit open

(not called)

503

CallNotPermittedException becomes 503 Service Unavailable.

resume (resumeWithoutError, statusCodes 500)

500

500, empty body

The empty response keeps the status already set by the proxy.

resume

answers after 1.5 s

504

Timeouts are mapped before resumeWithoutError is consulted.

resume, circuit open

(not called)

503

Same: rejection is mapped first.

plain (fallback, no statusCodes)

500

500, empty body

Nothing marks it as a failure, the breaker stays closed.

down (no fallback, nothing listens on the port)

connection refused

500

Not mapped to 503 or 504: a plain exception goes to the error handler.

downresume (resumeWithoutError)

connection refused

200, empty body

A swallowed error looks like success.

Source: SpringCloudCircuitBreakerFilterFactory and SpringCloudCircuitBreakerResilience4JFilterFactory at Gateway 5.0.3 and the Spring Cloud Gateway reference above. Run: GatewayCircuitBreakerTest (5 tests, all green); the assertions are the statusContract method, the other columns are from its printed output.

Web MVC: the CircuitBreaker filter

The Web MVC gateway has the same four settings with two renames that matter: the breaker name is id (not name), and the fallback is stored as a path. YAML looks like the WebFlux one with the webmvc prefix:

Gateway routes (Web MVC), excerpt
spring:
  cloud:
    circuitbreaker:
      resilience4j:
        enableSemaphoreDefaultBulkhead: true
    gateway:
      server:
        webmvc:
          routes:
            - id: pricing
              uri: ${bookshelf.pricing.uri}
              predicates:
                - Path=/pricing/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    id: pricingRoute
                    fallbackUri: forward:/fallback/pricing
                    statusCodes:
                      - 500
                      - 502
                      - 503

Source: Spring Cloud Gateway: Web MVC CircuitBreaker filter and CircuitBreakerFilterFunctions at Gateway 5.0.3. Run on Spring Boot 4.1.1. The Gateway MVC reference shows name: in the same YAML; that key binds nothing (the configuration class has id, not name) and every request failed with IllegalArgumentException: A CircuitBreaker must have an id and a 500, so use id.

The same route in the Java DSL, which is what to use when predicates are computed:

The Java form of the route (Web MVC)
@Configuration
class JavaRoutes {

    @Bean
    RouterFunction<ServerResponse> javaPricingRoute(@Value("${bookshelf.pricing.uri}") String pricingUri) {
        return route("java-pricing")
                .route(path("/java/**"), http())
                .before(uri(pricingUri))
                .before(stripPrefix(1))
                .filter(circuitBreaker(c -> c.setId("javaPricingRoute")
                        .setFallbackPath("/fallback/pricing")
                        .setStatusCodes("500", "502", "503")))
                .build();
    }
}

Source: Spring Cloud Gateway: Web MVC CircuitBreaker filter. Run: /java/ok returned 200 and /java/boom returned the fallback body. The fallback handler is a normal @RestController; the cause is request.getAttribute(MvcUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR), and the Web MVC FallbackHeaders filter works as in WebFlux (WireMock reported the header names in lower case; the values were the exception class name and 500 INTERNAL_SERVER_ERROR).

The alignment trap: what the fallback receives

Run with the aligned Resilience4j 2.4.0 tree and no extra setting, the Web MVC gateway behaved differently from the WebFlux one: the exception stored for the fallback was an ExecutionException wrapping the real cause, and a timeout without a fallback produced a 500 instead of the documented 504. The filter tests instanceof TimeoutException and the wrapper hides it. The wrapper comes from the thread-pool bulkhead that Spring Cloud CircuitBreaker adds when resilience4j-bulkhead is on the classpath: it runs the call in another thread and waits with Future.get, and with 2.4.0 the resilience4j-bulkhead jar arrives transitively through resilience4j-micrometer, so the bulkhead is active. With the unmodified Spring Cloud 2025.1.3 tree (Resilience4j 2.3.0, no bulkhead module) the same test returned 504. The fix, verified, is a single property that moves the default bulkhead to the semaphore form, which runs on the caller’s thread and does not wrap:

Keep the original exception visible to the Web MVC filter
spring:
  cloud:
    circuitbreaker:
      resilience4j:
        enableSemaphoreDefaultBulkhead: true

Source: Spring Cloud CircuitBreaker reference and Spring Cloud CircuitBreaker source (Resilience4jBulkheadProvider). Run on Boot 4.1.1: with the property the Web MVC cause was TimeoutException and the timeout status 504; with -Dspring.cloud.circuitbreaker.resilience4j.enableSemaphoreDefaultBulkhead=false the same assertion saw 500. The semaphore bulkhead has its own default limit on concurrent calls; see Bulkhead and Spring Cloud CircuitBreaker for sizing it. The WebFlux filter was not affected in the run.

The observed results for Web MVC, with the property set. Rows equal to WebFlux are marked "same":

Table 2. Web MVC gateway: real status codes (property set)
Route and backend Client gets Difference from WebFlux

pricing, backend 500 / slow / circuit open

200, fallback body, cause CircuitBreakerStatusCodeException / TimeoutException / CallNotPermittedException

Same.

pricing, backend 404

404, empty body

Same.

strict, backend 500 and 502

500 and 500

Same, including the lost 502.

strict, slow; circuit open

504; 503

Same.

resume, backend 500

200, empty body

Different: WebFlux keeps the backend’s 500 with an empty body; Web MVC answers 200.

resume, slow; circuit open

504; 503

Same.

plain, backend 500

500, empty body

Same.

down, downresume

500; 200 empty

Same.

Source: CircuitBreakerFilterFunctions at Gateway 5.0.3. Run: GatewayMvcCircuitBreakerTest (3 tests, all green).

503 and 504 semantics

The mapping lives in the filter, not in Resilience4j, and it applies only when there is no fallbackUri:

  • TimeoutException: 504 Gateway Timeout. This is the time limiter of the breaker (default one second unless configured), not the HTTP client timeout of the proxy.

  • CallNotPermittedException (circuit open): 503 Service Unavailable. A client may retry later; the breaker moves to half open after its wait duration.

  • A statusCodes failure or any other exception: the gateway’s own 500, even when the backend said 502 or 503; the original code is not preserved. Name the statuses you want to keep in a fallback that returns them, or leave them out of statusCodes and let the response pass through.

Do not confuse these with the timeouts of the proxy itself. The Netty connect-timeout and response-timeout of the WebFlux gateway are separate and measured in TimeLimiter, where the layering with a gateway or ingress timeout is discussed. When the gateway has a retry filter and a circuit breaker, the order of the filters decides whether a retried call counts as one failure or several, as in Fallbacks and Composition. A circuit breaker on the gateway and a breaker in each client behind it are two different safety nets; the gateway one protects the edge from a dead service for all users at once, the client one protects the calling service from its own dependency.

WebFlux RequestRateLimiter with Redis

Limiting at the edge is a gateway concern and is not the Resilience4j RateLimiter. The WebFlux gateway ships a RequestRateLimiter filter backed by RedisRateLimiter, a token bucket evaluated by a Lua script in Redis so that all gateway replicas share one count. It needs spring-boot-starter-data-redis-reactive, a KeyResolver bean that decides who is limited, and the bucket settings under redis-rate-limiter.*.

A Redis token bucket per client, resolved from a header
- id: limited
  uri: ${bookshelf.pricing.uri}
  predicates:
    - Path=/limited/**
  filters:
    - StripPrefix=1
    - name: RequestRateLimiter
      args:
        key-resolver: "#{@clientKeyResolver}"
        redis-rate-limiter.replenishRate: 10
        redis-rate-limiter.burstCapacity: 20
        redis-rate-limiter.requestedTokens: 1
The key resolver
@Configuration
class RateLimiterConfig {

    /** One bucket per API key header; requests without the header have no key. */
    @Bean
    @Primary
    KeyResolver clientKeyResolver() {
        return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-Client-Id"));
    }
}

Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory. Redis was not run for this page: Docker is not available on the machine used and no Redis server was installed, so the Lua script and the 429 Too Many Requests response were not exercised. What did run, against a Redis address with nothing listening:

  • A request whose resolver returned no key got 403 Forbidden (the default of deny-empty-key is true and the default empty-key-status-code is FORBIDDEN).

  • A request with a key got 200 and the response carried X-RateLimit-Remaining: -1, X-RateLimit-Replenish-Rate: 10, X-RateLimit-Burst-Capacity: 20 and X-RateLimit-Requested-Tokens: 1. The Gateway 5.0.3 source explains it: when the call to Redis fails the limiter logs an error and allows the request (fail open). Watch that error in the logs: a dead Redis silently disables the limit.

With two KeyResolver beans the default injection is ambiguous and startup fails; mark one @Primary or name the resolver in every route. replenishRate is requests per second and a replenishRate of zero blocks every request, according to the reference; limits below one request per second use requestedTokens as the time span. For a quota that must hold exactly or per plan, see Bucket4j: Distributed Rate Limiting; plans and quotas in an API manager are the subject of issue #184, as mentioned in RateLimiter.

The Web MVC gateway uses a different rate-limiting filter that is not Resilience4j either: Bucket4jFilterFunctions.rateLimit, backed by a Bucket4j AsyncProxyManager. It is covered, together with the Bucket4j limiter that WebFlux’s RequestRateLimiter can use instead of Redis, in Bucket4j: Spring Cloud Gateway.

Spring Cloud OpenFeign

Spring Cloud OpenFeign wraps a Feign interface in a circuit breaker per method. It is off until you set one property, and it needs a Spring Cloud CircuitBreaker starter (the blocking spring-cloud-starter-circuitbreaker-resilience4j, with the same BOM order and exclusion as in the setup above). The OpenFeign starter is spring-cloud-starter-openfeign; on Spring Cloud 2025.1.3 it resolved to Feign 13.6.1, and Feign clients must be enabled with @EnableFeignClients on a configuration class.

Enable the circuit breaker for all Feign clients
spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 500ms
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 4
        minimumNumberOfCalls: 4
        failureRateThreshold: 50
        waitDurationInOpenState: 30s

Source: Spring Cloud OpenFeign: Spring Cloud CircuitBreaker Support. Run on Spring Boot 4.1.1: with the property set, every method call goes through CircuitBreakerFactory (the source makes the circuit-breaker targeter conditional on this property; the unset case was not run). The time limiter applies too: a backend slower than the configured timeout is cut off by it before Feign’s own read timeout.

The client, fallback and fallbackFactory

@FeignClient has two attributes for degraded answers. fallback names a class implementing the interface; fallbackFactory names a class that creates an implementation from the cause. Either must be a Spring bean (annotate it with @Component), and the factory is the one to prefer because it sees why the call failed.

A Bookshelf pricing client with a fallback factory
@FeignClient(name = "pricing", url = "${bookshelf.pricing.uri}",
        fallbackFactory = PricingClientFallbackFactory.class)
public interface PricingClient {

    @GetMapping("/prices/{isbn}")
    Money price(@PathVariable("isbn") String isbn);
}

@Component
public class PricingClientFallbackFactory implements FallbackFactory<PricingClient> {

    @Override
    public PricingClient create(Throwable cause) {
        return isbn -> Money.unavailable();
    }
}

public record Money(BigDecimal amount, String currency) {

    public static Money unavailable() {
        return new Money(null, "N/A");
    }
}

Source: Spring Cloud OpenFeign: Spring Cloud CircuitBreaker Support and FeignCircuitBreakerInvocationHandler. The factory type is org.springframework.cloud.openfeign.FallbackFactory, not the Resilience4j or Feign class of the same name. Run (package com.example.bookshelf.pricing): the happy path returned the stub’s JSON, a 500, a 404 and a slow answer all produced Money.unavailable(), and after the window filled the circuit opened and the backend received no request while it was open. url is set here only so the demo can point at a stub; in a real system leave it out and let service discovery resolve pricing.

The fallback attribute takes the same kind of bean. It cannot see the cause: use it only when one canned answer fits every failure.

What the fallback receives

The Throwable handed to the factory is whatever the Spring Cloud CircuitBreaker call raised. In the aligned tree it is not the FeignException:

Table 3. The cause seen by FallbackFactory.create
Situation Default (thread-pool bulkhead active, Resilience4j 2.4.0) With enableSemaphoreDefaultBulkhead=true

Backend answers 500

ExecutionException wrapping FeignException.InternalServerError

FeignException.InternalServerError

Backend answers 404

ExecutionException wrapping FeignException.NotFound

FeignException.NotFound

Slow answer

ExecutionException wrapping TimeoutException

TimeoutException

Circuit open

CallNotPermittedException

CallNotPermittedException

Source: Spring Cloud CircuitBreaker source (Resilience4jBulkheadProvider). Run: OpenFeignCircuitBreakerTest and the two classes of OpenFeignCauseTest (assertions on the cause type); the unmodified Spring Cloud tree (Resilience4j 2.3.0) delivered the unwrapped exceptions in the same test. A factory that does if (cause instanceof FeignException.NotFound) therefore never matches in the default setup. Either set the property from the Web MVC section or unwrap the cause chain in the factory.

Circuit breaker names, alphanumeric ids and groups

Every Feign method gets its own breaker, named from the Feign config key, for example PricingClient#price(String). Because #, parentheses and dots are awkward in YAML keys and metric tags, spring.cloud.openfeign.circuitbreaker.alphanumeric-ids.enabled (which defaults to true) strips every non-alphanumeric character: PricingClientpriceString. That is the name to use in resilience4j.circuitbreaker.instances and in metrics.

Table 4. The breaker name under each setting (names read from the registry)
Setting Breaker name

Default (alphanumeric ids on)

PricingClientpriceString

alphanumeric-ids.enabled=false

PricingClient#price(String)

A custom CircuitBreakerNameResolver returning feignClientName + "." + method.getName()

pricing.price

Source: Spring Cloud OpenFeign: Spring Cloud CircuitBreaker Support. Run: OpenFeignNamingTest (nested classes DefaultNames, RawNames, Custom), printing CircuitBreakerRegistry names.

A bean of type CircuitBreakerNameResolver replaces the naming completely (the resolver is declared with @ConditionalOnMissingBean):

A resolver that names the breaker after the client and the method
@TestConfiguration
static class CustomResolver {

    @Bean
    CircuitBreakerNameResolver nameResolver() {
        return (String feignClientName, Target<?> target, Method method) -> feignClientName + "." + method.getName();
    }
}

Source: CircuitBreakerNameResolver in Spring Cloud OpenFeign. Run in the test shown (a @TestConfiguration; in an application declare the bean in any @Configuration).

The second switch is spring.cloud.openfeign.circuitbreaker.group.enabled (default false). When true, the breaker is created with the Feign client name as its group, so a configuration named after the client (pricing) is applied to every method of that client, and a method-level name still wins. Without it, a configuration named pricing is ignored: in the custom-resolver run above, resilience4j.circuitbreaker.configs.pricing.slidingWindowSize=2 had no effect and the breaker kept the default window of 4.

A configuration for the whole client, applied through the group
spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true
        group:
          enabled: true

resilience4j:
  circuitbreaker:
    configs:
      pricing:
        slidingWindowSize: 2
        minimumNumberOfCalls: 2
  timelimiter:
    configs:
      pricing:
        timeoutDuration: 2s

Source: Spring Cloud OpenFeign: Spring Cloud CircuitBreaker Support. Run: with the group on, the breaker PricingClientpriceString had a window of 2 and a 1.5 second answer was tolerated by the 2 second limiter of the group.

4xx responses and ignored exceptions

By default every FeignException is a failure, including 404 Not Found, and the fallback runs for it too. A client that looks up books that may not exist will open its circuit on a run of 404s. Tell the breaker to ignore the exception class; the fallback is still invoked (the ignored exception is not counted) so that the caller gets an answer.

Ignore a 404 for the breaker
resilience4j:
  circuitbreaker:
    configs:
      default:
        ignoreExceptions:
          - feign.FeignException$NotFound

Source: Resilience4j: CircuitBreaker and CircuitBreaker. Run: six consecutive 404 responses left the breaker closed with 0 failed calls, and the fallback factory was still called for each (the cause is the ExecutionException from the previous section). If the 404 should not trigger a fallback either, decode it into a domain result in a Feign ErrorDecoder instead of an exception.

Plain Resilience4jFeign

Without Spring Cloud, resilience4j-feign decorates a Feign interface with FeignDecorators. It is in the Resilience4j BOM (2.4.0) and has no Feign dependency of its own: you supply Feign. The module is compiled against Feign 12.0, and it ran correctly on Feign 13.6.1 (the Feign of Spring Cloud 2025.1.3) in the test below. The 2.4.0 source marks Resilience4jFeign.builder(FeignDecorator) as deprecated because Feign 12.5 and later are not compatible with it: use the capability instead (the deprecated builder worked on Feign 13.6.1 here too, because it adds the capability itself). FeignDecorators.Builder offers withRetry, withCircuitBreaker, withRateLimiter, withBulkhead and the withFallback and withFallbackFactory variants, with an optional exception class or predicate filter. There is no TimeLimiter.

Plain Feign with a retry inside a circuit breaker
public interface PricingApi {

    @RequestLine("GET /prices/{isbn}")
    String price(@Param("isbn") String isbn);
}

public static PricingApi create(String baseUrl, CircuitBreaker circuitBreaker, Retry retry) {
    FeignDecorators decorators = FeignDecorators.builder()
            .withRetry(retry)                       // innermost: runs first around the HTTP call
            .withCircuitBreaker(circuitBreaker)     // wraps the retry
            .withFallback((PricingApi) isbn -> "{\"amount\":null}", CallNotPermittedException.class)
            .build();
    return Feign.builder()
            .addCapability(Resilience4jFeign.capability(decorators))
            .target(PricingApi.class, baseUrl);
}

Source: Resilience4j: Feign and resilience4j-feign at v2.4.0. Run (Resilience4jFeignTest, 3 tests, green) with the breaker slidingWindowSize(4) and a three-attempt Retry: one call to a failing endpoint reached the backend 3 times and the breaker recorded one failure; after one success and three failed calls the breaker was OPEN, and the next call returned the fallback body without reaching the backend. The CallNotPermittedException.class filter limits the fallback to rejections, so a real 500 still surfaces as FeignException.InternalServerError and the caller decides.

Decorators are applied in the order added, and each one wraps the previous: the last added is the outermost. That is the opposite of how a reader scans the builder, and it matters, as it does for the annotations in Annotations and Aspect Order. With the retry outermost the breaker would record each attempt (three failures per call; this order was not run). The fallback shown is a fixed object; withFallbackFactory(Function<Exception, ?>) passes the exception, as in the capability variant that was also run ({"amount":null,"cause":"InternalServerError"} for a 500). The registry-based way to get such breakers is in Registries, Configuration & Decorators, and the Decorators builder in Programmatic Usage.

Choosing where to put the breaker

Table 5. Gateway, Spring Cloud OpenFeign or plain Resilience4jFeign
Option Use it when Watch for

Gateway CircuitBreaker filter

You want to protect the edge and the callers of a service that is down, with one fallback response per route.

Time limiter of one second by default; Web MVC wrapping; the original status of a failure is not preserved.

OpenFeign circuit-breaker support

A Spring service calls another through Feign and you want per-method breakers and fallbackFactory.

Cause wrapping, 404 counted as failure, one breaker per method, naming for YAML.

Resilience4jFeign with FeignDecorators

Feign without Spring Cloud, or you want Retry, RateLimiter and Bulkhead around Feign.

Decorator order, deprecated builder, no time limiter, you manage the instances.

For the rest of the stack, a plain @CircuitBreaker on a method of a Spring bean is covered in Annotations and Aspect Order and the Spring Cloud abstraction itself in Spring Cloud CircuitBreaker. The tests for fallbacks and rejected calls are in Testing, and the production guidance on timeouts across tiers in Production Tuning and Anti-patterns.

The Bookshelf example

The edge and the client of the scenario, with the settings this page measured. The gateway has one route per backend, the pricing route has a fallback that returns a marker, and the Feign client in bookshelf-service has its own fallback factory. Package com.example.bookshelf.

Gateway route for pricing and Feign client for the same service
# gateway: application.yml
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: pricing
              uri: ${bookshelf.pricing.uri}
              predicates:
                - Path=/pricing/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: pricingRoute
                    fallbackUri: forward:/fallback/pricing
                    statusCodes: [500, 502, 503]

# bookshelf-service: application.yml
spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true
        group:
          enabled: true
resilience4j:
  circuitbreaker:
    configs:
      pricing:
        slidingWindowSize: 20
        minimumNumberOfCalls: 10
        waitDurationInOpenState: 30s
        ignoreExceptions:
          - feign.FeignException$NotFound
  timelimiter:
    configs:
      pricing:
        timeoutDuration: 800ms

Source: Spring Cloud Gateway: CircuitBreaker GatewayFilter Factory and Spring Cloud OpenFeign. Run: the individual keys (route shape, group configuration, ignored exception) were exercised as shown in the sections above; the two files are shown together for readability and were not started as two services.

Pitfalls

Pitfall What to do

name in a Web MVC CircuitBreaker filter, as in the reference example

It binds nothing; each request fails with a 500 "A CircuitBreaker must have an id". Use id.

Web MVC gateway timeout returns 500, and the fallback cause is ExecutionException

The thread-pool bulkhead of Spring Cloud CircuitBreaker wraps the exception once resilience4j-bulkhead is present. Set spring.cloud.circuitbreaker.resilience4j.enableSemaphoreDefaultBulkhead=true.

No statusCodes: a backend that answers 500 never opens the circuit

List the failure statuses. Without them the response passes through and counts as success.

statusCodes without a fallback: the client sees 500 for a backend 502 or 503

The original status is lost. Return the status you want from a fallback, or leave it out of statusCodes.

resumeWithoutError: true hides outages

Connection errors become an empty 200 (Web MVC also for a listed status). Use it only for routes whose absence is harmless, and alert on the breaker state instead.

Fallback hides the degradation

A forwarded handler answers 200 by default. Return a marker or a 503 with Retry-After so callers and monitoring can tell.

Default one-second time limiter on the route

A healthy route that takes 1.2 s fails. Configure resilience4j.timelimiter for the route name.

Gateway RequestRateLimiter fails open when Redis is down

Alert on the "Error calling rate limiter lua" log line; decide whether failing open is acceptable.

Two KeyResolver beans

Startup fails on the ambiguity: mark one @Primary or name it in each route.

Feign: instanceof FeignException never matches in the fallback factory

The cause is an ExecutionException unless the semaphore bulkhead is selected; unwrap the chain.

Feign: 404 opens the circuit

Add feign.FeignException$NotFound to ignoreExceptions, or decode it with an ErrorDecoder.

Feign: fallbackFactory is not a bean

Annotate it with @Component; otherwise startup fails with a missing bean message.

Config named after the Feign client does nothing

Set spring.cloud.openfeign.circuitbreaker.group.enabled=true, or use the method-level name (PricingClientpriceString).

Resilience4jFeign.builder(…​) on a current Feign

It is deprecated; use Feign.builder().addCapability(Resilience4jFeign.capability(decorators)).

Decorator order in FeignDecorators

The last added is the outermost. Put Retry before CircuitBreaker so the breaker sees one failure per call.

What changed recently

  • Spring Cloud 2025.1 (Gateway 5.0.x). The WebFlux gateway uses spring-cloud-starter-gateway-server-webflux and the property prefix spring.cloud.gateway.server.webflux.routes, and a Web MVC gateway is available as spring-cloud-starter-gateway-server-webmvc with the prefix spring.cloud.gateway.server.webmvc.routes. The older names used in Boot 3 era guides were not run for this page.

  • Resilience4j 2.4.0. Adds resilience4j-spring-boot4. With Spring Cloud CircuitBreaker 5.0.3 (which still pins 2.3.0) the aligned 2.4.0 tree activates the thread-pool bulkhead and changes the exception seen by gateway MVC and Feign fallbacks, as measured above. Spring Cloud CircuitBreaker 5.1.0 is a milestone at the time of writing and moves to 2.4.0.

  • Feign. Resilience4jFeign.builder(FeignDecorator) is deprecated in 2.4.0 in favour of Resilience4jFeign.capability, because its source says Feign 12.5 and later are not compatible with the builder.

  • The Gateway MVC reference documents name for the breaker in YAML and the reactor starter; the source takes id and the blocking starter is the one to declare.