Skip to content

Commit c64e737

Browse files
committed
Add handling for CircuitBreaker 'not permitted' and resume-without-error cases
- Return 503 Service Unavailable when CircuitBreaker is open or not permitted - Return 200 OK when resume-without-error is configured Signed-off-by: raccoonback <[email protected]>
1 parent 8769340 commit c64e737

File tree

4 files changed

+332
-4
lines changed

4 files changed

+332
-4
lines changed

docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public RouteLocator routes(RouteLocatorBuilder builder) {
5858
return builder.routes()
5959
.route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint")
6060
.filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").fallbackUri("forward:/inCaseOfFailureUseThis"))
61-
.rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088")
61+
.rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088"))
6262
.build();
6363
}
6464
----
@@ -161,7 +161,45 @@ public RouteLocator routes(RouteLocatorBuilder builder) {
161161
return builder.routes()
162162
.route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint")
163163
.filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").fallbackUri("forward:/inCaseOfFailureUseThis").addStatusCode("INTERNAL_SERVER_ERROR"))
164-
.rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088")
164+
.rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088"))
165+
.build();
166+
}
167+
----
168+
169+
[[circuit-breaker-resume-without-error]]
170+
== Resume Without Error
171+
172+
When a circuit breaker trips or encounters an error, you can configure it to resume without propagating the error to the client by setting `resumeWithoutError` to `true`. This is useful for non-critical services where you want to continue processing even if the circuit breaker fails.
173+
174+
NOTE: When `resumeWithoutError` is `true`, timeout exceptions and circuit open exceptions (CallNotPermittedException) are still returned with their respective HTTP status codes (504 Gateway Timeout and 503 Service Unavailable). Only other unhandled exceptions will result in a successful response.
175+
176+
.application.yml
177+
[source,yaml]
178+
----
179+
spring:
180+
cloud:
181+
gateway:
182+
routes:
183+
- id: circuitbreaker_route
184+
uri: lb://backing-service:8088
185+
predicates:
186+
- Path=/consumingServiceEndpoint
187+
filters:
188+
- name: CircuitBreaker
189+
args:
190+
name: myCircuitBreaker
191+
resumeWithoutError: true
192+
----
193+
194+
.Application.java
195+
[source,java]
196+
----
197+
@Bean
198+
public RouteLocator routes(RouteLocatorBuilder builder) {
199+
return builder.routes()
200+
.route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint")
201+
.filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").setResumeWithoutError(true)))
202+
.uri("lb://backing-service:8088"))
165203
.build();
166204
}
167205
----

docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/circuitbreaker-filter.adoc

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,51 @@ class RouteConfiguration {
221221
}
222222
----
223223

224+
[[circuit-breaker-resume-without-error]]
225+
== Resume Without Error
226+
227+
When a circuit breaker trips or encounters an error, you can configure it to resume without propagating the error to the client by setting `resumeWithoutError` to `true`. This is useful for non-critical services where you want to continue processing even if the circuit breaker fails.
228+
229+
NOTE: When `resumeWithoutError` is `true`, timeout exceptions and circuit open exceptions (CallNotPermittedException) are still returned with their respective HTTP status codes (504 Gateway Timeout and 503 Service Unavailable). Only other unhandled exceptions will result in a successful response.
230+
231+
.application.yml
232+
[source,yaml]
233+
----
234+
spring:
235+
cloud:
236+
gateway:
237+
mvc:
238+
routes:
239+
- id: circuitbreaker_route
240+
uri: lb://backing-service:8088
241+
predicates:
242+
- Path=/consumingServiceEndpoint
243+
filters:
244+
- name: CircuitBreaker
245+
args:
246+
name: myCircuitBreaker
247+
resumeWithoutError: true
248+
----
249+
250+
.GatewaySampleApplication.java
251+
[source,java]
252+
----
253+
import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.circuitBreaker;
254+
import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb;
255+
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
256+
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
257+
258+
@Configuration
259+
class RouteConfiguration {
260+
261+
@Bean
262+
public RouterFunction<ServerResponse> gatewayRouterFunctionsCircuitBreakerResumeWithoutError() {
263+
return route("circuitbreaker_route")
264+
.route(path("/consumingServiceEndpoint"), http())
265+
.filter(lb("backing-service"))
266+
.filter(circuitBreaker(config -> config.setId("myCircuitBreaker").setResumeWithoutError(true)))
267+
.build();
268+
}
269+
}
270+
----
271+

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.function.Consumer;
2828
import java.util.stream.Collectors;
2929

