From 053deb52104084f13c8fb01cffc699509e530001 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 16 Jun 2025 13:55:44 +0200 Subject: [PATCH 01/26] Init draft. Signed-off-by: Olga Maciaszek-Sharma --- ...tractCloudHttpClientServiceProperties.java | 17 ++ .../CloudHttpClientServiceProperties.java | 30 +++ .../CircuitBreakerRequestValueProcessor.java | 24 +++ .../CircuitBreakerRestClientAdapter.java | 186 ++++++++++++++++++ ...rRestClientHttpServiceGroupConfigurer.java | 53 +++++ 5 files changed, 310 insertions(+) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java new file mode 100644 index 000000000..de6ac0052 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -0,0 +1,17 @@ +package org.springframework.cloud.client; + +/** + * @author Olga Maciaszek-Sharma + */ +public abstract class AbstractCloudHttpClientServiceProperties { + + private String fallbackClass; + + public String getFallbackClass() { + return fallbackClass; + } + + public void setFallbackClass(String fallbackClass) { + this.fallbackClass = fallbackClass; + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java new file mode 100644 index 000000000..671a755da --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -0,0 +1,30 @@ +package org.springframework.cloud.client; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Olga Maciaszek-Sharma + */ +@ConfigurationProperties("spring.cloud.http.client.service") +public class CloudHttpClientServiceProperties extends AbstractCloudHttpClientServiceProperties { + + private Map group = new LinkedHashMap<>(); + + public Map getGroup() { + return this.group; + } + + public void setGroup(Map group) { + this.group = group; + } + + /** + * Properties for a single HTTP Service client group. + */ + public static class Group extends AbstractCloudHttpClientServiceProperties { + + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java new file mode 100644 index 000000000..5b936b07c --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java @@ -0,0 +1,24 @@ +package org.springframework.cloud.client.circuitbreaker; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Processor { + + public static final String METHOD_ATTRIBUTE_NAME = "spring.cloud.method.name"; + public static final String PARAMETER_TYPES_ATTRIBUTE_NAME = "spring.cloud.method.parameter-types"; + public static final String ARGUMENTS_ATTRIBUTE_NAME = "spring.cloud.method.arguments"; + + @Override + public void process(Method method, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { + builder.addAttribute(METHOD_ATTRIBUTE_NAME, method.getName()); + builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); + builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java new file mode 100644 index 000000000..7197b0040 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java @@ -0,0 +1,186 @@ +package org.springframework.cloud.client.circuitbreaker; + +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.util.UriBuilderFactory; + +/** + * @author Olga Maciaszek-Sharma + * @author Rossen Stoyanchev + */ +public class CircuitBreakerRestClientAdapter implements HttpExchangeAdapter { + + // FIXME: get fallbacks + + private final RestClient restClient; + private final CircuitBreaker circuitBreaker; + private final Class fallbacks; + + private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker circuitBreaker, + // TODO: generics + Class fallbacks) { + this.restClient = restClient; + this.circuitBreaker = circuitBreaker; + this.fallbacks = fallbacks; + } + + + @Override + public boolean supportsRequestAttributes() { + return true; + } + + @Override + public void exchange(HttpRequestValues requestValues) { + Map attributes = requestValues.getAttributes(); + String methodName = String.valueOf(attributes + .get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); + Class[] parameterTypes = (Class[]) attributes + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + Method method; + try { + method = fallbacks.getMethod(methodName, parameterTypes); + method.setAccessible(true); + } + catch (NoSuchMethodException e) { + // TODO + throw new RuntimeException(e); + } + circuitBreaker.run(() -> newRequest(requestValues).retrieve().toBodilessEntity(), + throwable -> { + try { + return method.invoke(this, + attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME)); + } + catch (IllegalAccessException | InvocationTargetException e) { + // TODO + throw new RuntimeException(e); + } + }); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity() + .getHeaders()); + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return circuitBreaker.run(() -> { + if (bodyType.getType().equals(InputStream.class)) { + return (T) newRequest(values).exchange((request, response) -> response.getBody(), false); + } + return newRequest(values).retrieve().body(bodyType); + }); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity()); + } + + @SuppressWarnings("unchecked") + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return circuitBreaker.run(() -> { + if (bodyType.getType().equals(InputStream.class)) { + return (ResponseEntity) newRequest(values).exchangeForRequiredValue((request, response) -> + ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody()), false); + } + return newRequest(values).retrieve().toEntity(bodyType); + }); + } + + @SuppressWarnings("unchecked") + private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { + + HttpMethod httpMethod = values.getHttpMethod(); + Assert.notNull(httpMethod, "HttpMethod is required"); + + RestClient.RequestBodyUriSpec uriSpec = this.restClient.method(httpMethod); + + RestClient.RequestBodySpec bodySpec; + if (values.getUri() != null) { + bodySpec = uriSpec.uri(values.getUri()); + } + else if (values.getUriTemplate() != null) { + UriBuilderFactory uriBuilderFactory = values.getUriBuilderFactory(); + if (uriBuilderFactory != null) { + URI uri = uriBuilderFactory.expand(values.getUriTemplate(), values.getUriVariables()); + bodySpec = uriSpec.uri(uri); + } + else { + bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); + } + } + else { + throw new IllegalStateException("Neither full URL nor URI template"); + } + + bodySpec.headers(headers -> headers.putAll(values.getHeaders())); + + if (!values.getCookies().isEmpty()) { + List cookies = new ArrayList<>(); + values.getCookies() + .forEach((name, cookieValues) -> cookieValues.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); + bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); + } + + if (values.getApiVersion() != null) { + bodySpec.apiVersion(values.getApiVersion()); + } + + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); + + B body = (B) values.getBodyValue(); + if (body != null) { + if (body instanceof StreamingHttpOutputMessage.Body streamingBody) { + bodySpec.body(streamingBody); + } + else if (values.getBodyValueType() != null) { + bodySpec.body(body, (ParameterizedTypeReference) values.getBodyValueType()); + } + else { + bodySpec.body(body); + } + } + + return bodySpec; + } + + + /** + * Create a {@link RestClientAdapter} for the given {@link RestClient}. + */ + public static CircuitBreakerRestClientAdapter create(RestClient restClient, CircuitBreaker circuitBreaker, + Class fallbacks) { + return new CircuitBreakerRestClientAdapter(restClient, circuitBreaker, fallbacks); + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java new file mode 100644 index 000000000..b48e53765 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -0,0 +1,53 @@ +package org.springframework.cloud.client.circuitbreaker; + +import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer { + + // Make sure Boot's configurers run before + private static final int ORDER = 11; + + private final CloudHttpClientServiceProperties clientServiceProperties; + + public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties) { + this.clientServiceProperties = clientServiceProperties; + } + + @Override + public void configureGroups(Groups groups) { + groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { + String groupName = group.name(); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() + .get(groupName); + String fallbackClass = groupProperties == null ? null : groupProperties.getFallbackClass(); + factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); + Class fallbacks = null; + try { + fallbacks = Class.forName(fallbackClass); + } + catch (ClassNotFoundException e) { + // TODO + throw new RuntimeException(e); + } + // TODO: change to decorator + factoryBuilder.exchangeAdapter(CircuitBreakerRestClientAdapter.create(RestClient.builder() + .build(), buildCircuitBreaker(), fallbacks)); + }); + } + + + private CircuitBreaker buildCircuitBreaker() { + return null; + } + + + @Override + public int getOrder() { + return ORDER; + } +} From 6dfd121d7ad7da2619252e8af907a1183f3a4c58 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 18 Jun 2025 15:42:49 +0200 Subject: [PATCH 02/26] Handle missing fallbacks. Fix configuration. Signed-off-by: Olga Maciaszek-Sharma --- ...tractCloudHttpClientServiceProperties.java | 17 +++ .../CloudHttpClientServiceProperties.java | 17 +++ .../CommonsClientAutoConfiguration.java | 18 +++ .../CircuitBreakerRequestValueProcessor.java | 28 ++++ .../CircuitBreakerRestClientAdapter.java | 130 ++++++++++++------ ...rRestClientHttpServiceGroupConfigurer.java | 51 +++++-- 6 files changed, 208 insertions(+), 53 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java index de6ac0052..9be81c225 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client; /** @@ -14,4 +30,5 @@ public String getFallbackClass() { public void setFallbackClass(String fallbackClass) { this.fallbackClass = fallbackClass; } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java index 671a755da..5957a8511 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client; import java.util.LinkedHashMap; @@ -27,4 +43,5 @@ public void setGroup(Map group) { public static class Group extends AbstractCloudHttpClientServiceProperties { } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index ad30201c4..21cfb4ff1 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -31,6 +31,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.client.actuator.FeaturesEndpoint; import org.springframework.cloud.client.actuator.HasFeatures; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerRestClientHttpServiceGroupConfigurer; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicator; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicatorProperties; @@ -39,6 +42,7 @@ import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} for Spring Cloud Commons Client. @@ -49,8 +53,22 @@ * @author Omer Naci Soydemir */ @Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(CloudHttpClientServiceProperties.class) public class CommonsClientAutoConfiguration { + // FIXME: move instantiation to`spring-cloud-circuitbreaker` project + @ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class }) + @ConditionalOnBean(CircuitBreakerFactory.class) + protected static class CircuitBreakerInterfaceClientsAutoConfiguration { + + @Bean + public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClientConfigurer( + CloudHttpClientServiceProperties properties, CircuitBreakerFactory circuitBreakerFactory) { + return new CircuitBreakerRestClientHttpServiceGroupConfigurer(properties, circuitBreakerFactory); + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(HealthIndicator.class) @EnableConfigurationProperties(DiscoveryClientHealthIndicatorProperties.class) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java index 5b936b07c..f6408c3de 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker; import java.lang.reflect.Method; @@ -11,8 +27,19 @@ */ public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Processor { + /** + * Spring Cloud-specific attribute name for storing method name. + */ public static final String METHOD_ATTRIBUTE_NAME = "spring.cloud.method.name"; + + /** + * Spring Cloud-specific attribute name for storing method parameter types. + */ public static final String PARAMETER_TYPES_ATTRIBUTE_NAME = "spring.cloud.method.parameter-types"; + + /** + * Spring Cloud-specific attribute name for storing method arguments. + */ public static final String ARGUMENTS_ATTRIBUTE_NAME = "spring.cloud.method.arguments"; @Override @@ -21,4 +48,5 @@ public void process(Method method, @Nullable Object[] arguments, HttpRequestValu builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java index 7197b0040..ba5d6183b 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker; import java.io.InputStream; @@ -7,7 +23,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Function; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.core.ParameterizedTypeReference; @@ -27,12 +46,18 @@ * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev */ -public class CircuitBreakerRestClientAdapter implements HttpExchangeAdapter { +// TODO: change into decorator after support in FW added +@SuppressWarnings("unchecked") +public final class CircuitBreakerRestClientAdapter implements HttpExchangeAdapter { + + // FIXME: get fallbacks from factory - // FIXME: get fallbacks + private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientAdapter.class); private final RestClient restClient; + private final CircuitBreaker circuitBreaker; + private final Class fallbacks; private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker circuitBreaker, @@ -43,7 +68,6 @@ private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker ci this.fallbacks = fallbacks; } - @Override public boolean supportsRequestAttributes() { return true; @@ -51,67 +75,45 @@ public boolean supportsRequestAttributes() { @Override public void exchange(HttpRequestValues requestValues) { - Map attributes = requestValues.getAttributes(); - String methodName = String.valueOf(attributes - .get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); - Class[] parameterTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); - Method method; - try { - method = fallbacks.getMethod(methodName, parameterTypes); - method.setAccessible(true); - } - catch (NoSuchMethodException e) { - // TODO - throw new RuntimeException(e); - } circuitBreaker.run(() -> newRequest(requestValues).retrieve().toBodilessEntity(), - throwable -> { - try { - return method.invoke(this, - attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME)); - } - catch (IllegalAccessException | InvocationTargetException e) { - // TODO - throw new RuntimeException(e); - } - }); + handleThrowable(requestValues)); } @Override public HttpHeaders exchangeForHeaders(HttpRequestValues values) { - return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity() - .getHeaders()); + return (HttpHeaders) circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity().getHeaders(), + handleThrowable(values)); } @SuppressWarnings("unchecked") @Override public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { - return circuitBreaker.run(() -> { + return (T) circuitBreaker.run(() -> { if (bodyType.getType().equals(InputStream.class)) { return (T) newRequest(values).exchange((request, response) -> response.getBody(), false); } return newRequest(values).retrieve().body(bodyType); - }); + }, handleThrowable(values)); } @Override public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { - return circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity()); + return (ResponseEntity) circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity(), + handleThrowable(values)); } @SuppressWarnings("unchecked") @Override public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { - return circuitBreaker.run(() -> { + return (ResponseEntity) circuitBreaker.run(() -> { if (bodyType.getType().equals(InputStream.class)) { - return (ResponseEntity) newRequest(values).exchangeForRequiredValue((request, response) -> - ResponseEntity.status(response.getStatusCode()) - .headers(response.getHeaders()) - .body(response.getBody()), false); + return newRequest(values) + .exchangeForRequiredValue((request, response) -> ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody()), false); } return newRequest(values).retrieve().toEntity(bodyType); - }); + }, handleThrowable(values)); } @SuppressWarnings("unchecked") @@ -144,11 +146,10 @@ else if (values.getUriTemplate() != null) { if (!values.getCookies().isEmpty()) { List cookies = new ArrayList<>(); - values.getCookies() - .forEach((name, cookieValues) -> cookieValues.forEach(value -> { - HttpCookie cookie = new HttpCookie(name, value); - cookies.add(cookie.toString()); - })); + values.getCookies().forEach((name, cookieValues) -> cookieValues.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } @@ -174,6 +175,49 @@ else if (values.getBodyValueType() != null) { return bodySpec; } + private Method getFallbackMethod(Map attributes) { + if (fallbacks == null) { + return null; + } + String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); + Class[] parameterTypes = (Class[]) attributes + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + Method method; + try { + method = fallbacks.getMethod(methodName, parameterTypes); + method.setAccessible(true); + } + catch (NoSuchMethodException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Could not find fallback method " + methodName + " in class " + fallbacks.getName(), e); + } + throw new RuntimeException(e); + } + return method; + } + + private Function handleThrowable(HttpRequestValues requestValues) { + Map attributes = requestValues.getAttributes(); + Method fallbackMethod = getFallbackMethod(attributes); + + return throwable -> { + try { + if (fallbackMethod == null) { + throw new NoFallbackAvailableException("No fallback available.", throwable); + } + // FIXME: create proxy to invoke method + return fallbackMethod.invoke(this, + attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME)); + } + catch (IllegalAccessException | InvocationTargetException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Could not invoke fallback method " + fallbackMethod.getName() + " due to exception: " + + e.getMessage(), e); + } + throw new RuntimeException(e); + } + }; + } /** * Create a {@link RestClientAdapter} for the given {@link RestClient}. diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index b48e53765..f7e5a017e 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -1,5 +1,24 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import org.springframework.cloud.client.CloudHttpClientServiceProperties; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; @@ -12,42 +31,54 @@ public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestC // Make sure Boot's configurers run before private static final int ORDER = 11; + private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientHttpServiceGroupConfigurer.class); + private final CloudHttpClientServiceProperties clientServiceProperties; - public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties) { + private final CircuitBreakerFactory circuitBreakerFactory; + + public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties, + CircuitBreakerFactory circuitBreakerFactory) { this.clientServiceProperties = clientServiceProperties; + this.circuitBreakerFactory = circuitBreakerFactory; } @Override public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() - .get(groupName); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); String fallbackClass = groupProperties == null ? null : groupProperties.getFallbackClass(); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); Class fallbacks = null; try { - fallbacks = Class.forName(fallbackClass); + fallbacks = fallbackClass != null ? Class.forName(fallbackClass) : null; } catch (ClassNotFoundException e) { - // TODO + if (LOG.isDebugEnabled()) { + LOG.debug("Could not load fallback class: " + fallbackClass, e); + } throw new RuntimeException(e); } // TODO: change to decorator - factoryBuilder.exchangeAdapter(CircuitBreakerRestClientAdapter.create(RestClient.builder() - .build(), buildCircuitBreaker(), fallbacks)); + factoryBuilder.exchangeAdapter(CircuitBreakerRestClientAdapter.create(RestClient.builder().build(), + buildCircuitBreaker(resolveCircuitBreakerName(groupName)), fallbacks)); }); } - - private CircuitBreaker buildCircuitBreaker() { - return null; + // TODO + private String resolveCircuitBreakerName(String groupName) { + return groupName; } + // TODO + private CircuitBreaker buildCircuitBreaker(String circuitBreakerName) { + return circuitBreakerFactory.create(circuitBreakerName); + } @Override public int getOrder() { return ORDER; } + } From 5c1e91f87653450b81571d42567cdb7beda36113 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 23 Jun 2025 17:44:35 +0200 Subject: [PATCH 03/26] Create proxy to invoke fallback method. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerRestClientAdapter.java | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java index ba5d6183b..9f5da461c 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java @@ -29,6 +29,7 @@ import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; +import org.springframework.aop.framework.ProxyFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; @@ -60,6 +61,8 @@ public final class CircuitBreakerRestClientAdapter implements HttpExchangeAdapte private final Class fallbacks; + private Object fallbackProxy; + private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker circuitBreaker, // TODO: generics Class fallbacks) { @@ -81,7 +84,8 @@ public void exchange(HttpRequestValues requestValues) { @Override public HttpHeaders exchangeForHeaders(HttpRequestValues values) { - return (HttpHeaders) circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity().getHeaders(), + return (HttpHeaders) circuitBreaker.run(() -> newRequest(values).retrieve() + .toBodilessEntity().getHeaders(), handleThrowable(values)); } @@ -98,7 +102,8 @@ public HttpHeaders exchangeForHeaders(HttpRequestValues values) { @Override public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { - return (ResponseEntity) circuitBreaker.run(() -> newRequest(values).retrieve().toBodilessEntity(), + return (ResponseEntity) circuitBreaker.run(() -> newRequest(values).retrieve() + .toBodilessEntity(), handleThrowable(values)); } @@ -108,9 +113,9 @@ public ResponseEntity exchangeForEntity(HttpRequestValues values, Paramet return (ResponseEntity) circuitBreaker.run(() -> { if (bodyType.getType().equals(InputStream.class)) { return newRequest(values) - .exchangeForRequiredValue((request, response) -> ResponseEntity.status(response.getStatusCode()) - .headers(response.getHeaders()) - .body(response.getBody()), false); + .exchangeForRequiredValue((request, response) -> ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody()), false); } return newRequest(values).retrieve().toEntity(bodyType); }, handleThrowable(values)); @@ -146,10 +151,11 @@ else if (values.getUriTemplate() != null) { if (!values.getCookies().isEmpty()) { List cookies = new ArrayList<>(); - values.getCookies().forEach((name, cookieValues) -> cookieValues.forEach(value -> { - HttpCookie cookie = new HttpCookie(name, value); - cookies.add(cookie.toString()); - })); + values.getCookies() + .forEach((name, cookieValues) -> cookieValues.forEach(value -> { + HttpCookie cookie = new HttpCookie(name, value); + cookies.add(cookie.toString()); + })); bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } @@ -181,7 +187,7 @@ private Method getFallbackMethod(Map attributes) { } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] parameterTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); Method method; try { method = fallbacks.getMethod(methodName, parameterTypes); @@ -205,9 +211,10 @@ private Function handleThrowable(HttpRequestValues requestVal if (fallbackMethod == null) { throw new NoFallbackAvailableException("No fallback available.", throwable); } - // FIXME: create proxy to invoke method - return fallbackMethod.invoke(this, - attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME)); + Object fallbackProxy = getFallbackProxy(); + Object[] arguments = (Object[]) attributes + .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + return fallbackMethod.invoke(fallbackProxy, arguments); } catch (IllegalAccessException | InvocationTargetException e) { if (LOG.isErrorEnabled()) { @@ -216,9 +223,24 @@ private Function handleThrowable(HttpRequestValues requestVal } throw new RuntimeException(e); } + // TODO + catch (InstantiationException | NoSuchMethodException e) { + throw new RuntimeException(e); + } }; } + private Object getFallbackProxy() + throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + if (fallbackProxy == null) { + Object target = fallbacks.getConstructor().newInstance(); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + fallbackProxy = proxyFactory.getProxy(); + } + return fallbackProxy; + } + /** * Create a {@link RestClientAdapter} for the given {@link RestClient}. */ From 12352c311dabb9c95a7e3355b87021859dfbc23a Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 23 Jun 2025 17:52:52 +0200 Subject: [PATCH 04/26] Handle proxy target instantiation exceptions. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerRestClientAdapter.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java index 9f5da461c..54a5ed954 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java @@ -177,7 +177,6 @@ else if (values.getBodyValueType() != null) { bodySpec.body(body); } } - return bodySpec; } @@ -223,8 +222,17 @@ private Function handleThrowable(HttpRequestValues requestVal } throw new RuntimeException(e); } - // TODO - catch (InstantiationException | NoSuchMethodException e) { + catch (NoSuchMethodException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Default constructor not found in: " + fallbacks.getName() + + ". Fallback class needs to have a default constructor", e); + } + throw new RuntimeException(e); + } + catch (InstantiationException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Could not instantiate fallback class: " + fallbacks.getName(), e); + } throw new RuntimeException(e); } }; From 1d9b8a606797ca77c3d3829568012a86dbead5ac Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 1 Jul 2025 13:17:16 +0200 Subject: [PATCH 05/26] Add decorator. Signed-off-by: Olga Maciaszek-Sharma --- ...cuitBreakerRestClientAdapterDecorator.java | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java new file mode 100644 index 000000000..8721e1d20 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java @@ -0,0 +1,160 @@ +package org.springframework.cloud.client.circuitbreaker; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerRestClientAdapterDecorator extends HttpExchangeAdapterDecorator { + + private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientAdapterDecorator.class); + + private final CircuitBreaker circuitBreaker; + + private final Class fallbacks; + + private Object fallbackProxy; + + public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, + CircuitBreaker circuitBreaker, Class fallbacks) { + super(delegate); + this.circuitBreaker = circuitBreaker; + this.fallbacks = fallbacks; + } + + @Override + public void exchange(HttpRequestValues requestValues) { + super.exchange(requestValues); + circuitBreaker.run(() -> { + super.exchange(requestValues); + return null; + }, + handleThrowable(requestValues)); + } + + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return (HttpHeaders) circuitBreaker.run(() -> super.exchangeForHeaders(values), + handleThrowable(values)); + } + + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), + handleThrowable(values)); + return handleCast(result); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), + handleThrowable(values)); + return handleCast(result); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + Object result = circuitBreaker.run(() -> super.exchangeForEntity(values, bodyType), handleThrowable(values)); + return handleCast(result); + } + + @SuppressWarnings("unchecked") + private T handleCast(Object result) { + try { + return (T) result; + } + catch (ClassCastException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Failed to cast object of type " + result.getClass() + " to expected type."); + } + throw exception; + } + } + + + private Function handleThrowable(HttpRequestValues requestValues) { + Map attributes = requestValues.getAttributes(); + Method fallbackMethod = getFallbackMethod(attributes); + + return throwable -> { + try { + if (fallbackMethod == null) { + throw new NoFallbackAvailableException("No fallback available.", throwable); + } + Object fallbackProxy = getFallbackProxy(); + Object[] arguments = (Object[]) attributes + .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + return fallbackMethod.invoke(fallbackProxy, arguments); + } + catch (IllegalAccessException | InvocationTargetException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Could not invoke fallback method " + fallbackMethod.getName() + " due to exception: " + + e.getMessage(), e); + } + throw new RuntimeException(e); + } + catch (NoSuchMethodException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Default constructor not found in: " + fallbacks.getName() + + ". Fallback class needs to have a default constructor", e); + } + throw new RuntimeException(e); + } + catch (InstantiationException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Could not instantiate fallback class: " + fallbacks.getName(), e); + } + throw new RuntimeException(e); + } + }; + } + + private Method getFallbackMethod(Map attributes) { + if (fallbacks == null) { + return null; + } + String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); + Class[] parameterTypes = (Class[]) attributes + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + Method method; + try { + method = fallbacks.getMethod(methodName, parameterTypes); + method.setAccessible(true); + } + catch (NoSuchMethodException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Could not find fallback method " + methodName + " in class " + fallbacks.getName(), e); + } + throw new RuntimeException(e); + } + return method; + } + + private Object getFallbackProxy() + throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + if (fallbackProxy == null) { + Object target = fallbacks.getConstructor().newInstance(); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + fallbackProxy = proxyFactory.getProxy(); + } + return fallbackProxy; + } +} From 034aae772618ea7661755934adfe0ca14aa84185 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 1 Jul 2025 17:42:21 +0200 Subject: [PATCH 06/26] Switch to using decorator. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerRestClientAdapter.java | 260 ------------------ ...cuitBreakerRestClientAdapterDecorator.java | 45 +-- ...rRestClientHttpServiceGroupConfigurer.java | 7 +- 3 files changed, 30 insertions(+), 282 deletions(-) delete mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java deleted file mode 100644 index 54a5ed954..000000000 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapter.java +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright 2013-2025 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.client.circuitbreaker; - -import java.io.InputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Function; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.Nullable; - -import org.springframework.aop.framework.ProxyFactory; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpCookie; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.http.StreamingHttpOutputMessage; -import org.springframework.util.Assert; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.support.RestClientAdapter; -import org.springframework.web.service.invoker.HttpExchangeAdapter; -import org.springframework.web.service.invoker.HttpRequestValues; -import org.springframework.web.util.UriBuilderFactory; - -/** - * @author Olga Maciaszek-Sharma - * @author Rossen Stoyanchev - */ -// TODO: change into decorator after support in FW added -@SuppressWarnings("unchecked") -public final class CircuitBreakerRestClientAdapter implements HttpExchangeAdapter { - - // FIXME: get fallbacks from factory - - private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientAdapter.class); - - private final RestClient restClient; - - private final CircuitBreaker circuitBreaker; - - private final Class fallbacks; - - private Object fallbackProxy; - - private CircuitBreakerRestClientAdapter(RestClient restClient, CircuitBreaker circuitBreaker, - // TODO: generics - Class fallbacks) { - this.restClient = restClient; - this.circuitBreaker = circuitBreaker; - this.fallbacks = fallbacks; - } - - @Override - public boolean supportsRequestAttributes() { - return true; - } - - @Override - public void exchange(HttpRequestValues requestValues) { - circuitBreaker.run(() -> newRequest(requestValues).retrieve().toBodilessEntity(), - handleThrowable(requestValues)); - } - - @Override - public HttpHeaders exchangeForHeaders(HttpRequestValues values) { - return (HttpHeaders) circuitBreaker.run(() -> newRequest(values).retrieve() - .toBodilessEntity().getHeaders(), - handleThrowable(values)); - } - - @SuppressWarnings("unchecked") - @Override - public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { - return (T) circuitBreaker.run(() -> { - if (bodyType.getType().equals(InputStream.class)) { - return (T) newRequest(values).exchange((request, response) -> response.getBody(), false); - } - return newRequest(values).retrieve().body(bodyType); - }, handleThrowable(values)); - } - - @Override - public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { - return (ResponseEntity) circuitBreaker.run(() -> newRequest(values).retrieve() - .toBodilessEntity(), - handleThrowable(values)); - } - - @SuppressWarnings("unchecked") - @Override - public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { - return (ResponseEntity) circuitBreaker.run(() -> { - if (bodyType.getType().equals(InputStream.class)) { - return newRequest(values) - .exchangeForRequiredValue((request, response) -> ResponseEntity.status(response.getStatusCode()) - .headers(response.getHeaders()) - .body(response.getBody()), false); - } - return newRequest(values).retrieve().toEntity(bodyType); - }, handleThrowable(values)); - } - - @SuppressWarnings("unchecked") - private RestClient.RequestBodySpec newRequest(HttpRequestValues values) { - - HttpMethod httpMethod = values.getHttpMethod(); - Assert.notNull(httpMethod, "HttpMethod is required"); - - RestClient.RequestBodyUriSpec uriSpec = this.restClient.method(httpMethod); - - RestClient.RequestBodySpec bodySpec; - if (values.getUri() != null) { - bodySpec = uriSpec.uri(values.getUri()); - } - else if (values.getUriTemplate() != null) { - UriBuilderFactory uriBuilderFactory = values.getUriBuilderFactory(); - if (uriBuilderFactory != null) { - URI uri = uriBuilderFactory.expand(values.getUriTemplate(), values.getUriVariables()); - bodySpec = uriSpec.uri(uri); - } - else { - bodySpec = uriSpec.uri(values.getUriTemplate(), values.getUriVariables()); - } - } - else { - throw new IllegalStateException("Neither full URL nor URI template"); - } - - bodySpec.headers(headers -> headers.putAll(values.getHeaders())); - - if (!values.getCookies().isEmpty()) { - List cookies = new ArrayList<>(); - values.getCookies() - .forEach((name, cookieValues) -> cookieValues.forEach(value -> { - HttpCookie cookie = new HttpCookie(name, value); - cookies.add(cookie.toString()); - })); - bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); - } - - if (values.getApiVersion() != null) { - bodySpec.apiVersion(values.getApiVersion()); - } - - bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); - - B body = (B) values.getBodyValue(); - if (body != null) { - if (body instanceof StreamingHttpOutputMessage.Body streamingBody) { - bodySpec.body(streamingBody); - } - else if (values.getBodyValueType() != null) { - bodySpec.body(body, (ParameterizedTypeReference) values.getBodyValueType()); - } - else { - bodySpec.body(body); - } - } - return bodySpec; - } - - private Method getFallbackMethod(Map attributes) { - if (fallbacks == null) { - return null; - } - String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); - Class[] parameterTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); - Method method; - try { - method = fallbacks.getMethod(methodName, parameterTypes); - method.setAccessible(true); - } - catch (NoSuchMethodException e) { - if (LOG.isDebugEnabled()) { - LOG.debug("Could not find fallback method " + methodName + " in class " + fallbacks.getName(), e); - } - throw new RuntimeException(e); - } - return method; - } - - private Function handleThrowable(HttpRequestValues requestValues) { - Map attributes = requestValues.getAttributes(); - Method fallbackMethod = getFallbackMethod(attributes); - - return throwable -> { - try { - if (fallbackMethod == null) { - throw new NoFallbackAvailableException("No fallback available.", throwable); - } - Object fallbackProxy = getFallbackProxy(); - Object[] arguments = (Object[]) attributes - .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); - return fallbackMethod.invoke(fallbackProxy, arguments); - } - catch (IllegalAccessException | InvocationTargetException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Could not invoke fallback method " + fallbackMethod.getName() + " due to exception: " - + e.getMessage(), e); - } - throw new RuntimeException(e); - } - catch (NoSuchMethodException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Default constructor not found in: " + fallbacks.getName() - + ". Fallback class needs to have a default constructor", e); - } - throw new RuntimeException(e); - } - catch (InstantiationException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Could not instantiate fallback class: " + fallbacks.getName(), e); - } - throw new RuntimeException(e); - } - }; - } - - private Object getFallbackProxy() - throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { - if (fallbackProxy == null) { - Object target = fallbacks.getConstructor().newInstance(); - ProxyFactory proxyFactory = new ProxyFactory(target); - proxyFactory.setProxyTargetClass(true); - fallbackProxy = proxyFactory.getProxy(); - } - return fallbackProxy; - } - - /** - * Create a {@link RestClientAdapter} for the given {@link RestClient}. - */ - public static CircuitBreakerRestClientAdapter create(RestClient restClient, CircuitBreaker circuitBreaker, - Class fallbacks) { - return new CircuitBreakerRestClientAdapter(restClient, circuitBreaker, fallbacks); - } - -} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java index 8721e1d20..eb6149043 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker; import java.lang.reflect.InvocationTargetException; @@ -30,8 +46,8 @@ public class CircuitBreakerRestClientAdapterDecorator extends HttpExchangeAdapte private Object fallbackProxy; - public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, - CircuitBreaker circuitBreaker, Class fallbacks) { + public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, + Class fallbacks) { super(delegate); this.circuitBreaker = circuitBreaker; this.fallbacks = fallbacks; @@ -39,33 +55,26 @@ public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, @Override public void exchange(HttpRequestValues requestValues) { - super.exchange(requestValues); circuitBreaker.run(() -> { - super.exchange(requestValues); - return null; - }, - handleThrowable(requestValues)); + super.exchange(requestValues); + return null; + }, handleThrowable(requestValues)); } - @Override public HttpHeaders exchangeForHeaders(HttpRequestValues values) { - return (HttpHeaders) circuitBreaker.run(() -> super.exchangeForHeaders(values), - handleThrowable(values)); + return (HttpHeaders) circuitBreaker.run(() -> super.exchangeForHeaders(values), handleThrowable(values)); } - @Override public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { - Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), - handleThrowable(values)); + Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), handleThrowable(values)); return handleCast(result); } @Override public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { - Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), - handleThrowable(values)); + Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), handleThrowable(values)); return handleCast(result); } @@ -88,7 +97,6 @@ private T handleCast(Object result) { } } - private Function handleThrowable(HttpRequestValues requestValues) { Map attributes = requestValues.getAttributes(); Method fallbackMethod = getFallbackMethod(attributes); @@ -100,7 +108,7 @@ private Function handleThrowable(HttpRequestValues requestVal } Object fallbackProxy = getFallbackProxy(); Object[] arguments = (Object[]) attributes - .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); return fallbackMethod.invoke(fallbackProxy, arguments); } catch (IllegalAccessException | InvocationTargetException e) { @@ -132,7 +140,7 @@ private Method getFallbackMethod(Map attributes) { } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] parameterTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); Method method; try { method = fallbacks.getMethod(methodName, parameterTypes); @@ -157,4 +165,5 @@ private Object getFallbackProxy() } return fallbackProxy; } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index f7e5a017e..9c96e17e0 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -50,7 +50,7 @@ public void configureGroups(Groups groups) { CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); String fallbackClass = groupProperties == null ? null : groupProperties.getFallbackClass(); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); - Class fallbacks = null; + Class fallbacks; try { fallbacks = fallbackClass != null ? Class.forName(fallbackClass) : null; } @@ -60,9 +60,8 @@ public void configureGroups(Groups groups) { } throw new RuntimeException(e); } - // TODO: change to decorator - factoryBuilder.exchangeAdapter(CircuitBreakerRestClientAdapter.create(RestClient.builder().build(), - buildCircuitBreaker(resolveCircuitBreakerName(groupName)), fallbacks)); + factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerRestClientAdapterDecorator( + httpExchangeAdapter, buildCircuitBreaker(resolveCircuitBreakerName(groupName)), fallbacks)); }); } From 397ff05b24a53a84e4b1638a94a515ff2cefa401 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 1 Jul 2025 19:26:14 +0200 Subject: [PATCH 07/26] Refactor CircuitBreakerRestClientAdapterDecorator. Signed-off-by: Olga Maciaszek-Sharma --- ...cuitBreakerRestClientAdapterDecorator.java | 131 ++++++++++-------- 1 file changed, 75 insertions(+), 56 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java index eb6149043..1ceeefc51 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java @@ -18,8 +18,10 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.Map; import java.util.function.Function; +import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -42,15 +44,15 @@ public class CircuitBreakerRestClientAdapterDecorator extends HttpExchangeAdapte private final CircuitBreaker circuitBreaker; - private final Class fallbacks; + private final Class fallbackClass; - private Object fallbackProxy; + private volatile Object fallbackProxy; public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, - Class fallbacks) { + Class fallbackClass) { super(delegate); this.circuitBreaker = circuitBreaker; - this.fallbacks = fallbacks; + this.fallbackClass = fallbackClass; } @Override @@ -58,34 +60,38 @@ public void exchange(HttpRequestValues requestValues) { circuitBreaker.run(() -> { super.exchange(requestValues); return null; - }, handleThrowable(requestValues)); + }, createFallbackHandler(requestValues)); } @Override public HttpHeaders exchangeForHeaders(HttpRequestValues values) { - return (HttpHeaders) circuitBreaker.run(() -> super.exchangeForHeaders(values), handleThrowable(values)); + Object result = circuitBreaker.run(() -> super.exchangeForHeaders(values), createFallbackHandler(values)); + return castIfPossible(result); } @Override public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { - Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), handleThrowable(values)); - return handleCast(result); + Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), + createFallbackHandler(values)); + return castIfPossible(result); } @Override public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { - Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), handleThrowable(values)); - return handleCast(result); + Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), + createFallbackHandler(values)); + return castIfPossible(result); } @Override public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { - Object result = circuitBreaker.run(() -> super.exchangeForEntity(values, bodyType), handleThrowable(values)); - return handleCast(result); + Object result = circuitBreaker.run(() -> super.exchangeForEntity(values, bodyType), + createFallbackHandler(values)); + return castIfPossible(result); } @SuppressWarnings("unchecked") - private T handleCast(Object result) { + private T castIfPossible(Object result) { try { return (T) result; } @@ -97,71 +103,84 @@ private T handleCast(Object result) { } } - private Function handleThrowable(HttpRequestValues requestValues) { + private Function createFallbackHandler(HttpRequestValues requestValues) { Map attributes = requestValues.getAttributes(); - Method fallbackMethod = getFallbackMethod(attributes); + Method fallback = resolveFallbackMethod(attributes, false); + Method fallbackWithCause = resolveFallbackMethod(attributes, true); return throwable -> { - try { - if (fallbackMethod == null) { - throw new NoFallbackAvailableException("No fallback available.", throwable); - } - Object fallbackProxy = getFallbackProxy(); - Object[] arguments = (Object[]) attributes - .get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); - return fallbackMethod.invoke(fallbackProxy, arguments); + if (fallback != null) { + return invokeFallback(fallback, attributes, null); } - catch (IllegalAccessException | InvocationTargetException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Could not invoke fallback method " + fallbackMethod.getName() + " due to exception: " - + e.getMessage(), e); - } - throw new RuntimeException(e); + else if (fallbackWithCause != null) { + return invokeFallback(fallbackWithCause, attributes, throwable); } - catch (NoSuchMethodException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Default constructor not found in: " + fallbacks.getName() - + ". Fallback class needs to have a default constructor", e); - } - throw new RuntimeException(e); - } - catch (InstantiationException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Could not instantiate fallback class: " + fallbacks.getName(), e); - } - throw new RuntimeException(e); + else { + throw new NoFallbackAvailableException("No fallback available.", throwable); } }; + } - private Method getFallbackMethod(Map attributes) { - if (fallbacks == null) { + private Method resolveFallbackMethod(Map attributes, boolean withThrowable) { + if (fallbackClass == null) { return null; } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); - Class[] parameterTypes = (Class[]) attributes + Class[] paramTypes = (Class[]) attributes .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); - Method method; + Class[] effectiveTypes = withThrowable + ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) + : paramTypes; + try { - method = fallbacks.getMethod(methodName, parameterTypes); + Method method = fallbackClass.getMethod(methodName, effectiveTypes); method.setAccessible(true); + return method; } - catch (NoSuchMethodException e) { + catch (NoSuchMethodException exception) { if (LOG.isDebugEnabled()) { - LOG.debug("Could not find fallback method " + methodName + " in class " + fallbacks.getName(), e); + LOG.debug("Fallback method not found: " + methodName + " in " + fallbackClass.getName(), exception); } - throw new RuntimeException(e); + return null; } - return method; } - private Object getFallbackProxy() - throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + private Object invokeFallback(Method method, Map attributes, @Nullable Throwable throwable) { + try { + Object proxy = getFallbackProxy(); + Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + Object[] finalArgs = (throwable != null) + ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; + return method.invoke(proxy, finalArgs); + } + catch (InvocationTargetException | IllegalAccessException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Error invoking fallback method: " + method.getName(), exception); + } + throw new RuntimeException("Failed to invoke fallback method", exception); + } + } + + private Object getFallbackProxy() { if (fallbackProxy == null) { - Object target = fallbacks.getConstructor().newInstance(); - ProxyFactory proxyFactory = new ProxyFactory(target); - proxyFactory.setProxyTargetClass(true); - fallbackProxy = proxyFactory.getProxy(); + synchronized (this) { + if (fallbackProxy == null) { + try { + Object target = fallbackClass.getConstructor().newInstance(); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + fallbackProxy = proxyFactory.getProxy(); + } + catch (ReflectiveOperationException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Error instantiating fallback proxy for class: " + fallbackClass.getName(), + exception); + } + throw new RuntimeException("Could not create fallback proxy", exception); + } + } + } } return fallbackProxy; } From de9b5ce60d3f49a49f6ff76818c0d0a2bcf0a6ac Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 2 Jul 2025 13:22:08 +0200 Subject: [PATCH 08/26] Refactor CircuitBreakerRestClientAdapterDecorator and CircuitBreakerRestClientHttpServiceGroupConfigurer. Signed-off-by: Olga Maciaszek-Sharma --- ...cuitBreakerRestClientAdapterDecorator.java | 7 +++ ...rRestClientHttpServiceGroupConfigurer.java | 50 +++++++++++-------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java index 1ceeefc51..ff54e612f 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java @@ -158,6 +158,13 @@ private Object invokeFallback(Method method, Map attributes, @Nu if (LOG.isErrorEnabled()) { LOG.error("Error invoking fallback method: " + method.getName(), exception); } + Throwable underlyingException = exception.getCause(); + if (underlyingException instanceof RuntimeException) { + throw (RuntimeException) underlyingException; + } + if (underlyingException != null) { + throw new IllegalStateException("Failed to invoke fallback method", underlyingException); + } throw new RuntimeException("Failed to invoke fallback method", exception); } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 9c96e17e0..9d559a66a 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -29,7 +29,7 @@ public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer { // Make sure Boot's configurers run before - private static final int ORDER = 11; + private static final int ORDER = 15; private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientHttpServiceGroupConfigurer.class); @@ -37,7 +37,8 @@ public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestC private final CircuitBreakerFactory circuitBreakerFactory; - public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties, + public CircuitBreakerRestClientHttpServiceGroupConfigurer( + CloudHttpClientServiceProperties clientServiceProperties, CircuitBreakerFactory circuitBreakerFactory) { this.clientServiceProperties = clientServiceProperties; this.circuitBreakerFactory = circuitBreakerFactory; @@ -47,32 +48,37 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientService public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); - String fallbackClass = groupProperties == null ? null : groupProperties.getFallbackClass(); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() + .get(groupName); + String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClass() : null; + Class fallbackClass = resolveFallbackClass(fallbackClassName); + factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); - Class fallbacks; - try { - fallbacks = fallbackClass != null ? Class.forName(fallbackClass) : null; - } - catch (ClassNotFoundException e) { - if (LOG.isDebugEnabled()) { - LOG.debug("Could not load fallback class: " + fallbackClass, e); - } - throw new RuntimeException(e); - } - factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerRestClientAdapterDecorator( - httpExchangeAdapter, buildCircuitBreaker(resolveCircuitBreakerName(groupName)), fallbacks)); + + factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> + new CircuitBreakerRestClientAdapterDecorator( + httpExchangeAdapter, + buildCircuitBreaker(groupName), + fallbackClass)); }); } - // TODO - private String resolveCircuitBreakerName(String groupName) { - return groupName; + private Class resolveFallbackClass(String className) { + if (className == null || className.isBlank()) { + return null; + } + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Fallback class not found: " + className, e); + } + throw new IllegalStateException("Unable to load fallback class: " + className, e); + } } - // TODO - private CircuitBreaker buildCircuitBreaker(String circuitBreakerName) { - return circuitBreakerFactory.create(circuitBreakerName); + private CircuitBreaker buildCircuitBreaker(String name) { + return circuitBreakerFactory.create(name); } @Override From 4c3dac640cc72a37710f95cd1d931039a9790aee Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 3 Jul 2025 13:10:54 +0200 Subject: [PATCH 09/26] Handle empty or null fallbackClassName. Add tests. Signed-off-by: Olga Maciaszek-Sharma --- ...tractCloudHttpClientServiceProperties.java | 10 +- ...cuitBreakerRestClientAdapterDecorator.java | 10 + ...rRestClientHttpServiceGroupConfigurer.java | 11 +- ...ClientHttpServiceGroupConfigurerTests.java | 189 ++++++++++++++++++ .../client/circuitbreaker/Fallbacks.java | 7 + 5 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java index 9be81c225..945778d57 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -21,14 +21,14 @@ */ public abstract class AbstractCloudHttpClientServiceProperties { - private String fallbackClass; + private String fallbackClassName; - public String getFallbackClass() { - return fallbackClass; + public String getFallbackClassName() { + return fallbackClassName; } - public void setFallbackClass(String fallbackClass) { - this.fallbackClass = fallbackClass; + public void setFallbackClassName(String fallbackClassName) { + this.fallbackClassName = fallbackClassName; } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java index ff54e612f..87cce2cbf 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java @@ -90,6 +90,16 @@ public ResponseEntity exchangeForEntity(HttpRequestValues values, Paramet return castIfPossible(result); } + // Visible for tests + CircuitBreaker getCircuitBreaker() { + return circuitBreaker; + } + + // Visible for tests + Class getFallbackClass() { + return fallbackClass; + } + @SuppressWarnings("unchecked") private T castIfPossible(Object result) { try { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 9d559a66a..307afe8b7 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -50,7 +50,10 @@ public void configureGroups(Groups groups) { String groupName = group.name(); CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() .get(groupName); - String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClass() : null; + String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; + if (fallbackClassName == null || fallbackClassName.isBlank()) { + return; + } Class fallbackClass = resolveFallbackClass(fallbackClassName); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); @@ -64,12 +67,10 @@ public void configureGroups(Groups groups) { } private Class resolveFallbackClass(String className) { - if (className == null || className.isBlank()) { - return null; - } try { return Class.forName(className); - } catch (ClassNotFoundException e) { + } + catch (ClassNotFoundException e) { if (LOG.isDebugEnabled()) { LOG.debug("Fallback class not found: " + className, e); } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java new file mode 100644 index 000000000..4e5d84024 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,189 @@ +/* + * Copyright 2012-2025 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.client.circuitbreaker; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.ArgumentCaptor; + +import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.service.registry.HttpServiceGroup; +import org.springframework.web.service.registry.HttpServiceGroupConfigurer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link CircuitBreakerRestClientHttpServiceGroupConfigurer}. + * + * @author Olga Maciaszek-Sharma + */ +class CircuitBreakerRestClientHttpServiceGroupConfigurerTests { + + private static final String GROUP_NAME = "testService"; + + private final CloudHttpClientServiceProperties clientServiceProperties = new CloudHttpClientServiceProperties(); + private final CircuitBreakerFactory circuitBreakerFactory = mock(CircuitBreakerFactory.class); + private final TestGroups groups = new TestGroups(); + + @BeforeEach + void setUp() { + when(circuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(CircuitBreaker.class)); + } + + + @SuppressWarnings("unchecked") + @Test + void shouldAddCircuitBreakerAdapterDecorator() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName(Fallbacks.class.getCanonicalName()); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = + new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, + circuitBreakerFactory); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Function.class); + + configurer.configureGroups(groups); + + verify(groups.builder).exchangeAdapterDecorator(captor.capture()); + Function captured = captor.getValue(); + CircuitBreakerRestClientAdapterDecorator decorator = (CircuitBreakerRestClientAdapterDecorator) captured.apply(new TestHttpExchangeAdapter()); + assertThat(decorator.getCircuitBreaker()).isNotNull(); + assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); + } + + @Test + void shouldThrowExceptionWhenCantLoadClass() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName("org.test.Fallback"); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = + new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, + circuitBreakerFactory); + + assertThatIllegalStateException() + .isThrownBy(() -> configurer.configureGroups(groups)); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddDecoratorWhenFallbackClassNameIsNull(String fallbackClassName) { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName(fallbackClassName); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = + new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, + circuitBreakerFactory); + + assertThatNoException().isThrownBy(() -> configurer.configureGroups(groups)); + verify(circuitBreakerFactory, never()).create(GROUP_NAME); + } + + private static class TestGroups implements HttpServiceGroupConfigurer.Groups { + + HttpServiceProxyFactory.Builder builder = mock(HttpServiceProxyFactory.Builder.class); + + @Override + public HttpServiceGroupConfigurer.Groups filterByName(String... groupNames) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public HttpServiceGroupConfigurer.Groups filter(Predicate predicate) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public void forEachClient(HttpServiceGroupConfigurer.ClientCallback configurer) { + + } + + @Override + public void forEachProxyFactory(HttpServiceGroupConfigurer.ProxyFactoryCallback configurer) { + + } + + @Override + public void forEachGroup(HttpServiceGroupConfigurer.GroupCallback groupConfigurer) { + groupConfigurer.withGroup( + new TestGroup(GROUP_NAME, HttpServiceGroup.ClientType.REST_CLIENT, new HashSet<>()), + RestClient.builder(), + builder); + } + + } + + private record TestGroup(String name, ClientType clientType, + Set> httpServiceTypes) implements HttpServiceGroup { + + } + + private static class TestHttpExchangeAdapter implements HttpExchangeAdapter { + + @Override + public boolean supportsRequestAttributes() { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public void exchange(HttpRequestValues requestValues) { + + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + } + +} \ No newline at end of file diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java new file mode 100644 index 000000000..5af031aea --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java @@ -0,0 +1,7 @@ +package org.springframework.cloud.client.circuitbreaker; + +/** + * @author Olga Maciaszek-Sharma + */ +class Fallbacks { +} From a77fa3e587ab6967ba88a3115a36111d935895b5 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 3 Jul 2025 14:26:14 +0200 Subject: [PATCH 10/26] Repackage. Add decorator tests. Signed-off-by: Olga Maciaszek-Sharma --- .../CommonsClientAutoConfiguration.java | 2 +- .../CircuitBreakerRequestValueProcessor.java | 2 +- ...cuitBreakerRestClientAdapterDecorator.java | 9 ++- ...rRestClientHttpServiceGroupConfigurer.java | 4 +- .../client/circuitbreaker/Fallbacks.java | 7 -- ...reakerRestClientAdapterDecoratorTests.java | 65 +++++++++++++++++++ ...ClientHttpServiceGroupConfigurerTests.java | 4 +- .../circuitbreaker/httpservice/Fallbacks.java | 11 ++++ 8 files changed, 91 insertions(+), 13 deletions(-) rename spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/{ => httpservice}/CircuitBreakerRequestValueProcessor.java (96%) rename spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/{ => httpservice}/CircuitBreakerRestClientAdapterDecorator.java (93%) rename spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/{ => httpservice}/CircuitBreakerRestClientHttpServiceGroupConfigurer.java (93%) delete mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java rename spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/{ => httpservice}/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java (97%) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index 21cfb4ff1..ede49f144 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -33,7 +33,7 @@ import org.springframework.cloud.client.actuator.HasFeatures; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; -import org.springframework.cloud.client.circuitbreaker.CircuitBreakerRestClientHttpServiceGroupConfigurer; +import org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRestClientHttpServiceGroupConfigurer; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicator; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicatorProperties; diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java similarity index 96% rename from spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java rename to spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index f6408c3de..fb75a9aef 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.client.circuitbreaker; +package org.springframework.cloud.client.circuitbreaker.httpservice; import java.lang.reflect.Method; diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java similarity index 93% rename from spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java rename to spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java index 87cce2cbf..e00782469 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.client.circuitbreaker; +package org.springframework.cloud.client.circuitbreaker.httpservice; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -28,6 +28,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.aop.framework.ProxyFactory; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -113,7 +115,8 @@ private T castIfPossible(Object result) { } } - private Function createFallbackHandler(HttpRequestValues requestValues) { + // Visible for tests + Function createFallbackHandler(HttpRequestValues requestValues) { Map attributes = requestValues.getAttributes(); Method fallback = resolveFallbackMethod(attributes, false); Method fallbackWithCause = resolveFallbackMethod(attributes, true); @@ -139,6 +142,7 @@ private Method resolveFallbackMethod(Map attributes, boolean wit String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] paramTypes = (Class[]) attributes .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + paramTypes = paramTypes != null ? paramTypes : new Class[0]; Class[] effectiveTypes = withThrowable ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) : paramTypes; @@ -160,6 +164,7 @@ private Object invokeFallback(Method method, Map attributes, @Nu try { Object proxy = getFallbackProxy(); Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + args = args != null ? args : new Class[0]; Object[] finalArgs = (throwable != null) ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; return method.invoke(proxy, finalArgs); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java similarity index 93% rename from spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java rename to spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 307afe8b7..b73be992e 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -14,12 +14,14 @@ * limitations under the License. */ -package org.springframework.cloud.client.circuitbreaker; +package org.springframework.cloud.client.circuitbreaker.httpservice; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java deleted file mode 100644 index 5af031aea..000000000 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/Fallbacks.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.springframework.cloud.client.circuitbreaker; - -/** - * @author Olga Maciaszek-Sharma - */ -class Fallbacks { -} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java new file mode 100644 index 000000000..f756680bc --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java @@ -0,0 +1,65 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; + +/** + * @author Olga Maciaszek-Sharma + */ +class CircuitBreakerRestClientAdapterDecoratorTests { + + private final HttpExchangeAdapter adapter = mock(HttpExchangeAdapter.class); + private final CircuitBreaker circuitBreaker = mock(CircuitBreaker.class); + private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); + private final CircuitBreakerRestClientAdapterDecorator decorator = new CircuitBreakerRestClientAdapterDecorator( + adapter, circuitBreaker, Fallbacks.class); + + + @Test + void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { + decorator.exchange(httpRequestValues); + + verify(circuitBreaker).run(any(), any()); + } + + @Test + void shouldCreateFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {"testDescription", 5}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + System.out.println("test"); + } + + @Test + void shouldThrowExceptionWhenNoFallbackAvailable() { + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + assertThatExceptionOfType(NoFallbackAvailableException.class) + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + } + +} \ No newline at end of file diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java similarity index 97% rename from spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java rename to spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java index 4e5d84024..12bbd0da2 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.cloud.client.circuitbreaker; +package org.springframework.cloud.client.circuitbreaker.httpservice; import java.util.HashSet; import java.util.Set; @@ -29,6 +29,8 @@ import org.mockito.ArgumentCaptor; import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java new file mode 100644 index 000000000..f34426870 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -0,0 +1,11 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +/** + * @author Olga Maciaszek-Sharma + */ +class Fallbacks { + + String test(String description, int value) { + return description + ": " + value; + } +} From d449a70c015215dfcb7d5afe95c00c5cc706fd79 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 7 Jul 2025 16:13:50 +0200 Subject: [PATCH 11/26] Add more tests. Refactor. Signed-off-by: Olga Maciaszek-Sharma --- .../CommonsClientAutoConfiguration.java | 1 + ...va => CircuitBreakerAdapterDecorator.java} | 6 +-- ...rRestClientHttpServiceGroupConfigurer.java | 13 ++--- ... CircuitBreakerAdapterDecoratorTests.java} | 48 ++++++++++++++++--- ...ClientHttpServiceGroupConfigurerTests.java | 42 ++++++++-------- .../circuitbreaker/httpservice/Fallbacks.java | 25 +++++++++- 6 files changed, 94 insertions(+), 41 deletions(-) rename spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/{CircuitBreakerRestClientAdapterDecorator.java => CircuitBreakerAdapterDecorator.java} (96%) rename spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/{CircuitBreakerRestClientAdapterDecoratorTests.java => CircuitBreakerAdapterDecoratorTests.java} (59%) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index 21dd69942..53901d517 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -59,6 +59,7 @@ public class CommonsClientAutoConfiguration { // FIXME: move instantiation to`spring-cloud-circuitbreaker` project @ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class }) @ConditionalOnBean(CircuitBreakerFactory.class) + @Configuration(proxyBeanMethods = false) protected static class CircuitBreakerInterfaceClientsAutoConfiguration { @Bean diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java similarity index 96% rename from spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java rename to spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index e00782469..b433475be 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -40,9 +40,9 @@ /** * @author Olga Maciaszek-Sharma */ -public class CircuitBreakerRestClientAdapterDecorator extends HttpExchangeAdapterDecorator { +public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator { - private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientAdapterDecorator.class); + private static final Log LOG = LogFactory.getLog(CircuitBreakerAdapterDecorator.class); private final CircuitBreaker circuitBreaker; @@ -50,7 +50,7 @@ public class CircuitBreakerRestClientAdapterDecorator extends HttpExchangeAdapte private volatile Object fallbackProxy; - public CircuitBreakerRestClientAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, + public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, Class fallbackClass) { super(delegate); this.circuitBreaker = circuitBreaker; diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index b73be992e..81203d7cb 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -39,8 +39,7 @@ public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestC private final CircuitBreakerFactory circuitBreakerFactory; - public CircuitBreakerRestClientHttpServiceGroupConfigurer( - CloudHttpClientServiceProperties clientServiceProperties, + public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties, CircuitBreakerFactory circuitBreakerFactory) { this.clientServiceProperties = clientServiceProperties; this.circuitBreakerFactory = circuitBreakerFactory; @@ -50,8 +49,7 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer( public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() - .get(groupName); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; if (fallbackClassName == null || fallbackClassName.isBlank()) { return; @@ -60,11 +58,8 @@ public void configureGroups(Groups groups) { factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); - factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> - new CircuitBreakerRestClientAdapterDecorator( - httpExchangeAdapter, - buildCircuitBreaker(groupName), - fallbackClass)); + factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerAdapterDecorator( + httpExchangeAdapter, buildCircuitBreaker(groupName), fallbackClass)); }); } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java similarity index 59% rename from spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java rename to spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java index f756680bc..53ed796ff 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -1,3 +1,19 @@ +/* + * Copyright 2012-2025 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.client.circuitbreaker.httpservice; import java.util.HashMap; @@ -11,6 +27,7 @@ import org.springframework.web.service.invoker.HttpExchangeAdapter; import org.springframework.web.service.invoker.HttpRequestValues; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -21,16 +38,20 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; /** + * Tests for {@link CircuitBreakerAdapterDecorator}. + * * @author Olga Maciaszek-Sharma */ -class CircuitBreakerRestClientAdapterDecoratorTests { +class CircuitBreakerAdapterDecoratorTests { private final HttpExchangeAdapter adapter = mock(HttpExchangeAdapter.class); + private final CircuitBreaker circuitBreaker = mock(CircuitBreaker.class); + private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); - private final CircuitBreakerRestClientAdapterDecorator decorator = new CircuitBreakerRestClientAdapterDecorator( - adapter, circuitBreaker, Fallbacks.class); + private final CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator( + adapter, circuitBreaker, Fallbacks.class); @Test void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @@ -42,16 +63,31 @@ void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @Test void shouldCreateFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(METHOD_ATTRIBUTE_NAME, + "test"); attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {"testDescription", 5}); when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo("testDescription: 5"); + } + + @Test + void shouldCreateFallbackHandlerWithCause() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testThrowable"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), "testDescription", 5}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); Object fallback = fallbackHandler.apply(new RuntimeException("test")); - System.out.println("test"); + assertThat(fallback).isEqualTo("java.lang.Throwable: test! testDescription: 5"); } @Test @@ -62,4 +98,4 @@ void shouldThrowExceptionWhenNoFallbackAvailable() { .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); } -} \ No newline at end of file +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java index 12bbd0da2..1bce319cf 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java @@ -59,7 +59,9 @@ class CircuitBreakerRestClientHttpServiceGroupConfigurerTests { private static final String GROUP_NAME = "testService"; private final CloudHttpClientServiceProperties clientServiceProperties = new CloudHttpClientServiceProperties(); + private final CircuitBreakerFactory circuitBreakerFactory = mock(CircuitBreakerFactory.class); + private final TestGroups groups = new TestGroups(); @BeforeEach @@ -67,24 +69,23 @@ void setUp() { when(circuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(CircuitBreaker.class)); } - @SuppressWarnings("unchecked") @Test void shouldAddCircuitBreakerAdapterDecorator() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); group.setFallbackClassName(Fallbacks.class.getCanonicalName()); clientServiceProperties.getGroup().put(GROUP_NAME, group); - CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = - new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, - circuitBreakerFactory); - ArgumentCaptor> captor = - ArgumentCaptor.forClass(Function.class); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( + clientServiceProperties, circuitBreakerFactory); + ArgumentCaptor> captor = ArgumentCaptor + .forClass(Function.class); configurer.configureGroups(groups); verify(groups.builder).exchangeAdapterDecorator(captor.capture()); Function captured = captor.getValue(); - CircuitBreakerRestClientAdapterDecorator decorator = (CircuitBreakerRestClientAdapterDecorator) captured.apply(new TestHttpExchangeAdapter()); + CircuitBreakerAdapterDecorator decorator = (CircuitBreakerAdapterDecorator) captured + .apply(new TestHttpExchangeAdapter()); assertThat(decorator.getCircuitBreaker()).isNotNull(); assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); } @@ -94,12 +95,10 @@ void shouldThrowExceptionWhenCantLoadClass() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); group.setFallbackClassName("org.test.Fallback"); clientServiceProperties.getGroup().put(GROUP_NAME, group); - CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = - new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, - circuitBreakerFactory); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( + clientServiceProperties, circuitBreakerFactory); - assertThatIllegalStateException() - .isThrownBy(() -> configurer.configureGroups(groups)); + assertThatIllegalStateException().isThrownBy(() -> configurer.configureGroups(groups)); } @ParameterizedTest @@ -108,9 +107,8 @@ void shouldNotAddDecoratorWhenFallbackClassNameIsNull(String fallbackClassName) CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); group.setFallbackClassName(fallbackClassName); clientServiceProperties.getGroup().put(GROUP_NAME, group); - CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = - new CircuitBreakerRestClientHttpServiceGroupConfigurer(clientServiceProperties, - circuitBreakerFactory); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( + clientServiceProperties, circuitBreakerFactory); assertThatNoException().isThrownBy(() -> configurer.configureGroups(groups)); verify(circuitBreakerFactory, never()).create(GROUP_NAME); @@ -144,14 +142,13 @@ public void forEachProxyFactory(HttpServiceGroupConfigurer.ProxyFactoryCallback public void forEachGroup(HttpServiceGroupConfigurer.GroupCallback groupConfigurer) { groupConfigurer.withGroup( new TestGroup(GROUP_NAME, HttpServiceGroup.ClientType.REST_CLIENT, new HashSet<>()), - RestClient.builder(), - builder); + RestClient.builder(), builder); } } private record TestGroup(String name, ClientType clientType, - Set> httpServiceTypes) implements HttpServiceGroup { + Set> httpServiceTypes) implements HttpServiceGroup { } @@ -173,7 +170,8 @@ public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { } @Override - public @Nullable T exchangeForBody(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public @Nullable T exchangeForBody(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } @@ -183,9 +181,11 @@ public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestV } @Override - public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } + } -} \ No newline at end of file +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java index f34426870..d9aafa489 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -1,11 +1,32 @@ +/* + * Copyright 2012-2025 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.client.circuitbreaker.httpservice; /** * @author Olga Maciaszek-Sharma */ -class Fallbacks { + public class Fallbacks { - String test(String description, int value) { + public String test(String description, Integer value) { return description + ": " + value; } + + public String testThrowable(Throwable throwable, String description, Integer value) { + return throwable + " " + description + ": " + value; + } + } From fc81ce78fcf319c4871d6bbb3e91d7328c5fe435 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 8 Jul 2025 14:10:42 +0200 Subject: [PATCH 12/26] Draft reactive implementation. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerAdapterDecorator.java | 87 +------ .../CircuitBreakerConfigurerUtils.java | 116 +++++++++ ...rRestClientHttpServiceGroupConfigurer.java | 21 +- ...erWebClientHttpServiceGroupConfigurer.java | 77 ++++++ ...eactiveCircuitBreakerAdapterDecorator.java | 183 ++++++++++++++ ...ClientHttpServiceGroupConfigurerTests.java | 228 ++++++++++++++++++ 6 files changed, 620 insertions(+), 92 deletions(-) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index b433475be..e7a4dbf1a 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -16,12 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.Map; import java.util.function.Function; -import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -29,7 +24,6 @@ import org.springframework.aop.framework.ProxyFactory; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; -import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -37,6 +31,8 @@ import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; + /** * @author Olga Maciaszek-Sharma */ @@ -50,8 +46,8 @@ public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator private volatile Object fallbackProxy; - public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, - Class fallbackClass) { + public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, + CircuitBreaker circuitBreaker, Class fallbackClass) { super(delegate); this.circuitBreaker = circuitBreaker; this.fallbackClass = fallbackClass; @@ -59,10 +55,11 @@ public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreak @Override public void exchange(HttpRequestValues requestValues) { - circuitBreaker.run(() -> { - super.exchange(requestValues); - return null; - }, createFallbackHandler(requestValues)); + circuitBreaker.run( + () -> { + super.exchange(requestValues); + return null; + }, createFallbackHandler(requestValues)); } @Override @@ -117,71 +114,7 @@ private T castIfPossible(Object result) { // Visible for tests Function createFallbackHandler(HttpRequestValues requestValues) { - Map attributes = requestValues.getAttributes(); - Method fallback = resolveFallbackMethod(attributes, false); - Method fallbackWithCause = resolveFallbackMethod(attributes, true); - - return throwable -> { - if (fallback != null) { - return invokeFallback(fallback, attributes, null); - } - else if (fallbackWithCause != null) { - return invokeFallback(fallbackWithCause, attributes, throwable); - } - else { - throw new NoFallbackAvailableException("No fallback available.", throwable); - } - }; - - } - - private Method resolveFallbackMethod(Map attributes, boolean withThrowable) { - if (fallbackClass == null) { - return null; - } - String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); - Class[] paramTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); - paramTypes = paramTypes != null ? paramTypes : new Class[0]; - Class[] effectiveTypes = withThrowable - ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) - : paramTypes; - - try { - Method method = fallbackClass.getMethod(methodName, effectiveTypes); - method.setAccessible(true); - return method; - } - catch (NoSuchMethodException exception) { - if (LOG.isDebugEnabled()) { - LOG.debug("Fallback method not found: " + methodName + " in " + fallbackClass.getName(), exception); - } - return null; - } - } - - private Object invokeFallback(Method method, Map attributes, @Nullable Throwable throwable) { - try { - Object proxy = getFallbackProxy(); - Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); - args = args != null ? args : new Class[0]; - Object[] finalArgs = (throwable != null) - ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; - return method.invoke(proxy, finalArgs); - } - catch (InvocationTargetException | IllegalAccessException exception) { - if (LOG.isErrorEnabled()) { - LOG.error("Error invoking fallback method: " + method.getName(), exception); - } - Throwable underlyingException = exception.getCause(); - if (underlyingException instanceof RuntimeException) { - throw (RuntimeException) underlyingException; - } - if (underlyingException != null) { - throw new IllegalStateException("Failed to invoke fallback method", underlyingException); - } - throw new RuntimeException("Failed to invoke fallback method", exception); - } + return throwable -> getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); } private Object getFallbackProxy() { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java new file mode 100644 index 000000000..e85e2b86b --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -0,0 +1,116 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Stream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; + +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerConfigurerUtils { + + private static final Log LOG = LogFactory.getLog(CircuitBreakerConfigurerUtils.class); + + static Class resolveFallbackClass(String className) { + try { + return Class.forName(className); + } + catch (ClassNotFoundException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Fallback class not found: " + className, e); + } + throw new IllegalStateException("Unable to load fallback class: " + className, e); + } + } + + @SuppressWarnings("unchecked") + static T castIfPossible(Object result) { + try { + return (T) result; + } + catch (ClassCastException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Failed to cast object of type " + result.getClass() + " to expected type."); + } + throw exception; + } + } + + static Method resolveFallbackMethod(Map attributes, boolean withThrowable, Class fallbackClass) { + if (fallbackClass == null) { + return null; + } + String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); + Class[] paramTypes = (Class[]) attributes + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + paramTypes = paramTypes != null ? paramTypes : new Class[0]; + Class[] effectiveTypes = withThrowable + ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)) + .toArray(Class[]::new) + : paramTypes; + + try { + Method method = fallbackClass.getMethod(methodName, effectiveTypes); + method.setAccessible(true); + return method; + } + catch (NoSuchMethodException exception) { + if (LOG.isDebugEnabled()) { + LOG.debug("Fallback method not found: " + methodName + " in " + fallbackClass.getName(), exception); + } + return null; + } + } + + static Object invokeFallback(Method method, Map attributes, @Nullable Throwable throwable, + Object fallbackProxy) { + try { + Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); + args = args != null ? args : new Class[0]; + Object[] finalArgs = (throwable != null) + ? Stream.concat(Stream.of(throwable), Arrays.stream(args)) + .toArray(Object[]::new) : args; + return method.invoke(fallbackProxy, finalArgs); + } + catch (InvocationTargetException | IllegalAccessException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Error invoking fallback method: " + method.getName(), exception); + } + Throwable underlyingException = exception.getCause(); + if (underlyingException instanceof RuntimeException) { + throw (RuntimeException) underlyingException; + } + if (underlyingException != null) { + throw new IllegalStateException("Failed to invoke fallback method", underlyingException); + } + throw new RuntimeException("Failed to invoke fallback method", exception); + } + } + + static Object getFallback(HttpRequestValues requestValues, Throwable throwable, + Object fallbackProxy, Class fallbackClass) { + Map attributes = requestValues.getAttributes(); + Method fallback = resolveFallbackMethod(attributes, false, fallbackClass); + Method fallbackWithCause = resolveFallbackMethod(attributes, true, fallbackClass); + if (fallback != null) { + return invokeFallback(fallback, attributes, null, fallbackProxy); + } + else if (fallbackWithCause != null) { + return invokeFallback(fallbackWithCause, attributes, throwable, fallbackProxy); + } + else { + throw new NoFallbackAvailableException("No fallback available.", throwable); + } + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 81203d7cb..ec999b849 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -25,6 +25,8 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; + /** * @author Olga Maciaszek-Sharma */ @@ -49,7 +51,8 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientService public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() + .get(groupName); String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; if (fallbackClassName == null || fallbackClassName.isBlank()) { return; @@ -63,20 +66,8 @@ public void configureGroups(Groups groups) { }); } - private Class resolveFallbackClass(String className) { - try { - return Class.forName(className); - } - catch (ClassNotFoundException e) { - if (LOG.isDebugEnabled()) { - LOG.debug("Fallback class not found: " + className, e); - } - throw new IllegalStateException("Unable to load fallback class: " + className, e); - } - } - - private CircuitBreaker buildCircuitBreaker(String name) { - return circuitBreakerFactory.create(name); + private CircuitBreaker buildCircuitBreaker(String groupName) { + return circuitBreakerFactory.create(groupName); } @Override diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java new file mode 100644 index 000000000..badc58499 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -0,0 +1,77 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreakerFactory; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; + +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; + +/** + * @author Olga Maciaszek-Sharma + */ +public class CircuitBreakerWebClientHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer { + + // Make sure Boot's configurers run before + private static final int ORDER = 16; + + private static final Log LOG = LogFactory.getLog(CircuitBreakerWebClientHttpServiceGroupConfigurer.class); + + private final CloudHttpClientServiceProperties clientServiceProperties; + + private final ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory; + + private final CircuitBreakerFactory circuitBreakerFactory; + + public CircuitBreakerWebClientHttpServiceGroupConfigurer(CloudHttpClientServiceProperties clientServiceProperties, + ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory, + CircuitBreakerFactory circuitBreakerFactory) { + this.clientServiceProperties = clientServiceProperties; + this.reactiveCircuitBreakerFactory = reactiveCircuitBreakerFactory; + this.circuitBreakerFactory = circuitBreakerFactory; + } + + @Override + public void configureGroups(Groups groups) { + groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { + String groupName = group.name(); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() + .get(groupName); + String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; + if (fallbackClassName == null || fallbackClassName.isBlank()) { + return; + } + Class fallbackClass = resolveFallbackClass(fallbackClassName); + + factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); + + factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> { + Assert.isInstanceOf(ReactorHttpExchangeAdapter.class, httpExchangeAdapter); + return new ReactiveCircuitBreakerAdapterDecorator( + (ReactorHttpExchangeAdapter) httpExchangeAdapter, + buildReactiveCircuitBreaker(groupName), buildCircuitBreaker(groupName), fallbackClass); + }); + }); + } + + private ReactiveCircuitBreaker buildReactiveCircuitBreaker(String groupName) { + return reactiveCircuitBreakerFactory.create(groupName + "-reactive"); + } + + private CircuitBreaker buildCircuitBreaker(String groupName) { + return circuitBreakerFactory.create(groupName); + } + + @Override + public int getOrder() { + return ORDER; + } +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java new file mode 100644 index 000000000..f7c0046c9 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -0,0 +1,183 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapterDecorator; + +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.castIfPossible; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; + +/** + * @author Olga Maciaszek-Sharma + */ +public class ReactiveCircuitBreakerAdapterDecorator extends ReactorHttpExchangeAdapterDecorator { + + private static final Log LOG = LogFactory.getLog(ReactiveCircuitBreakerAdapterDecorator.class); + + private final ReactiveCircuitBreaker reactiveCircuitBreaker; + + private final CircuitBreaker circuitBreaker; + + private final Class fallbackClass; + + private volatile Object fallbackProxy; + + public ReactiveCircuitBreakerAdapterDecorator(ReactorHttpExchangeAdapter delegate, + ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, + Class fallbackClass) { + super(delegate); + this.reactiveCircuitBreaker = reactiveCircuitBreaker; + this.circuitBreaker = circuitBreaker; + this.fallbackClass = fallbackClass; + } + + @Override + public void exchange(HttpRequestValues requestValues) { + circuitBreaker.run(() -> { + super.exchange(requestValues); + return null; + }, createFallbackHandler(requestValues)); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + Object result = circuitBreaker.run(() -> super.exchangeForHeaders(values), createFallbackHandler(values)); + return castIfPossible(result); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType), + createFallbackHandler(values)); + return castIfPossible(result); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values), + createFallbackHandler(values)); + return castIfPossible(result); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { + Object result = circuitBreaker.run(() -> super.exchangeForEntity(values, bodyType), + createFallbackHandler(values)); + return castIfPossible(result); + } + + public Mono exchangeForMono(HttpRequestValues requestValues) { + return reactiveCircuitBreaker.run( + super.exchangeForMono(requestValues), + createMonoFallbackHandler(requestValues)); + } + + public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { + return reactiveCircuitBreaker.run(super.exchangeForHeadersMono(requestValues), + createHttpHeadersFallbackHandler(requestValues)); + } + + public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return reactiveCircuitBreaker.run(super.exchangeForBodyMono(requestValues, bodyType), + createMonoFallbackHandler(requestValues)); + } + + public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return reactiveCircuitBreaker.run(super.exchangeForBodyFlux(requestValues, bodyType), + createFluxFallbackHandler(requestValues)); + } + + public Mono> exchangeForBodilessEntityMono(HttpRequestValues requestValues) { + return reactiveCircuitBreaker.run(super.exchangeForBodilessEntityMono(requestValues), + createMonoFallbackHandler(requestValues)); + } + + public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return reactiveCircuitBreaker.run(super.exchangeForEntityMono(requestValues, bodyType), + createMonoFallbackHandler(requestValues)); + } + + public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return reactiveCircuitBreaker.run(super.exchangeForEntityFlux(requestValues, bodyType), + createMonoFallbackHandler(requestValues)); + } + + // Visible for tests + Function createFallbackHandler(HttpRequestValues requestValues) { + return throwable -> getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + } + + Function> createMonoFallbackHandler(HttpRequestValues requestValues) { + return throwable -> { + T fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + return Mono.just(fallback); + }; + } + + Function> createFluxFallbackHandler(HttpRequestValues requestValues) { + return throwable -> { + T fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + return Flux.just(fallback); + }; + } + + Function> createHttpHeadersFallbackHandler(HttpRequestValues requestValues) { + return throwable -> { + HttpHeaders fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + return Mono.just(fallback); + }; + } + + // Visible for tests + ReactiveCircuitBreaker getReactiveCircuitBreaker() { + return reactiveCircuitBreaker; + } + + // Visible for tests + CircuitBreaker getCircuitBreaker() { + return circuitBreaker; + } + + // Visible for tests + Class getFallbackClass() { + return fallbackClass; + } + + private Object getFallbackProxy() { + if (fallbackProxy == null) { + synchronized (this) { + if (fallbackProxy == null) { + try { + Object target = fallbackClass.getConstructor().newInstance(); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + fallbackProxy = proxyFactory.getProxy(); + } + catch (ReflectiveOperationException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Error instantiating fallback proxy for class: " + fallbackClass.getName(), + exception); + } + throw new RuntimeException("Could not create fallback proxy", exception); + } + } + } + } + return fallbackProxy; + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java new file mode 100644 index 000000000..1b04d94c6 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,228 @@ +package org.springframework.cloud.client.circuitbreaker.httpservice; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.CloudHttpClientServiceProperties; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreakerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; +import org.springframework.web.service.registry.HttpServiceGroup; +import org.springframework.web.service.registry.HttpServiceGroupConfigurer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Olga Maciaszek-Sharma + */ +class CircuitBreakerWebClientHttpServiceGroupConfigurerTests { + + private static final String GROUP_NAME = "testService"; + + private final CloudHttpClientServiceProperties clientServiceProperties = new CloudHttpClientServiceProperties(); + + private final CircuitBreakerFactory circuitBreakerFactory = mock(CircuitBreakerFactory.class); + + private final ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory = mock(ReactiveCircuitBreakerFactory.class); + + private final TestGroups groups = new TestGroups(); + + @BeforeEach + void setUp() { + when(circuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(CircuitBreaker.class)); + when(reactiveCircuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(ReactiveCircuitBreaker.class)); + } + + @SuppressWarnings("unchecked") + @Test + void shouldAddCircuitBreakerAdapterDecorator() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName(Fallbacks.class.getCanonicalName()); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( + clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); + ArgumentCaptor> captor = ArgumentCaptor + .forClass(Function.class); + + configurer.configureGroups(groups); + + verify(groups.builder).exchangeAdapterDecorator(captor.capture()); + Function captured = captor.getValue(); + ReactiveCircuitBreakerAdapterDecorator decorator = (ReactiveCircuitBreakerAdapterDecorator) captured + .apply(new TestHttpExchangeAdapter()); + assertThat(decorator.getCircuitBreaker()).isNotNull(); + assertThat(decorator.getReactiveCircuitBreaker()).isNotNull(); + assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); + } + + @Test + void shouldThrowExceptionWhenCantLoadClass() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName("org.test.Fallback"); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( + clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); + + assertThatIllegalStateException().isThrownBy(() -> configurer.configureGroups(groups)); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddDecoratorWhenFallbackClassNameIsNull(String fallbackClassName) { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassName(fallbackClassName); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( + clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); + + assertThatNoException().isThrownBy(() -> configurer.configureGroups(groups)); + verify(circuitBreakerFactory, never()).create(GROUP_NAME); + } + + private static class TestGroups implements HttpServiceGroupConfigurer.Groups { + + HttpServiceProxyFactory.Builder builder = mock(HttpServiceProxyFactory.Builder.class); + + @Override + public HttpServiceGroupConfigurer.Groups filterByName(String... groupNames) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public HttpServiceGroupConfigurer.Groups filter(Predicate predicate) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public void forEachClient(HttpServiceGroupConfigurer.ClientCallback configurer) { + + } + + @Override + public void forEachProxyFactory(HttpServiceGroupConfigurer.ProxyFactoryCallback configurer) { + + } + + @Override + public void forEachGroup(HttpServiceGroupConfigurer.GroupCallback groupConfigurer) { + groupConfigurer.withGroup( + new TestGroup(GROUP_NAME, HttpServiceGroup.ClientType.REST_CLIENT, new HashSet<>()), + WebClient.builder(), builder); + } + + } + + private record TestGroup(String name, ClientType clientType, + Set> httpServiceTypes) implements HttpServiceGroup { + + } + + private static class TestHttpExchangeAdapter implements ReactorHttpExchangeAdapter { + + @Override + public boolean supportsRequestAttributes() { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public void exchange(HttpRequestValues requestValues) { + + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public @Nullable T exchangeForBody(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public ResponseEntity exchangeForEntity(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public @Nullable Duration getBlockTimeout() { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono exchangeForMono(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono> exchangeForBodilessEntityMono(HttpRequestValues requestValues) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + + @Override + public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("Please, implement me."); + } + } + +} \ No newline at end of file From 34d34fbd217343a2e5365ee0d87ab6a10905063f Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 9 Jul 2025 18:39:01 +0200 Subject: [PATCH 13/26] Add tests for reactive implementation. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerConfigurerUtils.java | 16 ++ ...erWebClientHttpServiceGroupConfigurer.java | 16 ++ ...eactiveCircuitBreakerAdapterDecorator.java | 51 ++--- ...ClientHttpServiceGroupConfigurerTests.java | 18 +- .../circuitbreaker/httpservice/Fallbacks.java | 28 ++- ...veCircuitBreakerAdapterDecoratorTests.java | 183 ++++++++++++++++++ 6 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index e85e2b86b..b461ebc07 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; import java.lang.reflect.InvocationTargetException; diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java index badc58499..15e427ec7 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; import org.apache.commons.logging.Log; diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index f7c0046c9..4444f352f 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; import java.util.function.Function; @@ -83,37 +99,37 @@ public ResponseEntity exchangeForEntity(HttpRequestValues values, Paramet public Mono exchangeForMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run( super.exchangeForMono(requestValues), - createMonoFallbackHandler(requestValues)); + createBodyMonoFallbackHandler(requestValues)); } public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run(super.exchangeForHeadersMono(requestValues), - createHttpHeadersFallbackHandler(requestValues)); + createHttpHeadersMonoFallbackHandler(requestValues)); } public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForBodyMono(requestValues, bodyType), - createMonoFallbackHandler(requestValues)); + createBodyMonoFallbackHandler(requestValues)); } public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForBodyFlux(requestValues, bodyType), - createFluxFallbackHandler(requestValues)); + createBodyFluxFallbackHandler(requestValues)); } public Mono> exchangeForBodilessEntityMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run(super.exchangeForBodilessEntityMono(requestValues), - createMonoFallbackHandler(requestValues)); + createBodyMonoFallbackHandler(requestValues)); } public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityMono(requestValues, bodyType), - createMonoFallbackHandler(requestValues)); + createBodyMonoFallbackHandler(requestValues)); } public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityFlux(requestValues, bodyType), - createMonoFallbackHandler(requestValues)); + createBodyMonoFallbackHandler(requestValues)); } // Visible for tests @@ -121,25 +137,16 @@ Function createFallbackHandler(HttpRequestValues requestValue return throwable -> getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); } - Function> createMonoFallbackHandler(HttpRequestValues requestValues) { - return throwable -> { - T fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); - return Mono.just(fallback); - }; + Function> createBodyMonoFallbackHandler(HttpRequestValues requestValues) { + return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } - Function> createFluxFallbackHandler(HttpRequestValues requestValues) { - return throwable -> { - T fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); - return Flux.just(fallback); - }; + Function> createBodyFluxFallbackHandler(HttpRequestValues requestValues) { + return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } - Function> createHttpHeadersFallbackHandler(HttpRequestValues requestValues) { - return throwable -> { - HttpHeaders fallback = castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); - return Mono.just(fallback); - }; + Function> createHttpHeadersMonoFallbackHandler(HttpRequestValues requestValues) { + return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } // Visible for tests diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java index 1b04d94c6..fdd0404e0 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; import java.time.Duration; @@ -58,7 +74,7 @@ class CircuitBreakerWebClientHttpServiceGroupConfigurerTests { @BeforeEach void setUp() { when(circuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(CircuitBreaker.class)); - when(reactiveCircuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(ReactiveCircuitBreaker.class)); + when(reactiveCircuitBreakerFactory.create(GROUP_NAME + "-reactive")).thenReturn(mock(ReactiveCircuitBreaker.class)); } @SuppressWarnings("unchecked") diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java index d9aafa489..36b095f43 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -16,10 +16,18 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Collections; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; + /** * @author Olga Maciaszek-Sharma */ - public class Fallbacks { +public class Fallbacks { public String test(String description, Integer value) { return description + ": " + value; @@ -29,4 +37,22 @@ public String testThrowable(Throwable throwable, String description, Integer val return throwable + " " + description + ": " + value; } + public Mono testMono(String description, Integer value) { + return Mono.just(description + ": " + value); + } + + public Mono testThrowableMono(Throwable throwable, String description, Integer value) { + return Mono.just(throwable + " " + description + ": " + value); + } + + + public Flux testFlux(String description, Integer value) { + return Flux.just(description + ": " + value); + } + + public Mono testHttpHeadersMono(Throwable throwable, String description, Integer value) { + return Mono.just(new HttpHeaders(MultiValueMap.fromSingleValue(Collections.singletonMap(description, + String.valueOf(value))))); + } + } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java new file mode 100644 index 000000000..3ec7893af --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -0,0 +1,183 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.http.HttpHeaders; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; + +/** + * Tests for {@link ReactiveCircuitBreakerAdapterDecorator}. + * + * @author Olga Maciaszek-Sharma + */ +class ReactiveCircuitBreakerAdapterDecoratorTests { + + private static final String TEST_DESCRIPTION = "testDescription"; + private static final int TEST_VALUE = 5; + + private final ReactorHttpExchangeAdapter adapter = mock(ReactorHttpExchangeAdapter.class); + + private final ReactiveCircuitBreaker reactiveCircuitBreaker = mock(ReactiveCircuitBreaker.class); + + private final CircuitBreaker circuitBreaker = mock(CircuitBreaker.class); + + private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); + + private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator( + adapter, reactiveCircuitBreaker, circuitBreaker, Fallbacks.class); + + @BeforeEach + void setUp() { + when(adapter.exchangeForBodyMono(any(), any())) + .thenReturn(Mono.just("test")); + } + + @Test + void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { + decorator.exchange(httpRequestValues); + + verify(circuitBreaker).run(any(), any()); + } + + @Test + void shouldCreateFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @Test + void shouldCreateBodyMonoFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @Test + void shouldCreateBodyFluxFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testFlux"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")) + .blockFirst(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void shouldCreateHttpHeadersMonoFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testHttpHeadersMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createHttpHeadersMonoFallbackHandler(httpRequestValues); + + HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")) + .block(); + + assertThat(fallback.get(TEST_DESCRIPTION) + .get(0)).isEqualTo(String.valueOf(TEST_VALUE)); + } + + @Test + void shouldCreateFallbackHandlerWithCause() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testThrowable"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " + + TEST_VALUE); + } + + @Test + void shouldCreateReactiveFallbackHandlerWithCause() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, + "testThrowableMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " + + TEST_VALUE); + } + + @Test + void shouldThrowExceptionWhenNoFallbackAvailable() { + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + assertThatExceptionOfType(NoFallbackAvailableException.class) + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + } + +} \ No newline at end of file From 200dc59c84e3287883653d7871645c56197a81fb Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 9 Jul 2025 18:58:04 +0200 Subject: [PATCH 14/26] Fix checkstyle. Adjust to changes in FW. Match fallback method by return type. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerAdapterDecorator.java | 13 ++- .../CircuitBreakerConfigurerUtils.java | 19 ++--- .../CircuitBreakerRequestValueProcessor.java | 10 ++- ...rRestClientHttpServiceGroupConfigurer.java | 8 +- ...erWebClientHttpServiceGroupConfigurer.java | 7 +- ...eactiveCircuitBreakerAdapterDecorator.java | 12 +-- .../CircuitBreakerAdapterDecoratorTests.java | 23 +++--- ...ClientHttpServiceGroupConfigurerTests.java | 27 ++++--- .../circuitbreaker/httpservice/Fallbacks.java | 5 +- ...veCircuitBreakerAdapterDecoratorTests.java | 79 +++++++++---------- 10 files changed, 107 insertions(+), 96 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index e7a4dbf1a..3e92a5017 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -46,8 +46,8 @@ public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator private volatile Object fallbackProxy; - public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, - CircuitBreaker circuitBreaker, Class fallbackClass) { + public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, + Class fallbackClass) { super(delegate); this.circuitBreaker = circuitBreaker; this.fallbackClass = fallbackClass; @@ -55,11 +55,10 @@ public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, @Override public void exchange(HttpRequestValues requestValues) { - circuitBreaker.run( - () -> { - super.exchange(requestValues); - return null; - }, createFallbackHandler(requestValues)); + circuitBreaker.run(() -> { + super.exchange(requestValues); + return null; + }, createFallbackHandler(requestValues)); } @Override diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index b461ebc07..995de2918 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -25,7 +25,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; -import reactor.core.publisher.Flux; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.web.service.invoker.HttpRequestValues; @@ -33,7 +32,11 @@ /** * @author Olga Maciaszek-Sharma */ -public class CircuitBreakerConfigurerUtils { +final class CircuitBreakerConfigurerUtils { + + private CircuitBreakerConfigurerUtils() { + throw new UnsupportedOperationException("Cannot instantiate a utility class"); + } private static final Log LOG = LogFactory.getLog(CircuitBreakerConfigurerUtils.class); @@ -68,11 +71,10 @@ static Method resolveFallbackMethod(Map attributes, boolean with } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] paramTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); paramTypes = paramTypes != null ? paramTypes : new Class[0]; Class[] effectiveTypes = withThrowable - ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)) - .toArray(Class[]::new) + ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) : paramTypes; try { @@ -94,8 +96,7 @@ static Object invokeFallback(Method method, Map attributes, @Nul Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); args = args != null ? args : new Class[0]; Object[] finalArgs = (throwable != null) - ? Stream.concat(Stream.of(throwable), Arrays.stream(args)) - .toArray(Object[]::new) : args; + ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; return method.invoke(fallbackProxy, finalArgs); } catch (InvocationTargetException | IllegalAccessException exception) { @@ -113,8 +114,8 @@ static Object invokeFallback(Method method, Map attributes, @Nul } } - static Object getFallback(HttpRequestValues requestValues, Throwable throwable, - Object fallbackProxy, Class fallbackClass) { + static Object getFallback(HttpRequestValues requestValues, Throwable throwable, Object fallbackProxy, + Class fallbackClass) { Map attributes = requestValues.getAttributes(); Method fallback = resolveFallbackMethod(attributes, false, fallbackClass); Method fallbackWithCause = resolveFallbackMethod(attributes, true, fallbackClass); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index fb75a9aef..68b625acd 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.core.MethodParameter; import org.springframework.web.service.invoker.HttpRequestValues; /** @@ -42,11 +43,18 @@ public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Pr */ public static final String ARGUMENTS_ATTRIBUTE_NAME = "spring.cloud.method.arguments"; + /** + * Spring Cloud-specific attribute name for storing method return types. + */ + public static final String RETURN_TYPE_ATTRIBUTE_NAME = "spring.cloud.method.return-type"; + @Override - public void process(Method method, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { + public void process(Method method, MethodParameter[] parameters, @Nullable Object[] arguments, + HttpRequestValues.Builder builder) { builder.addAttribute(METHOD_ATTRIBUTE_NAME, method.getName()); builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); + builder.addAttribute(RETURN_TYPE_ATTRIBUTE_NAME, method.getReturnType()); } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index ec999b849..669b61995 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -51,8 +51,7 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer(CloudHttpClientService public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() - .get(groupName); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; if (fallbackClassName == null || fallbackClassName.isBlank()) { return; @@ -61,8 +60,9 @@ public void configureGroups(Groups groups) { factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); - factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerAdapterDecorator( - httpExchangeAdapter, buildCircuitBreaker(groupName), fallbackClass)); + factoryBuilder + .exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerAdapterDecorator(httpExchangeAdapter, + buildCircuitBreaker(groupName), fallbackClass)); }); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java index 15e427ec7..e4821f17f 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -59,8 +59,7 @@ public CircuitBreakerWebClientHttpServiceGroupConfigurer(CloudHttpClientServiceP public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); - CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup() - .get(groupName); + CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; if (fallbackClassName == null || fallbackClassName.isBlank()) { return; @@ -71,8 +70,7 @@ public void configureGroups(Groups groups) { factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> { Assert.isInstanceOf(ReactorHttpExchangeAdapter.class, httpExchangeAdapter); - return new ReactiveCircuitBreakerAdapterDecorator( - (ReactorHttpExchangeAdapter) httpExchangeAdapter, + return new ReactiveCircuitBreakerAdapterDecorator((ReactorHttpExchangeAdapter) httpExchangeAdapter, buildReactiveCircuitBreaker(groupName), buildCircuitBreaker(groupName), fallbackClass); }); }); @@ -90,4 +88,5 @@ private CircuitBreaker buildCircuitBreaker(String groupName) { public int getOrder() { return ORDER; } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index 4444f352f..a34805f9b 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -53,8 +53,7 @@ public class ReactiveCircuitBreakerAdapterDecorator extends ReactorHttpExchangeA private volatile Object fallbackProxy; public ReactiveCircuitBreakerAdapterDecorator(ReactorHttpExchangeAdapter delegate, - ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, - Class fallbackClass) { + ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, Class fallbackClass) { super(delegate); this.reactiveCircuitBreaker = reactiveCircuitBreaker; this.circuitBreaker = circuitBreaker; @@ -97,8 +96,7 @@ public ResponseEntity exchangeForEntity(HttpRequestValues values, Paramet } public Mono exchangeForMono(HttpRequestValues requestValues) { - return reactiveCircuitBreaker.run( - super.exchangeForMono(requestValues), + return reactiveCircuitBreaker.run(super.exchangeForMono(requestValues), createBodyMonoFallbackHandler(requestValues)); } @@ -122,12 +120,14 @@ public Mono> exchangeForBodilessEntityMono(HttpRequestValue createBodyMonoFallbackHandler(requestValues)); } - public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Mono> exchangeForEntityMono(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityMono(requestValues, bodyType), createBodyMonoFallbackHandler(requestValues)); } - public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityFlux(requestValues, bodyType), createBodyMonoFallbackHandler(requestValues)); } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java index 53ed796ff..c1651e550 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -36,6 +36,7 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME; /** * Tests for {@link CircuitBreakerAdapterDecorator}. @@ -50,8 +51,8 @@ class CircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); - private final CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator( - adapter, circuitBreaker, Fallbacks.class); + private final CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator(adapter, circuitBreaker, + Fallbacks.class); @Test void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @@ -63,10 +64,10 @@ void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @Test void shouldCreateFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {"testDescription", 5}); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { "testDescription", 5 }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -78,10 +79,10 @@ void shouldCreateFallbackHandler() { @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testThrowable"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), "testDescription", 5}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowable"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), "testDescription", 5 }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -95,7 +96,7 @@ void shouldThrowExceptionWhenNoFallbackAvailable() { Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) - .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); } } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java index fdd0404e0..0d934e75b 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -67,14 +67,16 @@ class CircuitBreakerWebClientHttpServiceGroupConfigurerTests { private final CircuitBreakerFactory circuitBreakerFactory = mock(CircuitBreakerFactory.class); - private final ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory = mock(ReactiveCircuitBreakerFactory.class); + private final ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory = mock( + ReactiveCircuitBreakerFactory.class); private final TestGroups groups = new TestGroups(); @BeforeEach void setUp() { when(circuitBreakerFactory.create(GROUP_NAME)).thenReturn(mock(CircuitBreaker.class)); - when(reactiveCircuitBreakerFactory.create(GROUP_NAME + "-reactive")).thenReturn(mock(ReactiveCircuitBreaker.class)); + when(reactiveCircuitBreakerFactory.create(GROUP_NAME + "-reactive")) + .thenReturn(mock(ReactiveCircuitBreaker.class)); } @SuppressWarnings("unchecked") @@ -86,14 +88,14 @@ void shouldAddCircuitBreakerAdapterDecorator() { CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); ArgumentCaptor> captor = ArgumentCaptor - .forClass(Function.class); + .forClass(Function.class); configurer.configureGroups(groups); verify(groups.builder).exchangeAdapterDecorator(captor.capture()); Function captured = captor.getValue(); ReactiveCircuitBreakerAdapterDecorator decorator = (ReactiveCircuitBreakerAdapterDecorator) captured - .apply(new TestHttpExchangeAdapter()); + .apply(new TestHttpExchangeAdapter()); assertThat(decorator.getCircuitBreaker()).isNotNull(); assertThat(decorator.getReactiveCircuitBreaker()).isNotNull(); assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); @@ -157,7 +159,7 @@ public void forEachGroup(HttpServiceGroupConfigurer.GroupCallback> httpServiceTypes) implements HttpServiceGroup { + Set> httpServiceTypes) implements HttpServiceGroup { } @@ -216,12 +218,14 @@ public Mono exchangeForHeadersMono(HttpRequestValues requestValues) } @Override - public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Mono exchangeForBodyMono(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } @Override - public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Flux exchangeForBodyFlux(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } @@ -231,14 +235,17 @@ public Mono> exchangeForBodilessEntityMono(HttpRequestValue } @Override - public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Mono> exchangeForEntityMono(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } @Override - public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { throw new UnsupportedOperationException("Please, implement me."); } + } -} \ No newline at end of file +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java index 36b095f43..8b5da482b 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -45,14 +45,13 @@ public Mono testThrowableMono(Throwable throwable, String description, I return Mono.just(throwable + " " + description + ": " + value); } - public Flux testFlux(String description, Integer value) { return Flux.just(description + ": " + value); } public Mono testHttpHeadersMono(Throwable throwable, String description, Integer value) { - return Mono.just(new HttpHeaders(MultiValueMap.fromSingleValue(Collections.singletonMap(description, - String.valueOf(value))))); + return Mono.just(new HttpHeaders( + MultiValueMap.fromSingleValue(Collections.singletonMap(description, String.valueOf(value))))); } } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 3ec7893af..58d5b9430 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -41,6 +41,7 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME; /** * Tests for {@link ReactiveCircuitBreakerAdapterDecorator}. @@ -50,6 +51,7 @@ class ReactiveCircuitBreakerAdapterDecoratorTests { private static final String TEST_DESCRIPTION = "testDescription"; + private static final int TEST_VALUE = 5; private final ReactorHttpExchangeAdapter adapter = mock(ReactorHttpExchangeAdapter.class); @@ -60,13 +62,12 @@ class ReactiveCircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); - private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator( - adapter, reactiveCircuitBreaker, circuitBreaker, Fallbacks.class); + private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, + reactiveCircuitBreaker, circuitBreaker, Fallbacks.class); @BeforeEach void setUp() { - when(adapter.exchangeForBodyMono(any(), any())) - .thenReturn(Mono.just("test")); + when(adapter.exchangeForBodyMono(any(), any())).thenReturn(Mono.just("test")); } @Test @@ -79,10 +80,10 @@ void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @Test void shouldCreateFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -94,10 +95,10 @@ void shouldCreateFallbackHandler() { @Test void shouldCreateBodyMonoFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -109,15 +110,14 @@ void shouldCreateBodyMonoFallbackHandler() { @Test void shouldCreateBodyFluxFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testFlux"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testFlux"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - Object fallback = fallbackHandler.apply(new RuntimeException("test")) - .blockFirst(); + Object fallback = fallbackHandler.apply(new RuntimeException("test")).blockFirst(); assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } @@ -126,50 +126,47 @@ void shouldCreateBodyFluxFallbackHandler() { @Test void shouldCreateHttpHeadersMonoFallbackHandler() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testHttpHeadersMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testHttpHeadersMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); - Function> fallbackHandler = decorator.createHttpHeadersMonoFallbackHandler(httpRequestValues); + Function> fallbackHandler = decorator + .createHttpHeadersMonoFallbackHandler(httpRequestValues); - HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")) - .block(); + HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")).block(); - assertThat(fallback.get(TEST_DESCRIPTION) - .get(0)).isEqualTo(String.valueOf(TEST_VALUE)); + assertThat(fallback.get(TEST_DESCRIPTION).get(0)).isEqualTo(String.valueOf(TEST_VALUE)); } @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testThrowable"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowable"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); Object fallback = fallbackHandler.apply(new RuntimeException("test")); - assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " - + TEST_VALUE); + assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " + TEST_VALUE); } @Test void shouldCreateReactiveFallbackHandlerWithCause() { Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, - "testThrowableMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowableMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); - assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " - + TEST_VALUE); + assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " + TEST_VALUE); } @Test @@ -177,7 +174,7 @@ void shouldThrowExceptionWhenNoFallbackAvailable() { Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) - .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); } -} \ No newline at end of file +} From bd3dbaa3fa87ff10d0718409dc5142ce1d90430c Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 15 Jul 2025 18:02:37 +0200 Subject: [PATCH 15/26] Fix configuration. Signed-off-by: Olga Maciaszek-Sharma --- .../CommonsClientAutoConfiguration.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index 53901d517..608431316 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -33,7 +33,10 @@ import org.springframework.cloud.client.actuator.HasFeatures; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreakerFactory; import org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRestClientHttpServiceGroupConfigurer; +import org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerWebClientHttpServiceGroupConfigurer; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicator; import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicatorProperties; @@ -43,6 +46,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} for Spring Cloud Commons Client. @@ -57,6 +61,7 @@ public class CommonsClientAutoConfiguration { // FIXME: move instantiation to`spring-cloud-circuitbreaker` project + // TODO: add property flag @ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class }) @ConditionalOnBean(CircuitBreakerFactory.class) @Configuration(proxyBeanMethods = false) @@ -70,6 +75,25 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClie } + // FIXME: move instantiation to`spring-cloud-circuitbreaker` project + // TODO: add property flag + @ConditionalOnClass({ CircuitBreaker.class, ReactiveCircuitBreaker.class, + WebClientHttpServiceGroupConfigurer.class }) + @ConditionalOnBean({ CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class }) + @Configuration(proxyBeanMethods = false) + protected static class ReactiveCircuitBreakerInterfaceClientsAutoConfiguration { + + @Bean + public CircuitBreakerWebClientHttpServiceGroupConfigurer circuitBreakerWebClientConfigurer( + CloudHttpClientServiceProperties properties, + ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory, + CircuitBreakerFactory circuitBreakerFactory) { + return new CircuitBreakerWebClientHttpServiceGroupConfigurer(properties, reactiveCircuitBreakerFactory, + circuitBreakerFactory); + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(HealthIndicator.class) @EnableConfigurationProperties(DiscoveryClientHealthIndicatorProperties.class) From 6dc3bb24e8ec9172c308952ac23fa5661a4776cd Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Tue, 15 Jul 2025 19:09:51 +0200 Subject: [PATCH 16/26] Fix reactive implementation and add more tests. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerConfigurerUtils.java | 8 +- ...eactiveCircuitBreakerAdapterDecorator.java | 43 ++++++- .../circuitbreaker/httpservice/Fallbacks.java | 4 + ...veCircuitBreakerAdapterDecoratorTests.java | 119 +++++++++++++++--- 4 files changed, 151 insertions(+), 23 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index 995de2918..947afc415 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -71,10 +71,11 @@ static Method resolveFallbackMethod(Map attributes, boolean with } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] paramTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); paramTypes = paramTypes != null ? paramTypes : new Class[0]; Class[] effectiveTypes = withThrowable - ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) + ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)) + .toArray(Class[]::new) : paramTypes; try { @@ -96,7 +97,8 @@ static Object invokeFallback(Method method, Map attributes, @Nul Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); args = args != null ? args : new Class[0]; Object[] finalArgs = (throwable != null) - ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; + ? Stream.concat(Stream.of(throwable), Arrays.stream(args)) + .toArray(Object[]::new) : args; return method.invoke(fallbackProxy, finalArgs); } catch (InvocationTargetException | IllegalAccessException exception) { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index a34805f9b..7b92f4c02 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -138,15 +138,52 @@ Function createFallbackHandler(HttpRequestValues requestValue } Function> createBodyMonoFallbackHandler(HttpRequestValues requestValues) { - return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + if (((requestValues.getAttributes() + .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) + .equals(Mono.class))) { + return throwable -> castIfPossible( + getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + if (fallback == null) { + return Mono.empty(); + } + return castIfPossible(Mono.just(fallback)); + }; } Function> createBodyFluxFallbackHandler(HttpRequestValues requestValues) { - return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + if (((requestValues.getAttributes() + .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME))) + .equals(Flux.class)) { + return throwable -> castIfPossible( + getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + + if (fallback == null) { + return Flux.empty(); + } + return castIfPossible(Flux.just(fallback)); + }; } Function> createHttpHeadersMonoFallbackHandler(HttpRequestValues requestValues) { - return throwable -> castIfPossible(getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + if ((requestValues.getAttributes() + .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) + .equals(Mono.class)) { + return throwable -> castIfPossible( + getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + if (fallback == null) { + return Mono.empty(); + } + return castIfPossible(Mono.just(fallback)); + }; } // Visible for tests diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java index 8b5da482b..2a876a05d 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -29,6 +29,10 @@ */ public class Fallbacks { + public void post(String test) { + System.err.println("Fallback String: " + test); + } + public String test(String description, Integer value) { return description + ": " + value; } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 58d5b9430..4226d2b8e 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -33,6 +33,7 @@ import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -81,8 +82,8 @@ void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { void shouldCreateFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -96,8 +97,8 @@ void shouldCreateFallbackHandler() { void shouldCreateBodyMonoFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -107,17 +108,99 @@ void shouldCreateBodyMonoFallbackHandler() { assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } + @Test + void shouldCreateBodyMonoFallbackHandlerForNonReactiveReturnType() { + assertThatCode( + () -> { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); + + fallbackHandler.apply(new RuntimeException("test")).block(); + } + ).doesNotThrowAnyException(); + } + + @Test + void shouldCreateBodyFluxFallbackHandlerForNonReactiveReturnType() { + assertThatCode( + () -> { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); + + fallbackHandler.apply(new RuntimeException("test")).blockFirst(); + } + ).doesNotThrowAnyException(); + } + + @Test + void shouldCreateBodyMonoFallbackHandlerForVoidReturnType() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "post"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION}); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Void.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isNull(); + } + @Test void shouldCreateBodyFluxFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testFlux"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")) + .blockFirst(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @Test + void shouldCreateBodyFluxFallbackHandlerFromNonReactiveReturnType() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")) + .blockFirst(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @Test + void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "testFlux"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - Object fallback = fallbackHandler.apply(new RuntimeException("test")).blockFirst(); + Object fallback = fallbackHandler.apply(new RuntimeException("test")) + .blockFirst(); assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } @@ -127,24 +210,26 @@ void shouldCreateBodyFluxFallbackHandler() { void shouldCreateHttpHeadersMonoFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testHttpHeadersMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator - .createHttpHeadersMonoFallbackHandler(httpRequestValues); + .createHttpHeadersMonoFallbackHandler(httpRequestValues); - HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")) + .block(); - assertThat(fallback.get(TEST_DESCRIPTION).get(0)).isEqualTo(String.valueOf(TEST_VALUE)); + assertThat(fallback.get(TEST_DESCRIPTION) + .get(0)).isEqualTo(String.valueOf(TEST_VALUE)); } @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowable"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -158,8 +243,8 @@ void shouldCreateFallbackHandlerWithCause() { void shouldCreateReactiveFallbackHandlerWithCause() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowableMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -174,7 +259,7 @@ void shouldThrowExceptionWhenNoFallbackAvailable() { Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) - .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); } } From 3223d8c4a43b70698b085bcf1b2e0d62dc5dac17 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 16 Jul 2025 14:36:39 +0200 Subject: [PATCH 17/26] Refactor. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerAdapterDecorator.java | 16 +-- .../CircuitBreakerConfigurerUtils.java | 25 ++++- .../FallbackProxyCreationException.java | 27 +++++ ...eactiveCircuitBreakerAdapterDecorator.java | 38 +++---- ...veCircuitBreakerAdapterDecoratorTests.java | 105 ++++++++---------- 5 files changed, 113 insertions(+), 98 deletions(-) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index 3e92a5017..ef2b4ccec 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -22,7 +22,6 @@ import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; -import org.springframework.aop.framework.ProxyFactory; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; @@ -31,6 +30,7 @@ import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxy; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** @@ -120,19 +120,7 @@ private Object getFallbackProxy() { if (fallbackProxy == null) { synchronized (this) { if (fallbackProxy == null) { - try { - Object target = fallbackClass.getConstructor().newInstance(); - ProxyFactory proxyFactory = new ProxyFactory(target); - proxyFactory.setProxyTargetClass(true); - fallbackProxy = proxyFactory.getProxy(); - } - catch (ReflectiveOperationException exception) { - if (LOG.isErrorEnabled()) { - LOG.error("Error instantiating fallback proxy for class: " + fallbackClass.getName(), - exception); - } - throw new RuntimeException("Could not create fallback proxy", exception); - } + fallbackProxy = createProxy(fallbackClass); } } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index 947afc415..adb504eeb 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -26,6 +26,7 @@ import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; +import org.springframework.aop.framework.ProxyFactory; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.web.service.invoker.HttpRequestValues; @@ -71,11 +72,10 @@ static Method resolveFallbackMethod(Map attributes, boolean with } String methodName = String.valueOf(attributes.get(CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME)); Class[] paramTypes = (Class[]) attributes - .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); + .get(CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME); paramTypes = paramTypes != null ? paramTypes : new Class[0]; Class[] effectiveTypes = withThrowable - ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)) - .toArray(Class[]::new) + ? Stream.concat(Stream.of(Throwable.class), Arrays.stream(paramTypes)).toArray(Class[]::new) : paramTypes; try { @@ -97,8 +97,7 @@ static Object invokeFallback(Method method, Map attributes, @Nul Object[] args = (Object[]) attributes.get(CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME); args = args != null ? args : new Class[0]; Object[] finalArgs = (throwable != null) - ? Stream.concat(Stream.of(throwable), Arrays.stream(args)) - .toArray(Object[]::new) : args; + ? Stream.concat(Stream.of(throwable), Arrays.stream(args)).toArray(Object[]::new) : args; return method.invoke(fallbackProxy, finalArgs); } catch (InvocationTargetException | IllegalAccessException exception) { @@ -132,4 +131,20 @@ else if (fallbackWithCause != null) { } } + static Object createProxy(Class fallbackClass) { + try { + Object target = fallbackClass.getConstructor().newInstance(); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + return proxyFactory.getProxy(); + } + catch (ReflectiveOperationException exception) { + if (LOG.isErrorEnabled()) { + LOG.error("Error instantiating fallback proxy for class: " + fallbackClass.getName() + ", exception: " + + exception.getMessage(), exception); + } + throw new FallbackProxyCreationException("Could not create fallback proxy", exception); + } + } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java new file mode 100644 index 000000000..7c37e5176 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; + +/** + * @author Olga Maciaszek-Sharma + */ +public class FallbackProxyCreationException extends RuntimeException { + + public FallbackProxyCreationException(String message, Throwable cause) { + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index 7b92f4c02..668040171 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -24,7 +24,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.aop.framework.ProxyFactory; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; import org.springframework.core.ParameterizedTypeReference; @@ -35,6 +34,7 @@ import org.springframework.web.service.invoker.ReactorHttpExchangeAdapterDecorator; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.castIfPossible; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxy; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** @@ -95,37 +95,44 @@ public ResponseEntity exchangeForEntity(HttpRequestValues values, Paramet return castIfPossible(result); } + @Override public Mono exchangeForMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run(super.exchangeForMono(requestValues), createBodyMonoFallbackHandler(requestValues)); } + @Override public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run(super.exchangeForHeadersMono(requestValues), createHttpHeadersMonoFallbackHandler(requestValues)); } + @Override public Mono exchangeForBodyMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForBodyMono(requestValues, bodyType), createBodyMonoFallbackHandler(requestValues)); } + @Override public Flux exchangeForBodyFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForBodyFlux(requestValues, bodyType), createBodyFluxFallbackHandler(requestValues)); } + @Override public Mono> exchangeForBodilessEntityMono(HttpRequestValues requestValues) { return reactiveCircuitBreaker.run(super.exchangeForBodilessEntityMono(requestValues), createBodyMonoFallbackHandler(requestValues)); } + @Override public Mono> exchangeForEntityMono(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityMono(requestValues, bodyType), createBodyMonoFallbackHandler(requestValues)); } + @Override public Mono>> exchangeForEntityFlux(HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { return reactiveCircuitBreaker.run(super.exchangeForEntityFlux(requestValues, bodyType), @@ -138,9 +145,8 @@ Function createFallbackHandler(HttpRequestValues requestValue } Function> createBodyMonoFallbackHandler(HttpRequestValues requestValues) { - if (((requestValues.getAttributes() - .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) - .equals(Mono.class))) { + if (((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) + .equals(Mono.class))) { return throwable -> castIfPossible( getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } @@ -154,9 +160,8 @@ Function> createBodyMonoFallbackHandler(HttpRequestValues } Function> createBodyFluxFallbackHandler(HttpRequestValues requestValues) { - if (((requestValues.getAttributes() - .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME))) - .equals(Flux.class)) { + if (((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME))) + .equals(Flux.class)) { return throwable -> castIfPossible( getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } @@ -171,9 +176,8 @@ Function> createBodyFluxFallbackHandler(HttpRequestValues } Function> createHttpHeadersMonoFallbackHandler(HttpRequestValues requestValues) { - if ((requestValues.getAttributes() - .get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) - .equals(Mono.class)) { + if ((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) + .equals(Mono.class)) { return throwable -> castIfPossible( getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); } @@ -205,19 +209,7 @@ private Object getFallbackProxy() { if (fallbackProxy == null) { synchronized (this) { if (fallbackProxy == null) { - try { - Object target = fallbackClass.getConstructor().newInstance(); - ProxyFactory proxyFactory = new ProxyFactory(target); - proxyFactory.setProxyTargetClass(true); - fallbackProxy = proxyFactory.getProxy(); - } - catch (ReflectiveOperationException exception) { - if (LOG.isErrorEnabled()) { - LOG.error("Error instantiating fallback proxy for class: " + fallbackClass.getName(), - exception); - } - throw new RuntimeException("Could not create fallback proxy", exception); - } + fallbackProxy = createProxy(fallbackClass); } } } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 4226d2b8e..61eac97ea 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -82,8 +82,8 @@ void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { void shouldCreateFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -97,8 +97,8 @@ void shouldCreateFallbackHandler() { void shouldCreateBodyMonoFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -110,44 +110,42 @@ void shouldCreateBodyMonoFallbackHandler() { @Test void shouldCreateBodyMonoFallbackHandlerForNonReactiveReturnType() { - assertThatCode( - () -> { - Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); - attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); - when(httpRequestValues.getAttributes()).thenReturn(attributes); - Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); - - fallbackHandler.apply(new RuntimeException("test")).block(); - } - ).doesNotThrowAnyException(); + assertThatCode(() -> { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator + .createBodyMonoFallbackHandler(httpRequestValues); + + fallbackHandler.apply(new RuntimeException("test")).block(); + }).doesNotThrowAnyException(); } @Test void shouldCreateBodyFluxFallbackHandlerForNonReactiveReturnType() { - assertThatCode( - () -> { - Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); - attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); - when(httpRequestValues.getAttributes()).thenReturn(attributes); - Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - - fallbackHandler.apply(new RuntimeException("test")).blockFirst(); - } - ).doesNotThrowAnyException(); + assertThatCode(() -> { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator + .createBodyFluxFallbackHandler(httpRequestValues); + + fallbackHandler.apply(new RuntimeException("test")).blockFirst(); + }).doesNotThrowAnyException(); } @Test void shouldCreateBodyMonoFallbackHandlerForVoidReturnType() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "post"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Void.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -161,14 +159,13 @@ void shouldCreateBodyMonoFallbackHandlerForVoidReturnType() { void shouldCreateBodyFluxFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testFlux"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - Object fallback = fallbackHandler.apply(new RuntimeException("test")) - .blockFirst(); + Object fallback = fallbackHandler.apply(new RuntimeException("test")).blockFirst(); assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } @@ -177,14 +174,13 @@ void shouldCreateBodyFluxFallbackHandler() { void shouldCreateBodyFluxFallbackHandlerFromNonReactiveReturnType() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "test"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - Object fallback = fallbackHandler.apply(new RuntimeException("test")) - .blockFirst(); + Object fallback = fallbackHandler.apply(new RuntimeException("test")).blockFirst(); assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } @@ -193,14 +189,13 @@ void shouldCreateBodyFluxFallbackHandlerFromNonReactiveReturnType() { void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testFlux"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); - Object fallback = fallbackHandler.apply(new RuntimeException("test")) - .blockFirst(); + Object fallback = fallbackHandler.apply(new RuntimeException("test")).blockFirst(); assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } @@ -210,26 +205,24 @@ void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { void shouldCreateHttpHeadersMonoFallbackHandler() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testHttpHeadersMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator - .createHttpHeadersMonoFallbackHandler(httpRequestValues); + .createHttpHeadersMonoFallbackHandler(httpRequestValues); - HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")) - .block(); + HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")).block(); - assertThat(fallback.get(TEST_DESCRIPTION) - .get(0)).isEqualTo(String.valueOf(TEST_VALUE)); + assertThat(fallback.get(TEST_DESCRIPTION).get(0)).isEqualTo(String.valueOf(TEST_VALUE)); } @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowable"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -243,8 +236,8 @@ void shouldCreateFallbackHandlerWithCause() { void shouldCreateReactiveFallbackHandlerWithCause() { Map attributes = new HashMap<>(); attributes.put(METHOD_ATTRIBUTE_NAME, "testThrowableMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] {Throwable.class, String.class, Integer.class}); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] {new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE}); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -259,7 +252,7 @@ void shouldThrowExceptionWhenNoFallbackAvailable() { Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) - .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); } } From f7d064513d2a5b4835907d1a401e013974af2695 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Wed, 16 Jul 2025 15:03:42 +0200 Subject: [PATCH 18/26] Add javadocs. Signed-off-by: Olga Maciaszek-Sharma --- ...bstractCloudHttpClientServiceProperties.java | 14 ++++++++++++++ .../CloudHttpClientServiceProperties.java | 3 +++ .../CircuitBreakerAdapterDecorator.java | 16 ++++++++++++++++ .../CircuitBreakerConfigurerUtils.java | 5 +++++ .../CircuitBreakerRequestValueProcessor.java | 12 ++++++++++++ ...kerRestClientHttpServiceGroupConfigurer.java | 6 ++++++ ...akerWebClientHttpServiceGroupConfigurer.java | 6 ++++++ .../FallbackProxyCreationException.java | 3 +++ .../ReactiveCircuitBreakerAdapterDecorator.java | 17 +++++++++++++++++ ...ebClientHttpServiceGroupConfigurerTests.java | 2 ++ .../circuitbreaker/httpservice/Fallbacks.java | 2 ++ 11 files changed, 86 insertions(+) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java index 945778d57..53db9f407 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -16,11 +16,25 @@ package org.springframework.cloud.client; +import org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerAdapterDecorator; +import org.springframework.cloud.client.circuitbreaker.httpservice.ReactiveCircuitBreakerAdapterDecorator; + /** + * Spring Cloud-specific {@code HttpClientServiceProperties}. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public abstract class AbstractCloudHttpClientServiceProperties { + /** + * Name of the class that contains fallback methods to be called by + * {@link CircuitBreakerAdapterDecorator} or + * {@link ReactiveCircuitBreakerAdapterDecorator} in case a fallback is triggered. + *

+ * Both the fallback class and the fallback methods must be public. + *

+ */ private String fallbackClassName; public String getFallbackClassName() { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java index 5957a8511..abba7c7e2 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -22,7 +22,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** + * Group specific implementation of {@link AbstractCloudHttpClientServiceProperties}. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ @ConfigurationProperties("spring.cloud.http.client.service") public class CloudHttpClientServiceProperties extends AbstractCloudHttpClientServiceProperties { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index ef2b4ccec..d5c31db88 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -34,7 +34,23 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** + * Blocking implementation of {@link HttpExchangeAdapterDecorator} that wraps + * {@code @HttpExchange} + *

+ * In the event of a CircuitBreaker fallback, this class uses the user-provided fallback + * class to create a proxy. The fallback method is selected by matching either: + *

    + *
  • A method with the same name and argument types as the original method, or
  • + *
  • A method with the same name and the original arguments preceded by a + * {@link Throwable}, allowing the user to access the {@code throwable} within the + * fallback.
  • + *
+ * Once a matching method is found, it is invoked to provide the fallback behavior. Both + * the fallback class and the fallback methods must be public. + *

+ * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index adb504eeb..f22f591d9 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -28,10 +28,15 @@ import org.springframework.aop.framework.ProxyFactory; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; /** + * Utility class used by CircuitBreaker-specific {@link HttpExchangeAdapterDecorator} + * implementations. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ final class CircuitBreakerConfigurerUtils { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index 68b625acd..4f5100513 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -24,7 +24,19 @@ import org.springframework.web.service.invoker.HttpRequestValues; /** + * A {@link HttpRequestValues.Processor} that adds information necessary for + * circuit-breaking to {@link HttpRequestValues}. The following attributes are added to + * the builder: + *
    + *
  • {@link #METHOD_ATTRIBUTE_NAME} - The name of the method being invoked.
  • + *
  • {@link #PARAMETER_TYPES_ATTRIBUTE_NAME} - The types of the parameters of the + * method.
  • + *
  • {@link #ARGUMENTS_ATTRIBUTE_NAME} - The actual arguments passed to the method.
  • + *
  • {@link #RETURN_TYPE_ATTRIBUTE_NAME} - The return type of the method.
  • + *
+ * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Processor { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 669b61995..1e52e8eb6 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -28,7 +28,13 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; /** + * An implementation of {@link RestClientHttpServiceGroupConfigurer} that provides + * CircuitBreaker integration for configured groups. This configurer applies + * CircuitBreaker logic to each HTTP service group and provides fallback behavior based on + * group-specific properties. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class CircuitBreakerRestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java index e4821f17f..e7bd01f31 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -32,7 +32,13 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; /** + * An implementation of {@link WebClientHttpServiceGroupConfigurer} that provides + * CircuitBreaker integration for configured groups. This configurer applies + * CircuitBreaker logic to each HTTP service group and provides fallback behavior based on + * group-specific properties. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class CircuitBreakerWebClientHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java index 7c37e5176..a81b964c0 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java @@ -17,7 +17,10 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; /** + * Exception thrown when a fallback proxy cannot be created. + * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class FallbackProxyCreationException extends RuntimeException { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index 668040171..b7ce15b27 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -29,6 +29,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapterDecorator; @@ -38,7 +39,23 @@ import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** + * Reactive implementation of {@link HttpExchangeAdapterDecorator} that wraps + * {@code @HttpExchange} + *

+ * In the event of a CircuitBreaker fallback, this class uses the user-provided fallback + * class to create a proxy. The fallback method is selected by matching either: + *

    + *
  • A method with the same name and argument types as the original method, or
  • + *
  • A method with the same name and the original arguments preceded by a + * {@link Throwable}, allowing the user to access the {@code throwable} within the + * fallback.
  • + *
+ * Once a matching method is found, it is invoked to provide the fallback behavior. Both + * the fallback class and the fallback methods must be public. + *

+ * * @author Olga Maciaszek-Sharma + * @since 5.0.0 */ public class ReactiveCircuitBreakerAdapterDecorator extends ReactorHttpExchangeAdapterDecorator { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java index 0d934e75b..1fa1f5d1f 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -57,6 +57,8 @@ import static org.mockito.Mockito.when; /** + * Tests for {@link CircuitBreakerWebClientHttpServiceGroupConfigurer}. + * * @author Olga Maciaszek-Sharma */ class CircuitBreakerWebClientHttpServiceGroupConfigurerTests { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java index 2a876a05d..b6061fab7 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -25,6 +25,8 @@ import org.springframework.util.MultiValueMap; /** + * Test fallback class. + * * @author Olga Maciaszek-Sharma */ public class Fallbacks { From 6a290b6b626ff537674ffa0b6113950f94dbe14b Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 17 Jul 2025 11:49:31 +0200 Subject: [PATCH 19/26] Add property flag to autoconfiguration. Signed-off-by: Olga Maciaszek-Sharma --- .../CommonsClientAutoConfiguration.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index 608431316..a62f16091 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -60,10 +60,10 @@ @EnableConfigurationProperties(CloudHttpClientServiceProperties.class) public class CommonsClientAutoConfiguration { - // FIXME: move instantiation to`spring-cloud-circuitbreaker` project - // TODO: add property flag - @ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class }) + @ConditionalOnClass({CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class}) @ConditionalOnBean(CircuitBreakerFactory.class) + @ConditionalOnProperty(value = "spring.cloud.circuitbreaker.interface-clients.enabled", + havingValue = "true", matchIfMissing = true) @Configuration(proxyBeanMethods = false) protected static class CircuitBreakerInterfaceClientsAutoConfiguration { @@ -75,11 +75,12 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClie } - // FIXME: move instantiation to`spring-cloud-circuitbreaker` project - // TODO: add property flag - @ConditionalOnClass({ CircuitBreaker.class, ReactiveCircuitBreaker.class, - WebClientHttpServiceGroupConfigurer.class }) - @ConditionalOnBean({ CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class }) + + @ConditionalOnClass({CircuitBreaker.class, ReactiveCircuitBreaker.class, + WebClientHttpServiceGroupConfigurer.class}) + @ConditionalOnBean({CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class}) + @ConditionalOnProperty(value = "spring.cloud.circuitbreaker.reactive-interface-clients.enabled", + havingValue = "true", matchIfMissing = true) @Configuration(proxyBeanMethods = false) protected static class ReactiveCircuitBreakerInterfaceClientsAutoConfiguration { @@ -112,7 +113,7 @@ public DiscoveryClientHealthIndicator discoveryClientHealthIndicator( @Bean @ConditionalOnProperty(value = "spring.cloud.discovery.client.composite-indicator.enabled", matchIfMissing = true) - @ConditionalOnBean({ DiscoveryHealthIndicator.class }) + @ConditionalOnBean({DiscoveryHealthIndicator.class}) public DiscoveryCompositeHealthContributor discoveryCompositeHealthContributor( List indicators) { return new DiscoveryCompositeHealthContributor(indicators); From d8d66367d3b5f3e743e85c3a078fe27203659ba2 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 17 Jul 2025 12:09:31 +0200 Subject: [PATCH 20/26] Reword javadoc. Signed-off-by: Olga Maciaszek-Sharma --- .../httpservice/CircuitBreakerAdapterDecorator.java | 2 +- .../httpservice/ReactiveCircuitBreakerAdapterDecorator.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index d5c31db88..ab5376f24 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -42,7 +42,7 @@ *
    *
  • A method with the same name and argument types as the original method, or
  • *
  • A method with the same name and the original arguments preceded by a - * {@link Throwable}, allowing the user to access the {@code throwable} within the + * {@link Throwable}, allowing the user to access the cause of failure within the * fallback.
  • *
* Once a matching method is found, it is invoked to provide the fallback behavior. Both diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index b7ce15b27..87fa612bc 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -47,7 +47,7 @@ *
    *
  • A method with the same name and argument types as the original method, or
  • *
  • A method with the same name and the original arguments preceded by a - * {@link Throwable}, allowing the user to access the {@code throwable} within the + * {@link Throwable}, allowing the user to access the cause of failure within the * fallback.
  • *
* Once a matching method is found, it is invoked to provide the fallback behavior. Both From 15dfe57c2ed3401d706f7ca8f187679a975821ee Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 17 Jul 2025 12:15:40 +0200 Subject: [PATCH 21/26] Add docs. Signed-off-by: Olga Maciaszek-Sharma --- .../pages/spring-cloud-circuitbreaker.adoc | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc b/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc index 0c44db692..c59b66162 100755 --- a/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc @@ -102,3 +102,49 @@ Customizer.once(circuitBreaker -> { .onStateTransition(event -> log.info("{}: {}", event.getCircuitBreakerName(), event.getStateTransition())); }, CircuitBreaker::getName) ---- + +[[interface-clients]] +== Spring Interface Clients support + +We provide integration for Spring Interface Clients through the `CircuitBreakerRestClientHttpServiceGroupConfigurer` and `CircuitBreakerWebClientHttpServiceGroupConfigurer`. If for a given https://docs.spring.io/spring-framework/reference/7.0-SNAPSHOT/integration/rest-clients.html#rest-http-interface-group-config[Spring Interface Clients Group], a fallback class name has been set through `spring.cloud.http.client.service.group.[groupName].fallback-class-name`, a `CircuitBreakerAdapterDecorator` or `ReactiveCircuitBreakerAdapterDecorator` -depending on whether `RestClient` or `WebClient` is being used under the hood- will be added for that group. + +The adapters wrap `@HttpExchange` calls with CircuitBreaker invocation. In the event of a CircuitBreaker fallback, they use the user-provided fallback +class to create a proxy. The fallback method is selected by matching either: + +* A method with the same name and argument types as the original method, or +* A method with the same name and the original arguments preceded by a +`Throwable`, allowing the user to access the cause of failure within the +fallback. + +For example, for the following interface: + +[source, java] +---- +@HttpExchange("/test") +public interface TestService { + + @GetExchange("/{id}") + Person test(@PathVariable UUID id); + + @GetExchange + String test(); +} +---- + +the following fallback class would have matching methods: + +[source, java] +---- +public class TestServiceFallback { + + Person test(UUID id); + + String test(Throwable cause); +} +---- + +Once a matching method is found, it is invoked to provide the fallback behavior. Both the fallback class and the fallback methods must be public. + +A single fallback class is required for the entire group, so fallback methods for various interface methods can be placed there. + +TIP: The fallback class methods should not have the `@HttpExchange`-related annotations in its methods. From ac02fef02a8f7312cab7dd0f1fdb74c8812bc1b8 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Thu, 17 Jul 2025 16:11:29 +0200 Subject: [PATCH 22/26] Allow using multiple fallback classes. Signed-off-by: Olga Maciaszek-Sharma --- ...tractCloudHttpClientServiceProperties.java | 13 +++-- .../CommonsClientAutoConfiguration.java | 15 +++--- .../CircuitBreakerAdapterDecorator.java | 27 +++++----- .../CircuitBreakerConfigurerUtils.java | 49 ++++++++++++++----- .../CircuitBreakerRequestValueProcessor.java | 6 +++ ...rRestClientHttpServiceGroupConfigurer.java | 13 +++-- ...erWebClientHttpServiceGroupConfigurer.java | 16 +++--- ...eactiveCircuitBreakerAdapterDecorator.java | 40 ++++++++------- .../CircuitBreakerAdapterDecoratorTests.java | 4 +- ...ClientHttpServiceGroupConfigurerTests.java | 13 +++-- ...ClientHttpServiceGroupConfigurerTests.java | 13 +++-- ...veCircuitBreakerAdapterDecoratorTests.java | 22 ++------- 12 files changed, 129 insertions(+), 102 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java index 53db9f407..635713a2e 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -16,6 +16,9 @@ package org.springframework.cloud.client; +import java.util.HashMap; +import java.util.Map; + import org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerAdapterDecorator; import org.springframework.cloud.client.circuitbreaker.httpservice.ReactiveCircuitBreakerAdapterDecorator; @@ -35,14 +38,14 @@ public abstract class AbstractCloudHttpClientServiceProperties { * Both the fallback class and the fallback methods must be public. *

*/ - private String fallbackClassName; + private Map fallbackClassNames = new HashMap<>(); - public String getFallbackClassName() { - return fallbackClassName; + public Map getFallbackClassNames() { + return fallbackClassNames; } - public void setFallbackClassName(String fallbackClassName) { - this.fallbackClassName = fallbackClassName; + public void setFallbackClassNames(Map fallbackClassNames) { + this.fallbackClassNames = fallbackClassNames; } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index a62f16091..dbdccb31b 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java @@ -60,10 +60,10 @@ @EnableConfigurationProperties(CloudHttpClientServiceProperties.class) public class CommonsClientAutoConfiguration { - @ConditionalOnClass({CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class}) + @ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class }) @ConditionalOnBean(CircuitBreakerFactory.class) - @ConditionalOnProperty(value = "spring.cloud.circuitbreaker.interface-clients.enabled", - havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty(value = "spring.cloud.circuitbreaker.interface-clients.enabled", havingValue = "true", + matchIfMissing = true) @Configuration(proxyBeanMethods = false) protected static class CircuitBreakerInterfaceClientsAutoConfiguration { @@ -75,10 +75,9 @@ public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClie } - - @ConditionalOnClass({CircuitBreaker.class, ReactiveCircuitBreaker.class, - WebClientHttpServiceGroupConfigurer.class}) - @ConditionalOnBean({CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class}) + @ConditionalOnClass({ CircuitBreaker.class, ReactiveCircuitBreaker.class, + WebClientHttpServiceGroupConfigurer.class }) + @ConditionalOnBean({ CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class }) @ConditionalOnProperty(value = "spring.cloud.circuitbreaker.reactive-interface-clients.enabled", havingValue = "true", matchIfMissing = true) @Configuration(proxyBeanMethods = false) @@ -113,7 +112,7 @@ public DiscoveryClientHealthIndicator discoveryClientHealthIndicator( @Bean @ConditionalOnProperty(value = "spring.cloud.discovery.client.composite-indicator.enabled", matchIfMissing = true) - @ConditionalOnBean({DiscoveryHealthIndicator.class}) + @ConditionalOnBean({ DiscoveryHealthIndicator.class }) public DiscoveryCompositeHealthContributor discoveryCompositeHealthContributor( List indicators) { return new DiscoveryCompositeHealthContributor(indicators); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index ab5376f24..b1f24875b 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -16,6 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Map; import java.util.function.Function; import org.apache.commons.logging.Log; @@ -30,7 +31,7 @@ import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; -import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxy; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxies; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** @@ -58,15 +59,15 @@ public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator private final CircuitBreaker circuitBreaker; - private final Class fallbackClass; + private final Map> fallbackClasses; - private volatile Object fallbackProxy; + private volatile Map fallbackProxies; public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, - Class fallbackClass) { + Map> fallbackClasses) { super(delegate); this.circuitBreaker = circuitBreaker; - this.fallbackClass = fallbackClass; + this.fallbackClasses = fallbackClasses; } @Override @@ -110,8 +111,8 @@ CircuitBreaker getCircuitBreaker() { } // Visible for tests - Class getFallbackClass() { - return fallbackClass; + Map> getFallbackClasses() { + return fallbackClasses; } @SuppressWarnings("unchecked") @@ -129,18 +130,18 @@ private T castIfPossible(Object result) { // Visible for tests Function createFallbackHandler(HttpRequestValues requestValues) { - return throwable -> getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + return throwable -> getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); } - private Object getFallbackProxy() { - if (fallbackProxy == null) { + private Map getFallbackProxies() { + if (fallbackProxies == null) { synchronized (this) { - if (fallbackProxy == null) { - fallbackProxy = createProxy(fallbackClass); + if (fallbackProxies == null) { + fallbackProxies = createProxies(fallbackClasses); } } } - return fallbackProxy; + return fallbackProxies; } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index f22f591d9..a5f1075b0 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -20,6 +20,7 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.Map; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.logging.Log; @@ -31,6 +32,8 @@ import org.springframework.web.service.invoker.HttpExchangeAdapterDecorator; import org.springframework.web.service.invoker.HttpRequestValues; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.DECLARING_CLASS_ATTRIBUTE_NAME; + /** * Utility class used by CircuitBreaker-specific {@link HttpExchangeAdapterDecorator} * implementations. @@ -40,22 +43,18 @@ */ final class CircuitBreakerConfigurerUtils { + public static final String DEFAULT_FALLBACK_KEY = "default"; + private CircuitBreakerConfigurerUtils() { throw new UnsupportedOperationException("Cannot instantiate a utility class"); } private static final Log LOG = LogFactory.getLog(CircuitBreakerConfigurerUtils.class); - static Class resolveFallbackClass(String className) { - try { - return Class.forName(className); - } - catch (ClassNotFoundException e) { - if (LOG.isDebugEnabled()) { - LOG.debug("Fallback class not found: " + className, e); - } - throw new IllegalStateException("Unable to load fallback class: " + className, e); - } + static Map> resolveFallbackClasses(Map fallbackClassNames) { + return fallbackClassNames.entrySet() + .stream() + .collect(Collectors.toMap(java.util.Map.Entry::getKey, entry -> resolveFallbackClass(entry.getValue()))); } @SuppressWarnings("unchecked") @@ -120,22 +119,46 @@ static Object invokeFallback(Method method, Map attributes, @Nul } } - static Object getFallback(HttpRequestValues requestValues, Throwable throwable, Object fallbackProxy, - Class fallbackClass) { + static Object getFallback(HttpRequestValues requestValues, Throwable throwable, Map fallbackProxies, + Map> fallbackClasses) { Map attributes = requestValues.getAttributes(); + Class fallbackClass = fallbackClasses.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) != null + ? fallbackClasses.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) + : fallbackClasses.get(DEFAULT_FALLBACK_KEY); Method fallback = resolveFallbackMethod(attributes, false, fallbackClass); Method fallbackWithCause = resolveFallbackMethod(attributes, true, fallbackClass); + Object fallbackProxy = fallbackProxies.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) != null + ? fallbackProxies.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) + : fallbackProxies.get(DEFAULT_FALLBACK_KEY); if (fallback != null) { return invokeFallback(fallback, attributes, null, fallbackProxy); } else if (fallbackWithCause != null) { - return invokeFallback(fallbackWithCause, attributes, throwable, fallbackProxy); + return invokeFallback(fallbackWithCause, attributes, throwable, fallbackProxies); } else { throw new NoFallbackAvailableException("No fallback available.", throwable); } } + static Map createProxies(Map> fallbackClasses) { + return fallbackClasses.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> createProxy(entry.getValue()))); + } + + private static Class resolveFallbackClass(String className) { + try { + return Class.forName(className); + } + catch (ClassNotFoundException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Fallback class not found: " + className, e); + } + throw new IllegalStateException("Unable to load fallback class: " + className, e); + } + } + static Object createProxy(Class fallbackClass) { try { Object target = fallbackClass.getConstructor().newInstance(); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index 4f5100513..6cd0bef4b 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -60,6 +60,11 @@ public class CircuitBreakerRequestValueProcessor implements HttpRequestValues.Pr */ public static final String RETURN_TYPE_ATTRIBUTE_NAME = "spring.cloud.method.return-type"; + /** + * Spring Cloud-specific attribute name for storing method declaring class name. + */ + public static final String DECLARING_CLASS_ATTRIBUTE_NAME = "spring.cloud.method.declaring-class"; + @Override public void process(Method method, MethodParameter[] parameters, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { @@ -67,6 +72,7 @@ public void process(Method method, MethodParameter[] parameters, @Nullable Objec builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); builder.addAttribute(RETURN_TYPE_ATTRIBUTE_NAME, method.getReturnType()); + builder.addAttribute(DECLARING_CLASS_ATTRIBUTE_NAME, method.getDeclaringClass()); } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index 1e52e8eb6..b8df8cb17 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -16,6 +16,8 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Map; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -25,7 +27,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; -import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClasses; /** * An implementation of {@link RestClientHttpServiceGroupConfigurer} that provides @@ -58,17 +60,18 @@ public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); - String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; - if (fallbackClassName == null || fallbackClassName.isBlank()) { + Map fallbackClassNames = (groupProperties != null) ? groupProperties.getFallbackClassNames() + : null; + if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { return; } - Class fallbackClass = resolveFallbackClass(fallbackClassName); + Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); factoryBuilder .exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerAdapterDecorator(httpExchangeAdapter, - buildCircuitBreaker(groupName), fallbackClass)); + buildCircuitBreaker(groupName), fallbackClasses)); }); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java index e7bd01f31..ee30cb3a0 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -16,8 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; +import java.util.Map; import org.springframework.cloud.client.CloudHttpClientServiceProperties; import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; @@ -29,7 +28,7 @@ import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; -import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClass; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClasses; /** * An implementation of {@link WebClientHttpServiceGroupConfigurer} that provides @@ -45,8 +44,6 @@ public class CircuitBreakerWebClientHttpServiceGroupConfigurer implements WebCli // Make sure Boot's configurers run before private static final int ORDER = 16; - private static final Log LOG = LogFactory.getLog(CircuitBreakerWebClientHttpServiceGroupConfigurer.class); - private final CloudHttpClientServiceProperties clientServiceProperties; private final ReactiveCircuitBreakerFactory reactiveCircuitBreakerFactory; @@ -66,18 +63,19 @@ public void configureGroups(Groups groups) { groups.forEachGroup((group, clientBuilder, factoryBuilder) -> { String groupName = group.name(); CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); - String fallbackClassName = (groupProperties != null) ? groupProperties.getFallbackClassName() : null; - if (fallbackClassName == null || fallbackClassName.isBlank()) { + Map fallbackClassNames = (groupProperties != null) ? groupProperties.getFallbackClassNames() + : null; + if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { return; } - Class fallbackClass = resolveFallbackClass(fallbackClassName); + Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); factoryBuilder.exchangeAdapterDecorator(httpExchangeAdapter -> { Assert.isInstanceOf(ReactorHttpExchangeAdapter.class, httpExchangeAdapter); return new ReactiveCircuitBreakerAdapterDecorator((ReactorHttpExchangeAdapter) httpExchangeAdapter, - buildReactiveCircuitBreaker(groupName), buildCircuitBreaker(groupName), fallbackClass); + buildReactiveCircuitBreaker(groupName), buildCircuitBreaker(groupName), fallbackClasses); }); }); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index 87fa612bc..452c04e6c 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -16,6 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Map; import java.util.function.Function; import org.apache.commons.logging.Log; @@ -35,7 +36,7 @@ import org.springframework.web.service.invoker.ReactorHttpExchangeAdapterDecorator; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.castIfPossible; -import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxy; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxies; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.getFallback; /** @@ -65,16 +66,17 @@ public class ReactiveCircuitBreakerAdapterDecorator extends ReactorHttpExchangeA private final CircuitBreaker circuitBreaker; - private final Class fallbackClass; + private final Map> fallbackClasses; - private volatile Object fallbackProxy; + private volatile Map fallbackProxies; public ReactiveCircuitBreakerAdapterDecorator(ReactorHttpExchangeAdapter delegate, - ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, Class fallbackClass) { + ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, + Map> fallbackClasses) { super(delegate); this.reactiveCircuitBreaker = reactiveCircuitBreaker; this.circuitBreaker = circuitBreaker; - this.fallbackClass = fallbackClass; + this.fallbackClasses = fallbackClasses; } @Override @@ -158,17 +160,17 @@ public Mono>> exchangeForEntityFlux(HttpRequestValues // Visible for tests Function createFallbackHandler(HttpRequestValues requestValues) { - return throwable -> getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + return throwable -> getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); } Function> createBodyMonoFallbackHandler(HttpRequestValues requestValues) { if (((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) .equals(Mono.class))) { return throwable -> castIfPossible( - getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses)); } return throwable -> { - Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); if (fallback == null) { return Mono.empty(); } @@ -180,10 +182,10 @@ Function> createBodyFluxFallbackHandler(HttpRequestValues if (((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME))) .equals(Flux.class)) { return throwable -> castIfPossible( - getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses)); } return throwable -> { - Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); if (fallback == null) { return Flux.empty(); @@ -196,10 +198,10 @@ Function> createHttpHeadersMonoFallbackHandler(Http if ((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) .equals(Mono.class)) { return throwable -> castIfPossible( - getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass)); + getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses)); } return throwable -> { - Object fallback = getFallback(requestValues, throwable, getFallbackProxy(), fallbackClass); + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); if (fallback == null) { return Mono.empty(); } @@ -218,19 +220,19 @@ CircuitBreaker getCircuitBreaker() { } // Visible for tests - Class getFallbackClass() { - return fallbackClass; + Map> getFallbackClasses() { + return fallbackClasses; } - private Object getFallbackProxy() { - if (fallbackProxy == null) { + private Map getFallbackProxies() { + if (fallbackProxies == null) { synchronized (this) { - if (fallbackProxy == null) { - fallbackProxy = createProxy(fallbackClass); + if (fallbackProxies == null) { + fallbackProxies = createProxies(fallbackClasses); } } } - return fallbackProxy; + return fallbackProxies; } } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java index c1651e550..91e3eda01 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -16,6 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -33,6 +34,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; @@ -52,7 +54,7 @@ class CircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); private final CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator(adapter, circuitBreaker, - Fallbacks.class); + Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class)); @Test void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java index 1bce319cf..6c55b7169 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java @@ -16,7 +16,9 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Collections; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; @@ -48,6 +50,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; /** * Tests for {@link CircuitBreakerRestClientHttpServiceGroupConfigurer}. @@ -73,7 +76,7 @@ void setUp() { @Test void shouldAddCircuitBreakerAdapterDecorator() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName(Fallbacks.class.getCanonicalName()); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class.getCanonicalName())); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( clientServiceProperties, circuitBreakerFactory); @@ -87,13 +90,13 @@ void shouldAddCircuitBreakerAdapterDecorator() { CircuitBreakerAdapterDecorator decorator = (CircuitBreakerAdapterDecorator) captured .apply(new TestHttpExchangeAdapter()); assertThat(decorator.getCircuitBreaker()).isNotNull(); - assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); + assertThat(decorator.getFallbackClasses().get(DEFAULT_FALLBACK_KEY)).isAssignableFrom(Fallbacks.class); } @Test void shouldThrowExceptionWhenCantLoadClass() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName("org.test.Fallback"); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, "org.test.Fallback")); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( clientServiceProperties, circuitBreakerFactory); @@ -103,9 +106,9 @@ void shouldThrowExceptionWhenCantLoadClass() { @ParameterizedTest @NullAndEmptySource - void shouldNotAddDecoratorWhenFallbackClassNameIsNull(String fallbackClassName) { + void shouldNotAddDecoratorWhenFallbackClassNamesNullOrEmpty(Map fallbackClassNames) { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName(fallbackClassName); + group.setFallbackClassNames(fallbackClassNames); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( clientServiceProperties, circuitBreakerFactory); diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java index 1fa1f5d1f..48fa28744 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -17,7 +17,9 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; import java.time.Duration; +import java.util.Collections; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; @@ -55,6 +57,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; /** * Tests for {@link CircuitBreakerWebClientHttpServiceGroupConfigurer}. @@ -85,7 +88,7 @@ void setUp() { @Test void shouldAddCircuitBreakerAdapterDecorator() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName(Fallbacks.class.getCanonicalName()); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class.getCanonicalName())); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); @@ -100,13 +103,13 @@ void shouldAddCircuitBreakerAdapterDecorator() { .apply(new TestHttpExchangeAdapter()); assertThat(decorator.getCircuitBreaker()).isNotNull(); assertThat(decorator.getReactiveCircuitBreaker()).isNotNull(); - assertThat(decorator.getFallbackClass()).isAssignableFrom(Fallbacks.class); + assertThat(decorator.getFallbackClasses().get(DEFAULT_FALLBACK_KEY)).isAssignableFrom(Fallbacks.class); } @Test void shouldThrowExceptionWhenCantLoadClass() { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName("org.test.Fallback"); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, "org.test.Fallback")); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); @@ -116,9 +119,9 @@ void shouldThrowExceptionWhenCantLoadClass() { @ParameterizedTest @NullAndEmptySource - void shouldNotAddDecoratorWhenFallbackClassNameIsNull(String fallbackClassName) { + void shouldNotAddDecoratorWhenFallbackClassNamesNullOrEmpty(Map fallbackClassNames) { CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); - group.setFallbackClassName(fallbackClassName); + group.setFallbackClassNames(fallbackClassNames); clientServiceProperties.getGroup().put(GROUP_NAME, group); CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 61eac97ea..6bc61ce42 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -16,6 +16,7 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -28,7 +29,6 @@ import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; -import org.springframework.http.HttpHeaders; import org.springframework.web.service.invoker.HttpRequestValues; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; @@ -39,6 +39,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; @@ -64,7 +65,7 @@ class ReactiveCircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, - reactiveCircuitBreaker, circuitBreaker, Fallbacks.class); + reactiveCircuitBreaker, circuitBreaker, Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class)); @BeforeEach void setUp() { @@ -200,23 +201,6 @@ void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } - @SuppressWarnings("DataFlowIssue") - @Test - void shouldCreateHttpHeadersMonoFallbackHandler() { - Map attributes = new HashMap<>(); - attributes.put(METHOD_ATTRIBUTE_NAME, "testHttpHeadersMono"); - attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); - attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); - attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); - when(httpRequestValues.getAttributes()).thenReturn(attributes); - Function> fallbackHandler = decorator - .createHttpHeadersMonoFallbackHandler(httpRequestValues); - - HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")).block(); - - assertThat(fallback.get(TEST_DESCRIPTION).get(0)).isEqualTo(String.valueOf(TEST_VALUE)); - } - @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); From 1fe805b7bb8af3756082765a50f9c4d2a3f16ae9 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Fri, 18 Jul 2025 13:36:37 +0200 Subject: [PATCH 23/26] Fix the logic for resolving fallback from multiple fallback classes. Signed-off-by: Olga Maciaszek-Sharma --- .../CircuitBreakerConfigurerUtils.java | 2 +- .../CircuitBreakerRequestValueProcessor.java | 2 +- ...iveCircuitBreakerAdapterDecoratorTests.java | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index a5f1075b0..bd9150179 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -134,7 +134,7 @@ static Object getFallback(HttpRequestValues requestValues, Throwable throwable, return invokeFallback(fallback, attributes, null, fallbackProxy); } else if (fallbackWithCause != null) { - return invokeFallback(fallbackWithCause, attributes, throwable, fallbackProxies); + return invokeFallback(fallbackWithCause, attributes, throwable, fallbackProxy); } else { throw new NoFallbackAvailableException("No fallback available.", throwable); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index 6cd0bef4b..1dc88abf2 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -72,7 +72,7 @@ public void process(Method method, MethodParameter[] parameters, @Nullable Objec builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); builder.addAttribute(RETURN_TYPE_ATTRIBUTE_NAME, method.getReturnType()); - builder.addAttribute(DECLARING_CLASS_ATTRIBUTE_NAME, method.getDeclaringClass()); + builder.addAttribute(DECLARING_CLASS_ATTRIBUTE_NAME, method.getDeclaringClass().getName()); } } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 6bc61ce42..bf3b23227 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -29,6 +29,7 @@ import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.cloud.client.circuitbreaker.ReactiveCircuitBreaker; +import org.springframework.http.HttpHeaders; import org.springframework.web.service.invoker.HttpRequestValues; import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; @@ -201,6 +202,23 @@ void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } + @SuppressWarnings("DataFlowIssue") + @Test + void shouldCreateHttpHeadersMonoFallbackHandler() { + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "testHttpHeadersMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator + .createHttpHeadersMonoFallbackHandler(httpRequestValues); + + HttpHeaders fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback.get(TEST_DESCRIPTION).get(0)).isEqualTo(String.valueOf(TEST_VALUE)); + } + @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); From 9dd9b1bd91ebb7a99c1068fd5ab416321880d4d8 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Fri, 18 Jul 2025 13:37:06 +0200 Subject: [PATCH 24/26] Add javadoc and regenerate configprops. Signed-off-by: Olga Maciaszek-Sharma --- docs/modules/ROOT/partials/_configprops.adoc | 2 ++ .../cloud/client/CloudHttpClientServiceProperties.java | 3 +++ 2 files changed, 5 insertions(+) diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index eb6f9da0e..181c71fb4 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -23,6 +23,8 @@ |spring.cloud.discovery.client.simple.order | | |spring.cloud.discovery.enabled | `+++true+++` | Enables discovery client health indicators. |spring.cloud.features.enabled | `+++true+++` | Enables the features endpoint. +|spring.cloud.http.client.service.fallback-class-names | | +|spring.cloud.http.client.service.group | | Maps properties to groups by group name. |spring.cloud.httpclientfactories.apache.enabled | `+++true+++` | Enables creation of Apache Http Client factory beans. |spring.cloud.httpclientfactories.ok.enabled | `+++true+++` | Enables creation of OK Http Client factory beans. |spring.cloud.hypermedia.refresh.fixed-delay | `+++5000+++` | diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java index abba7c7e2..94581d193 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -30,6 +30,9 @@ @ConfigurationProperties("spring.cloud.http.client.service") public class CloudHttpClientServiceProperties extends AbstractCloudHttpClientServiceProperties { + /** + * Maps properties to groups by group name. + */ private Map group = new LinkedHashMap<>(); public Map getGroup() { From dcc8d8ae7d4441fa8707c67d472e0ad00a2bdbaf Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Fri, 18 Jul 2025 13:59:16 +0200 Subject: [PATCH 25/26] Allow defining default fallback for all groups. Add more tests. Signed-off-by: Olga Maciaszek-Sharma --- ...rRestClientHttpServiceGroupConfigurer.java | 2 +- .../CircuitBreakerAdapterDecoratorTests.java | 21 ++++++++++++++++ .../httpservice/EmptyFallbacks.java | 24 +++++++++++++++++++ ...veCircuitBreakerAdapterDecoratorTests.java | 21 ++++++++++++++++ .../httpservice/TestService.java | 24 +++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/EmptyFallbacks.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/TestService.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index b8df8cb17..a0d8404ab 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -61,7 +61,7 @@ public void configureGroups(Groups groups) { String groupName = group.name(); CloudHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName); Map fallbackClassNames = (groupProperties != null) ? groupProperties.getFallbackClassNames() - : null; + : clientServiceProperties.getFallbackClassNames(); if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { return; } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java index 91e3eda01..21ea04de4 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -36,6 +36,7 @@ import static org.mockito.Mockito.when; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.DECLARING_CLASS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME; @@ -78,6 +79,26 @@ void shouldCreateFallbackHandler() { assertThat(fallback).isEqualTo("testDescription: 5"); } + @Test + void shouldCreateFallbackHandlerFromPerClassFallbackClassNames() { + Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, + TestService.class, Fallbacks.class); + CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator(adapter, circuitBreaker, + perClassFallbackClassNames); + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "test"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { "testDescription", 5 }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo("testDescription: 5"); + } + @Test void shouldCreateFallbackHandlerWithCause() { Map attributes = new HashMap<>(); diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/EmptyFallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/EmptyFallbacks.java new file mode 100644 index 000000000..b1a2e90d2 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/EmptyFallbacks.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; + +/** + * @author Olga Maciaszek-Sharma + */ +public class EmptyFallbacks { + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index bf3b23227..11919a693 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -42,6 +42,7 @@ import static org.mockito.Mockito.when; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.ARGUMENTS_ATTRIBUTE_NAME; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.DECLARING_CLASS_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.METHOD_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.PARAMETER_TYPES_ATTRIBUTE_NAME; import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME; @@ -110,6 +111,26 @@ void shouldCreateBodyMonoFallbackHandler() { assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); } + @Test + void shouldCreateBodyMonoFallbackHandlerFromPerClassFallbackClassNames() { + Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, + TestService.class, Fallbacks.class); + ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, + reactiveCircuitBreaker, circuitBreaker, perClassFallbackClassNames); + Map attributes = new HashMap<>(); + attributes.put(METHOD_ATTRIBUTE_NAME, "testMono"); + attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); + attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); + attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class); + when(httpRequestValues.getAttributes()).thenReturn(attributes); + Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + @Test void shouldCreateBodyMonoFallbackHandlerForNonReactiveReturnType() { assertThatCode(() -> { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/TestService.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/TestService.java new file mode 100644 index 000000000..b703810f8 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/TestService.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; + +/** + * @author Olga Maciaszek-Sharma + */ +interface TestService { + +} From a736cdd660aa0cb7ce6b1fb12d647d2ec33a6cc0 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Fri, 18 Jul 2025 16:29:56 +0200 Subject: [PATCH 26/26] Use String map keys. Handle default settings for all groups. Refactor. Add more tests. Adjust docs. Signed-off-by: Olga Maciaszek-Sharma --- .../pages/spring-cloud-circuitbreaker.adoc | 108 +++++++++++++----- docs/modules/ROOT/partials/_configprops.adoc | 2 +- .../CircuitBreakerAdapterDecorator.java | 10 +- .../CircuitBreakerConfigurerUtils.java | 19 ++- .../CircuitBreakerRequestValueProcessor.java | 2 +- ...rRestClientHttpServiceGroupConfigurer.java | 2 +- ...erWebClientHttpServiceGroupConfigurer.java | 2 +- ...eactiveCircuitBreakerAdapterDecorator.java | 10 +- .../CircuitBreakerAdapterDecoratorTests.java | 16 ++- ...veCircuitBreakerAdapterDecoratorTests.java | 25 +++- .../httpservice/UnusedTestService.java | 24 ++++ 11 files changed, 160 insertions(+), 60 deletions(-) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/UnusedTestService.java diff --git a/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc b/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc index c59b66162..aa92714cd 100755 --- a/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc @@ -16,7 +16,8 @@ Spring Cloud supports the following circuit-breaker implementations: [[core-concepts]] == Core Concepts -To create a circuit breaker in your code, you can use the `CircuitBreakerFactory` API. When you include a Spring Cloud Circuit Breaker starter on your classpath, a bean that implements this API is automatically created for you. +To create a circuit breaker in your code, you can use the `CircuitBreakerFactory` API. +When you include a Spring Cloud Circuit Breaker starter on your classpath, a bean that implements this API is automatically created for you. The following example shows a simple example of how to use this API: [source,java] @@ -82,16 +83,16 @@ that caused the failure. You can configure your circuit breakers by creating beans of type `Customizer`. The `Customizer` interface has a single method (called `customize`) that takes the `Object` to customize. -For detailed information on how to customize a given implementation see -the following documentation: +For detailed information on how to customize a given implementation see the following documentation: * link:../../../../spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j.html[Resilience4J] * link:https://github.com/alibaba/spring-cloud-alibaba/blob/master/spring-cloud-alibaba-docs/src/main/asciidoc/circuitbreaker-sentinel.adoc#circuit-breaker-spring-cloud-circuit-breaker-with-sentinel--configuring-sentinel-circuit-breakers[Sentinel] * link:../../../../../spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-spring-retry.html[Spring Retry] Some `CircuitBreaker` implementations such as `Resilience4JCircuitBreaker` call `customize` method every time `CircuitBreaker#run` is called. -It can be inefficient. In that case, you can use `CircuitBreaker#once` method. It is useful where calling `customize` many times doesn't make sense, -for example, in case of https://resilience4j.readme.io/docs/circuitbreaker#section-consume-emitted-circuitbreakerevents[consuming Resilience4j's events]. +It can be inefficient. +In that case, you can use `CircuitBreaker#once` method. +It is useful where calling `customize` many times doesn't make sense, for example, in case of https://resilience4j.readme.io/docs/circuitbreaker#section-consume-emitted-circuitbreakerevents[consuming Resilience4j's events]. The following example shows the way for each `io.github.resilience4j.circuitbreaker.CircuitBreaker` to consume events. @@ -104,47 +105,102 @@ Customizer.once(circuitBreaker -> { ---- [[interface-clients]] -== Spring Interface Clients support +== Spring Interface Clients Support -We provide integration for Spring Interface Clients through the `CircuitBreakerRestClientHttpServiceGroupConfigurer` and `CircuitBreakerWebClientHttpServiceGroupConfigurer`. If for a given https://docs.spring.io/spring-framework/reference/7.0-SNAPSHOT/integration/rest-clients.html#rest-http-interface-group-config[Spring Interface Clients Group], a fallback class name has been set through `spring.cloud.http.client.service.group.[groupName].fallback-class-name`, a `CircuitBreakerAdapterDecorator` or `ReactiveCircuitBreakerAdapterDecorator` -depending on whether `RestClient` or `WebClient` is being used under the hood- will be added for that group. +We provide support for Spring Interface Clients integration through the following configurers: -The adapters wrap `@HttpExchange` calls with CircuitBreaker invocation. In the event of a CircuitBreaker fallback, they use the user-provided fallback -class to create a proxy. The fallback method is selected by matching either: +- `CircuitBreakerRestClientHttpServiceGroupConfigurer` +- `CircuitBreakerWebClientHttpServiceGroupConfigurer` -* A method with the same name and argument types as the original method, or -* A method with the same name and the original arguments preceded by a -`Throwable`, allowing the user to access the cause of failure within the -fallback. +These configurers enable CircuitBreaker support for https://docs.spring.io/spring-framework/reference/7.0-SNAPSHOT/integration/rest-clients.html#rest-http-interface-group-config[Spring Interface Client Groups]. -For example, for the following interface: +When fallback class names are configured using: -[source, java] +- `spring.cloud.http.client.service.group.[groupName].fallback-class-names` (for a specific group), or +- `spring.cloud.http.client.service.fallback-class-names` (as a default for all groups), + +a CircuitBreaker adapter is added to the respective group: +- `CircuitBreakerAdapterDecorator` is used with `RestClient` +- `ReactiveCircuitBreakerAdapterDecorator` is used with `WebClient` + +=== How CircuitBreaker Adapters Work + +The adapters wrap `@HttpExchange` method calls with CircuitBreaker logic. When a fallback is triggered, a proxy is created using the user-defined fallback class. The appropriate fallback method is selected by matching: + +- A method with the same name and parameter types, or +- A method with the same name and parameter types preceded by a `Throwable` argument (to access the cause of failure) + +Given the following interface: + +[source,java] ---- @HttpExchange("/test") public interface TestService { - @GetExchange("/{id}") - Person test(@PathVariable UUID id); + @GetExchange("/{id}") + Person test(@PathVariable UUID id); - @GetExchange - String test(); + @GetExchange + String test(); } ---- -the following fallback class would have matching methods: +A matching fallback class could be: -[source, java] +[source,java] ---- public class TestServiceFallback { - Person test(UUID id); + public Person test(UUID id); - String test(Throwable cause); + public String test(Throwable cause); } ---- -Once a matching method is found, it is invoked to provide the fallback behavior. Both the fallback class and the fallback methods must be public. +[NOTE] +==== +Both the fallback class and its methods must be `public`. +==== + +[TIP] +==== +Fallback methods must *not* include `@HttpExchange` or any related annotations. +==== + +=== Configuring Fallbacks -A single fallback class is required for the entire group, so fallback methods for various interface methods can be placed there. +Fallback class names are configured via properties as a map: -TIP: The fallback class methods should not have the `@HttpExchange`-related annotations in its methods. +- *Key:* Fully qualified interface class name (or use `default` to specify a fallback class for all interfaces) +- *Value:* Fully qualified fallback class name + +The following example applies to all client groups and the setup will result in using `com.example.http.verification.client.fallback.PersonServiceFallbacks` as fallback class for `PersonService` and `com.example.http.verification.client.fallback.DefaultFallbacks` for all other services for all the groups. + +[source,yml] +---- +spring: + cloud: + http: + client: + service: + fallback-class-names: + -default: com.example.http.verification.client.fallback.DefaultFallbacks + -com.example.http.verification.client.clients.PersonService: com.example.http.verification.client.fallback.PersonServiceFallbacks +---- + + +The example below applies only to the `verification` group: + +[source,yml] +---- +spring: + cloud: + http: + client: + service: + group: + -verification: + fallback-class-names: + -default: com.example.http.verification.client.fallback.DefaultFallbacks + -com.example.http.verification.client.clients.PersonService: com.example.http.verification.client.fallback.PersonServiceFallbacks +---- diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index 181c71fb4..c7a5e3f4b 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -23,7 +23,7 @@ |spring.cloud.discovery.client.simple.order | | |spring.cloud.discovery.enabled | `+++true+++` | Enables discovery client health indicators. |spring.cloud.features.enabled | `+++true+++` | Enables the features endpoint. -|spring.cloud.http.client.service.fallback-class-names | | +|spring.cloud.http.client.service.fallback-class-names | | Name of the class that contains fallback methods to be called by {@link CircuitBreakerAdapterDecorator} or {@link ReactiveCircuitBreakerAdapterDecorator} in case a fallback is triggered.

Both the fallback class and the fallback methods must be public.

|spring.cloud.http.client.service.group | | Maps properties to groups by group name. |spring.cloud.httpclientfactories.apache.enabled | `+++true+++` | Enables creation of Apache Http Client factory beans. |spring.cloud.httpclientfactories.ok.enabled | `+++true+++` | Enables creation of OK Http Client factory beans. diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java index b1f24875b..3c109cc9f 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -59,12 +59,12 @@ public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator private final CircuitBreaker circuitBreaker; - private final Map> fallbackClasses; + private final Map> fallbackClasses; - private volatile Map fallbackProxies; + private volatile Map fallbackProxies; public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, - Map> fallbackClasses) { + Map> fallbackClasses) { super(delegate); this.circuitBreaker = circuitBreaker; this.fallbackClasses = fallbackClasses; @@ -111,7 +111,7 @@ CircuitBreaker getCircuitBreaker() { } // Visible for tests - Map> getFallbackClasses() { + Map> getFallbackClasses() { return fallbackClasses; } @@ -133,7 +133,7 @@ Function createFallbackHandler(HttpRequestValues requestValue return throwable -> getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); } - private Map getFallbackProxies() { + private Map getFallbackProxies() { if (fallbackProxies == null) { synchronized (this) { if (fallbackProxies == null) { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java index bd9150179..164b13777 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -51,7 +51,7 @@ private CircuitBreakerConfigurerUtils() { private static final Log LOG = LogFactory.getLog(CircuitBreakerConfigurerUtils.class); - static Map> resolveFallbackClasses(Map fallbackClassNames) { + static Map> resolveFallbackClasses(Map fallbackClassNames) { return fallbackClassNames.entrySet() .stream() .collect(Collectors.toMap(java.util.Map.Entry::getKey, entry -> resolveFallbackClass(entry.getValue()))); @@ -119,17 +119,16 @@ static Object invokeFallback(Method method, Map attributes, @Nul } } - static Object getFallback(HttpRequestValues requestValues, Throwable throwable, Map fallbackProxies, - Map> fallbackClasses) { + static Object getFallback(HttpRequestValues requestValues, Throwable throwable, Map fallbackProxies, + Map> fallbackClasses) { Map attributes = requestValues.getAttributes(); - Class fallbackClass = fallbackClasses.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) != null - ? fallbackClasses.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) - : fallbackClasses.get(DEFAULT_FALLBACK_KEY); + String declaringClassName = (String) attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME); + Class fallbackClass = fallbackClasses.getOrDefault(declaringClassName, + fallbackClasses.get(DEFAULT_FALLBACK_KEY)); Method fallback = resolveFallbackMethod(attributes, false, fallbackClass); Method fallbackWithCause = resolveFallbackMethod(attributes, true, fallbackClass); - Object fallbackProxy = fallbackProxies.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) != null - ? fallbackProxies.get(attributes.get(DECLARING_CLASS_ATTRIBUTE_NAME)) - : fallbackProxies.get(DEFAULT_FALLBACK_KEY); + Object fallbackProxy = fallbackProxies.getOrDefault(declaringClassName, + fallbackProxies.get(DEFAULT_FALLBACK_KEY)); if (fallback != null) { return invokeFallback(fallback, attributes, null, fallbackProxy); } @@ -141,7 +140,7 @@ else if (fallbackWithCause != null) { } } - static Map createProxies(Map> fallbackClasses) { + static Map createProxies(Map> fallbackClasses) { return fallbackClasses.entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> createProxy(entry.getValue()))); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java index 1dc88abf2..3735d7d1c 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -72,7 +72,7 @@ public void process(Method method, MethodParameter[] parameters, @Nullable Objec builder.addAttribute(PARAMETER_TYPES_ATTRIBUTE_NAME, method.getParameterTypes()); builder.addAttribute(ARGUMENTS_ATTRIBUTE_NAME, arguments); builder.addAttribute(RETURN_TYPE_ATTRIBUTE_NAME, method.getReturnType()); - builder.addAttribute(DECLARING_CLASS_ATTRIBUTE_NAME, method.getDeclaringClass().getName()); + builder.addAttribute(DECLARING_CLASS_ATTRIBUTE_NAME, method.getDeclaringClass().getCanonicalName()); } } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java index a0d8404ab..bf77116d6 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -65,7 +65,7 @@ public void configureGroups(Groups groups) { if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { return; } - Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); + Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java index ee30cb3a0..5eb1d7c54 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -68,7 +68,7 @@ public void configureGroups(Groups groups) { if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { return; } - Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); + Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java index 452c04e6c..75e22555d 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -66,13 +66,13 @@ public class ReactiveCircuitBreakerAdapterDecorator extends ReactorHttpExchangeA private final CircuitBreaker circuitBreaker; - private final Map> fallbackClasses; + private final Map> fallbackClasses; - private volatile Map fallbackProxies; + private volatile Map fallbackProxies; public ReactiveCircuitBreakerAdapterDecorator(ReactorHttpExchangeAdapter delegate, ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, - Map> fallbackClasses) { + Map> fallbackClasses) { super(delegate); this.reactiveCircuitBreaker = reactiveCircuitBreaker; this.circuitBreaker = circuitBreaker; @@ -220,11 +220,11 @@ CircuitBreaker getCircuitBreaker() { } // Visible for tests - Map> getFallbackClasses() { + Map> getFallbackClasses() { return fallbackClasses; } - private Map getFallbackProxies() { + private Map getFallbackProxies() { if (fallbackProxies == null) { synchronized (this) { if (fallbackProxies == null) { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java index 21ea04de4..6363fa072 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -16,7 +16,6 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -54,8 +53,10 @@ class CircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); + // Also verifies class fallback won't override default fallback for other classes private final CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator(adapter, circuitBreaker, - Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class)); + Map.of(DEFAULT_FALLBACK_KEY, Fallbacks.class, UnusedTestService.class.getCanonicalName(), + EmptyFallbacks.class)); @Test void shouldWrapAdapterCallsWithCircuitBreakerInvocation() { @@ -71,6 +72,7 @@ void shouldCreateFallbackHandler() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { "testDescription", 5 }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -81,8 +83,8 @@ void shouldCreateFallbackHandler() { @Test void shouldCreateFallbackHandlerFromPerClassFallbackClassNames() { - Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, - TestService.class, Fallbacks.class); + Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, + TestService.class.getCanonicalName(), Fallbacks.class); CircuitBreakerAdapterDecorator decorator = new CircuitBreakerAdapterDecorator(adapter, circuitBreaker, perClassFallbackClassNames); Map attributes = new HashMap<>(); @@ -90,7 +92,7 @@ void shouldCreateFallbackHandlerFromPerClassFallbackClassNames() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { "testDescription", 5 }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); - attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -106,6 +108,7 @@ void shouldCreateFallbackHandlerWithCause() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), "testDescription", 5 }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -116,6 +119,9 @@ void shouldCreateFallbackHandlerWithCause() { @Test void shouldThrowExceptionWhenNoFallbackAvailable() { + Map attributes = new HashMap<>(); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java index 11919a693..a85470ed1 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -16,7 +16,6 @@ package org.springframework.cloud.client.circuitbreaker.httpservice; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -66,8 +65,10 @@ class ReactiveCircuitBreakerAdapterDecoratorTests { private final HttpRequestValues httpRequestValues = mock(HttpRequestValues.class); + // Also verifies class fallback won't override default fallback for other classes private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, - reactiveCircuitBreaker, circuitBreaker, Collections.singletonMap(DEFAULT_FALLBACK_KEY, Fallbacks.class)); + reactiveCircuitBreaker, circuitBreaker, Map.of(DEFAULT_FALLBACK_KEY, Fallbacks.class, + UnusedTestService.class.getCanonicalName(), EmptyFallbacks.class)); @BeforeEach void setUp() { @@ -88,6 +89,7 @@ void shouldCreateFallbackHandler() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -103,6 +105,7 @@ void shouldCreateBodyMonoFallbackHandler() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -113,8 +116,8 @@ void shouldCreateBodyMonoFallbackHandler() { @Test void shouldCreateBodyMonoFallbackHandlerFromPerClassFallbackClassNames() { - Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, - TestService.class, Fallbacks.class); + Map> perClassFallbackClassNames = Map.of(DEFAULT_FALLBACK_KEY, EmptyFallbacks.class, + TestService.class.getCanonicalName(), Fallbacks.class); ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, reactiveCircuitBreaker, circuitBreaker, perClassFallbackClassNames); Map attributes = new HashMap<>(); @@ -122,7 +125,7 @@ void shouldCreateBodyMonoFallbackHandlerFromPerClassFallbackClassNames() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); - attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -139,6 +142,7 @@ void shouldCreateBodyMonoFallbackHandlerForNonReactiveReturnType() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator .createBodyMonoFallbackHandler(httpRequestValues); @@ -155,6 +159,7 @@ void shouldCreateBodyFluxFallbackHandlerForNonReactiveReturnType() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator .createBodyFluxFallbackHandler(httpRequestValues); @@ -170,6 +175,7 @@ void shouldCreateBodyMonoFallbackHandlerForVoidReturnType() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Void.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -185,6 +191,7 @@ void shouldCreateBodyFluxFallbackHandler() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); @@ -200,6 +207,7 @@ void shouldCreateBodyFluxFallbackHandlerFromNonReactiveReturnType() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); @@ -215,6 +223,7 @@ void shouldCreateBodyFluxFallbackHandlerFromReactiveReturnType() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Flux.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyFluxFallbackHandler(httpRequestValues); @@ -231,6 +240,7 @@ void shouldCreateHttpHeadersMonoFallbackHandler() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator .createHttpHeadersMonoFallbackHandler(httpRequestValues); @@ -247,6 +257,7 @@ void shouldCreateFallbackHandlerWithCause() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, String.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); @@ -262,6 +273,7 @@ void shouldCreateReactiveFallbackHandlerWithCause() { attributes.put(PARAMETER_TYPES_ATTRIBUTE_NAME, new Class[] { Throwable.class, String.class, Integer.class }); attributes.put(ARGUMENTS_ATTRIBUTE_NAME, new Object[] { new Throwable("test!"), TEST_DESCRIPTION, TEST_VALUE }); attributes.put(RETURN_TYPE_ATTRIBUTE_NAME, Mono.class); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); when(httpRequestValues.getAttributes()).thenReturn(attributes); Function> fallbackHandler = decorator.createBodyMonoFallbackHandler(httpRequestValues); @@ -272,6 +284,9 @@ void shouldCreateReactiveFallbackHandlerWithCause() { @Test void shouldThrowExceptionWhenNoFallbackAvailable() { + Map attributes = new HashMap<>(); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + when(httpRequestValues.getAttributes()).thenReturn(attributes); Function fallbackHandler = decorator.createFallbackHandler(httpRequestValues); assertThatExceptionOfType(NoFallbackAvailableException.class) diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/UnusedTestService.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/UnusedTestService.java new file mode 100644 index 000000000..b6b5d0613 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/UnusedTestService.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013-2025 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.client.circuitbreaker.httpservice; + +/** + * @author Olga Maciaszek-Sharma + */ +interface UnusedTestService { + +}