diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc index 9e50d7138e..9c047b4155 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc @@ -58,7 +58,7 @@ public RouteLocator routes(RouteLocatorBuilder builder) { return builder.routes() .route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint") .filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").fallbackUri("forward:/inCaseOfFailureUseThis")) - .rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088") + .rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088")) .build(); } ---- @@ -161,7 +161,45 @@ public RouteLocator routes(RouteLocatorBuilder builder) { return builder.routes() .route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint") .filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").fallbackUri("forward:/inCaseOfFailureUseThis").addStatusCode("INTERNAL_SERVER_ERROR")) - .rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088") + .rewritePath("/consumingServiceEndpoint", "/backingServiceEndpoint")).uri("lb://backing-service:8088")) + .build(); +} +---- + +[[circuit-breaker-resume-without-error]] +== Resume Without Error + +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. + +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. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: circuitbreaker_route + uri: lb://backing-service:8088 + predicates: + - Path=/consumingServiceEndpoint + filters: + - name: CircuitBreaker + args: + name: myCircuitBreaker + resumeWithoutError: true +---- + +.Application.java +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("circuitbreaker_route", r -> r.path("/consumingServiceEndpoint") + .filters(f -> f.circuitBreaker(c -> c.name("myCircuitBreaker").setResumeWithoutError(true))) + .uri("lb://backing-service:8088")) .build(); } ---- diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/circuitbreaker-filter.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/circuitbreaker-filter.adoc index 3b80b1e1bd..be22e5798a 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/circuitbreaker-filter.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/circuitbreaker-filter.adoc @@ -221,3 +221,51 @@ class RouteConfiguration { } ---- +[[circuit-breaker-resume-without-error]] +== Resume Without Error + +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. + +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. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + mvc: + routes: + - id: circuitbreaker_route + uri: lb://backing-service:8088 + predicates: + - Path=/consumingServiceEndpoint + filters: + - name: CircuitBreaker + args: + name: myCircuitBreaker + resumeWithoutError: true +---- + +.GatewaySampleApplication.java +[source,java] +---- +import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.circuitBreaker; +import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; + +@Configuration +class RouteConfiguration { + + @Bean + public RouterFunction gatewayRouterFunctionsCircuitBreakerResumeWithoutError() { + return route("circuitbreaker_route") + .route(path("/consumingServiceEndpoint"), http()) + .filter(lb("backing-service")) + .filter(circuitBreaker(config -> config.setId("myCircuitBreaker").setResumeWithoutError(true))) + .build(); + } +} +---- + diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java index 61f81d867c..76da84f5c8 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java @@ -27,6 +27,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import jakarta.servlet.ServletException; import org.jspecify.annotations.Nullable; @@ -105,8 +106,15 @@ public static HandlerFilterFunction circuitBreak throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, throwable.getMessage(), throwable); } - // TODO: if not permitted (like circuit open), SERVICE_UNAVAILABLE - // TODO: if resume without error, return ok response? + // if circuit breaker is open (CallNotPermittedException), raise SERVICE_UNAVAILABLE + if (throwable instanceof CallNotPermittedException) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, throwable.getMessage(), + throwable); + } + // if resume without error, return ok response + if (config.isResumeWithoutError()) { + return ServerResponse.ok().build(); + } throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, throwable != null ? throwable.getMessage() : null, throwable); } @@ -144,6 +152,8 @@ public static class CircuitBreakerConfig { private Set statusCodes = new HashSet<>(); + private boolean resumeWithoutError = false; + public @Nullable String getId() { return id; } @@ -193,6 +203,15 @@ public CircuitBreakerConfig setStatusCodes(Set statusCodes) { return this; } + public boolean isResumeWithoutError() { + return resumeWithoutError; + } + + public CircuitBreakerConfig setResumeWithoutError(boolean resumeWithoutError) { + this.resumeWithoutError = resumeWithoutError; + return this; + } + } public static class CircuitBreakerStatusCodeException extends ResponseStatusException { diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctionsTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctionsTests.java new file mode 100644 index 0000000000..bda4a287a5 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctionsTests.java @@ -0,0 +1,223 @@ +/* + * Copyright 2025-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.net.URI; +import java.time.Duration; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.timelimiter.TimeLimiterConfig; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.Customizer; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver; +import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.circuitBreaker; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.setPath; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; +import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path; + +/** + * @author raccoonback + */ +@SpringBootTest( + properties = { GatewayMvcProperties.PREFIX + ".function.enabled=false" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +class CircuitBreakerFilterFunctionsTests { + + @LocalServerPort + int port; + + @Autowired + RestTestClient restClient; + + @Test + void circuitBreakerCallNotPermittedExceptionReturns503() { + restClient.get() + .uri("/circuitbreaker/forced-open") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void circuitBreakerTimeoutReturns504() { + restClient.get() + .uri("/circuitbreaker/timeout") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + } + + @Test + void circuitBreakerResumeWithoutErrorReturns200() { + restClient.get() + .uri("/circuitbreaker/resume-without-error") + .exchange() + .expectStatus() + .isOk(); + } + + @Test + void circuitBreakerResumeWithoutErrorStillReturns503OnCircuitOpen() { + restClient.get() + .uri("/circuitbreaker/resume-without-error-forced-open") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void circuitBreakerResumeWithoutErrorStillReturns504OnTimeout() { + restClient.get() + .uri("/circuitbreaker/resume-without-error-timeout") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + } + + @Test + void circuitBreakerFallbackWorks() { + restClient.get() + .uri("/circuitbreaker/with-fallback") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class).isEqualTo("fallback response data"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ PermitAllSecurityConfiguration.class, LocalServerPortUriResolver.class }) + @RestController + static class TestConfig { + + @Bean + public Customizer circuitBreakerCustomizer() { + return factory -> { + factory.addCircuitBreakerCustomizer( + CircuitBreaker::transitionToForcedOpenState, + "forced-open" + ); + + factory.configure( + builder -> builder + .timeLimiterConfig( + TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofMillis(500)) + .build() + ) + .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults()), + "timeout" + ); + }; + } + + @Bean + public RouterFunction circuitBreakerRoutes() { + return route("circuit_breaker_forced_open") + .route(path("/circuitbreaker/forced-open"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/status/200")) + .filter(circuitBreaker("forced-open")) + .build() + + .and(route("circuit_breaker_timeout") + .route(path("/circuitbreaker/timeout"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/delay/10")) + .filter(circuitBreaker("timeout")) + .build()) + + .and(route("circuit_breaker_resume_without_error") + .route(path("/circuitbreaker/resume-without-error"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/status/500")) + .filter(circuitBreaker(config -> config.setId("resume-without-error") + .setResumeWithoutError(true) + .setStatusCodes("500"))) + .build()) + + .and(route("circuit_breaker_resume_without_error_forced_open") + .route(path("/circuitbreaker/resume-without-error-forced-open"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/status/200")) + .filter(circuitBreaker(config -> config.setId("forced-open") + .setResumeWithoutError(true))) + .build()) + + .and(route("circuit_breaker_resume_without_error_timeout") + .route(path("/circuitbreaker/resume-without-error-timeout"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/delay/10")) + .filter(circuitBreaker(config -> config.setId("timeout") + .setResumeWithoutError(true))) + .build()) + + .and(route("circuit_breaker_with_fallback") + .route(path("/circuitbreaker/with-fallback"), http()) + .before(new LocalServerPortUriResolver()) + .filter(setPath("/status/500")) + .filter(circuitBreaker(config -> config.setId("fallback") + .setFallbackUri(URI.create("forward:/fallback")) + .setStatusCodes("500"))) + .build()); + } + + @GetMapping("/delay/{seconds}") + public String delay(@PathVariable int seconds) throws InterruptedException { + Thread.sleep(seconds * 1000L); + return "delayed " + seconds + " seconds"; + } + + @GetMapping("/status/{status}") + public ResponseEntity status(@PathVariable int status) { + return ResponseEntity.status(status).build(); + } + + @GetMapping("/fallback") + public String fallback() { + return "fallback response data"; + } + + } + +}