30+
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
3031
import jakarta.servlet.ServletException;
3132
import org.jspecify.annotations.Nullable;
3233

@@ -105,8 +106,15 @@ public static HandlerFilterFunction<ServerResponse, ServerResponse> circuitBreak
105106
throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, throwable.getMessage(),
106107
throwable);
107108
}
108-
// TODO: if not permitted (like circuit open), SERVICE_UNAVAILABLE
109-
// TODO: if resume without error, return ok response?
109+
// if circuit breaker is open (CallNotPermittedException), raise SERVICE_UNAVAILABLE
110+
if (throwable instanceof CallNotPermittedException) {
111+
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, throwable.getMessage(),
112+
throwable);
113+
}
114+
// if resume without error, return ok response
115+
if (config.isResumeWithoutError()) {
116+
return ServerResponse.ok().build();
117+
}
110118
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
111119
throwable != null ? throwable.getMessage() : null, throwable);
112120
}
@@ -144,6 +152,8 @@ public static class CircuitBreakerConfig {
144152

145153
private Set<String> statusCodes = new HashSet<>();
146154

155+
private boolean resumeWithoutError = false;
156+
147157
public @Nullable String getId() {
148158
return id;
149159
}
@@ -193,6 +203,15 @@ public CircuitBreakerConfig setStatusCodes(Set<String> statusCodes) {
193203
return this;
194204
}
195205

206+
public boolean isResumeWithoutError() {
207+
return resumeWithoutError;
208+
}
209+
210+
public CircuitBreakerConfig setResumeWithoutError(boolean resumeWithoutError) {
211+
this.resumeWithoutError = resumeWithoutError;
212+
return this;
213+
}
214+
196215
}
197216

