diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java index a7fee0981..5646c24cb 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java @@ -48,6 +48,7 @@ import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; import org.springframework.cloud.gateway.server.mvc.filter.FilterBeanFactoryDiscoverer; import org.springframework.cloud.gateway.server.mvc.filter.FilterDiscoverer; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; import org.springframework.cloud.gateway.server.mvc.handler.HandlerDiscoverer; import org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctionDefinition; import org.springframework.cloud.gateway.server.mvc.invoke.InvocationContext; @@ -101,7 +102,7 @@ public String toString() { }; private static final RouterFunction NEVER_ROUTE = RouterFunctions.route(neverPredicate, - request -> ServerResponse.notFound().build()); + request -> GatewayServerResponse.notFound().build()); private final Log log = LogFactory.getLog(getClass()); diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/Bucket4jFilterFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/Bucket4jFilterFunctions.java index 8df51990e..5b5f85ef8 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/Bucket4jFilterFunctions.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/Bucket4jFilterFunctions.java @@ -32,6 +32,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; import org.springframework.core.task.TaskExecutor; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -78,7 +79,7 @@ public static HandlerFilterFunction rateLimit( String key = config.getKeyResolver() != null ? config.getKeyResolver().apply(request) : ""; if (!StringUtils.hasText(key)) { // TODO: configurable empty key status code - return ServerResponse.status(HttpStatus.FORBIDDEN).build(); + return GatewayServerResponse.status(HttpStatus.FORBIDDEN).build(); } TaskExecutor taskExecutor = MvcUtils.getApplicationContext(request) .getBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME, TaskExecutor.class); @@ -100,7 +101,7 @@ public static HandlerFilterFunction rateLimit( serverResponse.headers().add(config.getHeaderName(), String.valueOf(remainingTokens)); return serverResponse; } - return ServerResponse.status(config.getStatusCode()) + return GatewayServerResponse.status(config.getStatusCode()) .header(config.getHeaderName(), String.valueOf(remainingTokens)) .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 d2f218ccf..e001c73a3 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 @@ -114,7 +114,7 @@ public static HandlerFilterFunction circuitBreak } // if resume without error, return ok response if (config.isResumeWithoutError()) { - return ServerResponse.ok().build(); + return GatewayServerResponse.ok().build(); } throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, throwable != null ? throwable.getMessage() : null, throwable); diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java index d21ce9010..2e5c1390b 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java @@ -24,6 +24,7 @@ import org.springframework.cloud.gateway.server.mvc.common.Shortcut; import org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.DedupeStrategy; import org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.FallbackHeadersConfig; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.util.Assert; @@ -129,8 +130,9 @@ static HandlerFilterFunction redirectTo(HttpStat static HandlerFilterFunction redirectTo(HttpStatusHolder status, URI uri) { Assert.isTrue(status.is3xxRedirection(), "status must be a 3xx code, but was " + status); - return (request, - next) -> ServerResponse.status(status.resolve()).header(HttpHeaders.LOCATION, uri.toString()).build(); + return (request, next) -> GatewayServerResponse.status(status.resolve()) + .header(HttpHeaders.LOCATION, uri.toString()) + .build(); } @Shortcut diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java index fd48f6c03..40b0e1c5f 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java @@ -68,7 +68,7 @@ static ServerResponse processRequest(ServerRequest request, @Nullable FunctionIn } if (function == null) { - return ServerResponse.notFound().build(); + return GatewayServerResponse.notFound().build(); } HttpHeaders headers = request.headers().asHttpHeaders(); @@ -95,13 +95,13 @@ static ServerResponse processRequest(ServerRequest request, @Nullable FunctionIn } Object result = function.apply(inputMessage); if (function.isConsumer()) { - return HttpMethod.DELETE.equals(request.method()) ? ServerResponse.ok().build() - : ServerResponse.accepted() + return HttpMethod.DELETE.equals(request.method()) ? GatewayServerResponse.ok().build() + : GatewayServerResponse.accepted() .headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders))) .build(); } - BodyBuilder responseOkBuilder = ServerResponse.ok() + BodyBuilder responseOkBuilder = GatewayServerResponse.ok() .headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders))); if (result instanceof Iterable i) { List aggregatedResult = (List) StreamSupport.stream(i.spliterator(), false).map(m -> { diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java index 3bd102d58..128279d37 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java @@ -96,7 +96,7 @@ public static HandlerFunction fn(String functionName) { return processRequest(request, function, body, false, Collections.emptyList(), Collections.emptyList(), additionalRequestHeaders); } - return ServerResponse.notFound().build(); + return GatewayServerResponse.notFound().build(); }; } @@ -111,9 +111,9 @@ public static HandlerFunction stream(String bindingName) { .fromHttp(FunctionHandlerHeaderUtils.sanitize(request.headers().asHttpHeaders())); boolean send = streamOps.send(expandedBindingName, MessageBuilder.createMessage(body, messageHeaders)); if (send) { - return ServerResponse.accepted().build(); + return GatewayServerResponse.accepted().build(); } - return ServerResponse.badRequest().build(); + return GatewayServerResponse.badRequest().build(); }; } diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/AfterFilterFunctionsResponseHeadersTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/AfterFilterFunctionsResponseHeadersTests.java new file mode 100644 index 000000000..197089757 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/AfterFilterFunctionsResponseHeadersTests.java @@ -0,0 +1,345 @@ +/* + * Copyright 2026-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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.github.benmanes.caffeine.cache.Caffeine; +import io.github.bucket4j.caffeine.CaffeineProxyManager; +import io.github.bucket4j.distributed.proxy.AsyncProxyManager; +import io.github.bucket4j.distributed.remote.RemoteBucketState; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; +import org.springframework.cloud.stream.function.StreamOperations; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME; +import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.addResponseHeader; +import static org.springframework.cloud.gateway.server.mvc.filter.Bucket4jFilterFunctions.rateLimit; +import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.circuitBreaker; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.redirectTo; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.fn; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.stream; +import static org.springframework.web.servlet.function.RequestPredicates.path; + +class AfterFilterFunctionsResponseHeadersTests { + + @Test + void addResponseHeaderOnServerResponseBuild() { + assertThatThrownBy(() -> apply(addResponseHeader("X-Test", "TestFoo"), ServerResponse.ok().build())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void addResponseHeaderOnGatewayServerResponseBuild() { + ServerResponse response = apply(addResponseHeader("X-Test", "TestFoo"), GatewayServerResponse.ok().build()); + + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestFoo"); + } + + @Test + void addResponseHeaderAfterRedirectToFilter() throws Exception { + RouterFunction redirectRoute = route("redirectRoute") + .GET(path("/redirect"), req -> GatewayServerResponse.ok().build()) + .after(addResponseHeader("X-Test", "TestRedirect")) + .filter(redirectTo(HttpStatus.FOUND, URI.create("https://example.org"))) + .build(); + + ServerResponse response = handle(redirectRoute, request("GET", "/redirect", null)); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.FOUND); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestRedirect"); + } + + @Test + void addResponseHeaderAfterFnHandler() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ContextFunctionCatalogAutoConfiguration.class)) + .withUserConfiguration(UpperFunctionConfiguration.class) + .run(context -> { + RouterFunction fnRoute = route("fnRoute").POST(path("/fn"), fn("upper")) + .after(addResponseHeader("X-Test", "TestFn")) + .build(); + ServerRequest request = request("POST", "/fn", context, "hello"); + + ServerResponse response = handle(fnRoute, request); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestFn"); + }); + } + + @Test + void addResponseHeaderAfterFnHandlerFunctionNotFound() { + new WebApplicationContextRunner().withBean(FunctionCatalog.class, () -> mock(FunctionCatalog.class)) + .run(context -> { + RouterFunction fnRoute = route("fnRoute").GET(path("/fn"), fn("missing")) + .after(addResponseHeader("X-Test", "TestFn")) + .build(); + + ServerResponse response = handle(fnRoute, request("GET", "/fn", context)); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestFn"); + }); + } + + @Test + void addResponseHeaderAfterFnHandlerConsumerPost() { + consumerContextRunner().run(context -> { + RouterFunction fnRoute = route("fnRoute").POST(path("/fn"), fn("sink")) + .after(addResponseHeader("X-Test", "TestFn")) + .build(); + + ServerResponse response = handle(fnRoute, request("POST", "/fn", context, "hello")); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestFn"); + }); + } + + @Test + void addResponseHeaderAfterFnHandlerConsumerDelete() { + consumerContextRunner().run(context -> { + RouterFunction fnRoute = route("fnRoute").DELETE(path("/fn"), fn("sink")) + .after(addResponseHeader("X-Test", "TestFn")) + .build(); + + ServerResponse response = handle(fnRoute, request("DELETE", "/fn", context, "hello")); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestFn"); + }); + } + + @Test + void addResponseHeaderAfterStreamHandlerSendFailed() { + StreamOperations streamOperations = mock(StreamOperations.class); + given(streamOperations.send(anyString(), any())).willReturn(false); + new WebApplicationContextRunner().withBean(StreamOperations.class, () -> streamOperations).run(context -> { + RouterFunction streamRoute = route("streamRoute").POST(path("/stream"), stream("binding")) + .after(addResponseHeader("X-Test", "TestStream")) + .build(); + + ServerResponse response = handle(streamRoute, request("POST", "/stream", context, "hello")); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestStream"); + }); + } + + @Test + void rateLimitAllowedAddsHeaderToStreamHandlerResponse() { + StreamOperations streamOperations = mock(StreamOperations.class); + given(streamOperations.send(anyString(), any())).willReturn(true); + rateLimitContextRunner().withBean(StreamOperations.class, () -> streamOperations).run(context -> { + RouterFunction rateLimitRoute = route("rateLimitRoute") + .POST(path("/limited"), stream("binding")) + .filter(rateLimit(1, Duration.ofMinutes(1), req -> "client")) + .build(); + + ServerResponse response = handle(rateLimitRoute, request("POST", "/limited", context, "hello")); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.headers().getFirst(Bucket4jFilterFunctions.DEFAULT_HEADER_NAME)).isEqualTo("0"); + }); + } + + @Test + void addResponseHeaderAfterStreamHandler() { + StreamOperations streamOperations = mock(StreamOperations.class); + given(streamOperations.send(anyString(), any())).willReturn(true); + new WebApplicationContextRunner().withBean(StreamOperations.class, () -> streamOperations).run(context -> { + RouterFunction streamRoute = route("streamRoute").POST(path("/stream"), stream("binding")) + .after(addResponseHeader("X-Test", "TestStream")) + .build(); + ServerRequest request = request("POST", "/stream", context, "hello"); + + ServerResponse response = handle(streamRoute, request); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestStream"); + }); + } + + @Test + void addResponseHeaderAfterRateLimitRejectsEmptyKey() { + rateLimitContextRunner().run(context -> { + RouterFunction rateLimitRoute = route("rateLimitRoute") + .GET(path("/limited"), req -> GatewayServerResponse.ok().build()) + .after(addResponseHeader("X-Test", "TestRateLimit")) + .filter(rateLimit(1, Duration.ofMinutes(1), req -> "")) + .build(); + + ServerResponse response = handle(rateLimitRoute, request("GET", "/limited", context)); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestRateLimit"); + }); + } + + @Test + void addResponseHeaderAfterRateLimitExceeded() { + rateLimitContextRunner().run(context -> { + RouterFunction rateLimitRoute = route("rateLimitRoute") + .GET(path("/limited"), req -> GatewayServerResponse.ok().build()) + .after(addResponseHeader("X-Test", "TestRateLimit")) + .filter(rateLimit(1, Duration.ofMinutes(1), req -> "client")) + .build(); + handle(rateLimitRoute, request("GET", "/limited", context)); + + ServerResponse response = handle(rateLimitRoute, request("GET", "/limited", context)); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestRateLimit"); + }); + } + + @Test + void addResponseHeaderAfterCircuitBreakerResumeWithoutError() { + CircuitBreakerFactory circuitBreakerFactory = mock(CircuitBreakerFactory.class); + given(circuitBreakerFactory.create("cb")).willReturn(new FallbackOnErrorCircuitBreaker()); + new WebApplicationContextRunner().withBean(CircuitBreakerFactory.class, () -> circuitBreakerFactory) + .run(context -> { + RouterFunction circuitBreakerRoute = route("circuitBreakerRoute") + .GET(path("/cb"), req -> { + throw new IllegalStateException("boom"); + }) + .after(addResponseHeader("X-Test", "TestCircuitBreaker")) + .filter(circuitBreaker(config -> config.setId("cb").setResumeWithoutError(true))) + .build(); + + ServerResponse response = handle(circuitBreakerRoute, request("GET", "/cb", context)); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.headers().getFirst("X-Test")).isEqualTo("TestCircuitBreaker"); + }); + } + + private static WebApplicationContextRunner consumerContextRunner() { + return new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ContextFunctionCatalogAutoConfiguration.class)) + .withUserConfiguration(SinkConsumerConfiguration.class); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static WebApplicationContextRunner rateLimitContextRunner() { + Caffeine caffeine = (Caffeine) Caffeine.newBuilder().maximumSize(100); + AsyncProxyManager proxyManager = new CaffeineProxyManager<>(caffeine, Duration.ofMinutes(1)).asAsync(); + return new WebApplicationContextRunner().withBean(AsyncProxyManager.class, () -> proxyManager) + .withBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME, TaskExecutor.class, SyncTaskExecutor::new); + } + + private static ServerResponse handle(RouterFunction routerFunction, ServerRequest request) + throws Exception { + return routerFunction.route(request).orElseThrow().handle(request); + } + + private static ServerResponse apply(BiFunction filter, + ServerResponse response) { + return filter.apply(request("GET", "/foo", null), response); + } + + private static ServerRequest request(String method, String path, @Nullable WebApplicationContext context) { + return request(method, path, context, null); + } + + private static ServerRequest request(String method, String path, @Nullable WebApplicationContext context, + @Nullable String body) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, path); + if (context != null) { + servletRequest.setAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE, context); + } + if (body != null) { + servletRequest.setContentType(MediaType.TEXT_PLAIN_VALUE); + servletRequest.setContent(body.getBytes(StandardCharsets.UTF_8)); + } + return ServerRequest.create(servletRequest, + List.of(new StringHttpMessageConverter(), new ByteArrayHttpMessageConverter())); + } + + @Configuration(proxyBeanMethods = false) + static class UpperFunctionConfiguration { + + @Bean + Function upper() { + return String::toUpperCase; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SinkConsumerConfiguration { + + @Bean + Consumer sink() { + return value -> { + }; + } + + } + + static class FallbackOnErrorCircuitBreaker implements CircuitBreaker { + + @Override + public T run(Supplier toRun, Function<@Nullable Throwable, T> fallback) { + try { + return toRun.get(); + } + catch (RuntimeException ex) { + return fallback.apply(ex); + } + } + + } + +}