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.andresilience4j.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.
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.
<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.*.
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.
@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.
- 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):
| Route | Backend does | Client gets | Why |
|---|---|---|---|
|
200 |
200, backend body |
Success. |
|
500 |
200, fallback body, cause |
Listed status becomes a failure, then the forward. |
|
404 |
404, empty body |
404 is not listed: passes through and is a success for the breaker. |
|
answers after 1.5 s |
200, fallback body, cause |
The 500 ms time limiter fires. |
|
(not called) |
200, fallback body, cause |
Circuit open: the fallback also covers rejection. |
|
500 |
500 |
Status failure, no fallback, handled by the gateway error handler. |
|
502 |
500 |
The original 502 is lost: the exception is turned into the gateway’s own 500. |
|
answers after 1.5 s |
504 |
|
|
(not called) |
503 |
|
|
500 |
500, empty body |
The empty response keeps the status already set by the proxy. |
|
answers after 1.5 s |
504 |
Timeouts are mapped before |
|
(not called) |
503 |
Same: rejection is mapped first. |
|
500 |
500, empty body |
Nothing marks it as a failure, the breaker stays closed. |
|
connection refused |
500 |
Not mapped to 503 or 504: a plain exception goes to the error handler. |
|
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:
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:
@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:
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":
| Route and backend | Client gets | Difference from WebFlux |
|---|---|---|
|
200, fallback body, cause |
Same. |
|
404, empty body |
Same. |
|
500 and 500 |
Same, including the lost 502. |
|
504; 503 |
Same. |
|
200, empty body |
Different: WebFlux keeps the backend’s 500 with an empty body; Web MVC answers 200. |
|
504; 503 |
Same. |
|
500, empty body |
Same. |
|
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
statusCodesfailure or any other exception: the gateway’s own500, 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 ofstatusCodesand 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.*.
- 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
@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 ofdeny-empty-keyis true and the defaultempty-key-status-codeisFORBIDDEN). -
A request with a key got
200and the response carriedX-RateLimit-Remaining: -1,X-RateLimit-Replenish-Rate: 10,X-RateLimit-Burst-Capacity: 20andX-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.
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.
@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:
| Situation | Default (thread-pool bulkhead active, Resilience4j 2.4.0) | With enableSemaphoreDefaultBulkhead=true |
|---|---|---|
Backend answers 500 |
|
|
Backend answers 404 |
|
|
Slow answer |
|
|
Circuit open |
|
|
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.
| Setting | Breaker name |
|---|---|
Default (alphanumeric ids on) |
|
|
|
A custom |
|
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):
@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.
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.
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.
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
| Option | Use it when | Watch for |
|---|---|---|
Gateway |
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 |
Cause wrapping, 404 counted as failure, one breaker per method, naming for YAML. |
|
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: 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 |
|---|---|
|
It binds nothing; each request fails with a 500 "A CircuitBreaker must have an id". Use |
Web MVC gateway timeout returns 500, and the fallback cause is |
The thread-pool bulkhead of Spring Cloud CircuitBreaker wraps the exception once |
No |
List the failure statuses. Without them the response passes through and counts as success. |
|
The original status is lost. Return the status you want from a fallback, or leave it out of |
|
Connection errors become an empty |
Fallback hides the degradation |
A forwarded handler answers 200 by default. Return a marker or a 503 with |
Default one-second time limiter on the route |
A healthy route that takes 1.2 s fails. Configure |
Gateway |
Alert on the "Error calling rate limiter lua" log line; decide whether failing open is acceptable. |
Two |
Startup fails on the ambiguity: mark one |
Feign: |
The cause is an |
Feign: 404 opens the circuit |
Add |
Feign: |
Annotate it with |
Config named after the Feign client does nothing |
Set |
|
It is deprecated; use |
Decorator order in |
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-webfluxand the property prefixspring.cloud.gateway.server.webflux.routes, and a Web MVC gateway is available asspring-cloud-starter-gateway-server-webmvcwith the prefixspring.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 ofResilience4jFeign.capability, because its source says Feign 12.5 and later are not compatible with the builder. -
The Gateway MVC reference documents
namefor the breaker in YAML and the reactor starter; the source takesidand the blocking starter is the one to declare.