198217
public static class CircuitBreakerStatusCodeException extends ResponseStatusException {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.server.mvc.filter;
18+
19+
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
20+
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
21+
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
22+
import org.junit.jupiter.api.Test;
23+
import org.springframework.beans.factory.annotation.Autowired;
24+
import org.springframework.boot.SpringBootConfiguration;
25+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
26+
import org.springframework.boot.test.context.SpringBootTest;
27+
import org.springframework.boot.test.web.server.LocalServerPort;
28+
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
29+
import org.springframework.cloud.client.circuitbreaker.Customizer;
30+
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
31+
import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers;
32+
import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver;
33+
import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration;
34+
import org.springframework.context.annotation.Bean;
35+
import org.springframework.context.annotation.Import;
36+
import org.springframework.http.HttpStatus;
37+
import org.springframework.http.ResponseEntity;
38+
import org.springframework.test.context.ContextConfiguration;
39+
import org.springframework.test.web.servlet.client.RestTestClient;
40+
import org.springframework.web.bind.annotation.GetMapping;
41+
import org.springframework.web.bind.annotation.PathVariable;
42+
import org.springframework.web.bind.annotation.RestController;
43+
import org.springframework.web.servlet.function.RouterFunction;
44+
import org.springframework.web.servlet.function.ServerResponse;
45+
46+
import java.net.URI;
47+
import java.time.Duration;
48+
49+
import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.circuitBreaker;
50+
import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.setPath;
51+
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
52+
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
53+
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path;
54+
55+
/**
56+
* @author raccoonback
57+
*/
58+
@SpringBootTest(
59+
properties = { GatewayMvcProperties.PREFIX + ".function.enabled=false" },
60+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
61+
)
62+
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
63+
class CircuitBreakerFilterFunctionsTests {
64+
65+
@LocalServerPort
66+
int port;
67+
68+
@Autowired
69+
RestTestClient restClient;
70+
71+
@Test
72+
void circuitBreakerCallNotPermittedExceptionReturns503() {
73+
restClient.get()
74+
.uri("/circuitbreaker/forced-open")
75+
.exchange()
76+
.expectStatus()
77+
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
78+
}
79+
80+
@Test
81+
void circuitBreakerTimeoutReturns504() {
82+
restClient.get()
83+
.uri("/circuitbreaker/timeout")
84+
.exchange()
85+
.expectStatus()
86+
.isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
87+
}
88+
89+
@Test
90+
void circuitBreakerResumeWithoutErrorReturns200() {
91+
// resumeWithoutError=true일 때 일반 에러는 200 OK 반환 확인
92+
restClient.get()
93+
.uri("/circuitbreaker/resume-without-error")
94+
.exchange()
95+
.expectStatus()
96+
.isOk();
97+
}
98+
99+
@Test
100+
void circuitBreakerResumeWithoutErrorStillReturns503OnCircuitOpen() {
101+
restClient.get()
102+
.uri("/circuitbreaker/resume-without-error-forced-open")
103+
.exchange()
104+
.expectStatus()
105+
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
106+
}
107+
108+
@Test
109+
void circuitBreakerResumeWithoutErrorStillReturns504OnTimeout() {
110+
restClient.get()
111+
.uri("/circuitbreaker/resume-without-error-timeout")
112+
.exchange()
113+
.expectStatus()
114+
.isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
115+
}
116+
117+
@Test
118+
void circuitBreakerFallbackWorks() {
119+
restClient.get()
120+
.uri("/circuitbreaker/with-fallback")
121+
.exchange()
122+
.expectStatus()
123+
.isOk()
124+
.expectBody(String.class).isEqualTo("fallback response data");
125+
}
126+
127+
@SpringBootConfiguration
128+
@EnableAutoConfiguration
129+
@Import({ PermitAllSecurityConfiguration.class, LocalServerPortUriResolver.class })
130+
@RestController
131+
static class TestConfig {
132+
133+
@Bean
134+
public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerCustomizer() {
135+
return factory -> {
136+
factory.addCircuitBreakerCustomizer(
137+
CircuitBreaker::transitionToForcedOpenState,
138+
"forced-open"
139+
);
140+
141+
factory.configure(
142+
builder -> builder
143+
.timeLimiterConfig(
144+
TimeLimiterConfig.custom()
145+
.timeoutDuration(Duration.ofMillis(500))
146+
.build()
147+
)
148+
.circuitBreakerConfig(CircuitBreakerConfig.ofDefaults()),
149+
"timeout"
150+
);
151+
};
152+
}
153+
154+
@Bean
155+
public RouterFunction<ServerResponse> circuitBreakerRoutes() {
156+
return route("circuit_breaker_forced_open")
157+
.route(path("/circuitbreaker/forced-open"), http())
158+
.before(new LocalServerPortUriResolver())
159+
.filter(setPath("/status/200"))
160+
.filter(circuitBreaker("forced-open"))
161+
.build()
162+
163+
.and(route("circuit_breaker_timeout")
164+
.route(path("/circuitbreaker/timeout"), http())
165+
.before(new LocalServerPortUriResolver())
166+
.filter(setPath("/delay/10"))
167+
.filter(circuitBreaker("timeout"))
168+
.build())
169+
170+
.and(route("circuit_breaker_resume_without_error")
171+
.route(path("/circuitbreaker/resume-without-error"), http())
172+
.before(new LocalServerPortUriResolver())
173+
.filter(setPath("/status/500"))
174+
.filter(circuitBreaker(config -> config.setId("resume-without-error")
175+
.setResumeWithoutError(true)
176+
.setStatusCodes("500")))
177+
.build())
178+
179+
.and(route("circuit_breaker_resume_without_error_forced_open")
180+
.route(path("/circuitbreaker/resume-without-error-forced-open"), http())
181+
.before(new LocalServerPortUriResolver())
182+
.filter(setPath("/status/200"))
183+
.filter(circuitBreaker(config -> config.setId("forced-open")
184+
.setResumeWithoutError(true)))
185+
.build())
186+
187+
.and(route("circuit_breaker_resume_without_error_timeout")
188+
.route(path("/circuitbreaker/resume-without-error-timeout"), http())
189+
.before(new LocalServerPortUriResolver())
190+
.filter(setPath("/delay/10"))
191+
.filter(circuitBreaker(config -> config.setId("timeout")
192+
.setResumeWithoutError(true)))
193+
.build())
194+
195+
.and(route("circuit_breaker_with_fallback")
196+
.route(path("/circuitbreaker/with-fallback"), http())
197+
.before(new LocalServerPortUriResolver())
198+
.filter(setPath("/status/500"))
199+
.filter(circuitBreaker(config -> config.setId("fallback")
200+
.setFallbackUri(URI.create("forward:/fallback"))
201+
.setStatusCodes("500")))
202+
.build());
203+
}
204+
205+
@GetMapping("/delay/{seconds}")
206+
public String delay(@PathVariable int seconds) throws InterruptedException {
207+
Thread.sleep(seconds * 1000L);
208+
return "delayed " + seconds + " seconds";
209+
}
210+
211+
@GetMapping("/status/{status}")
212+
public ResponseEntity<Void> status(@PathVariable int status) {
213+
return ResponseEntity.status(status).build();
214+
}
215+
216+
@GetMapping("/fallback")
217+
public String fallback() {
218+
return "fallback response data";
219+
}
220+
221+
}
222+
223+
}

0 commit comments

Comments
 (0)