diff --git a/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc b/docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc index 0c44db692..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. @@ -102,3 +103,104 @@ Customizer.once(circuitBreaker -> { .onStateTransition(event -> log.info("{}: {}", event.getCircuitBreakerName(), event.getStateTransition())); }, CircuitBreaker::getName) ---- + +[[interface-clients]] +== Spring Interface Clients Support + +We provide support for Spring Interface Clients integration through the following configurers: + +- `CircuitBreakerRestClientHttpServiceGroupConfigurer` +- `CircuitBreakerWebClientHttpServiceGroupConfigurer` + +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]. + +When fallback class names are configured using: + +- `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 + String test(); +} +---- + +A matching fallback class could be: + +[source,java] +---- +public class TestServiceFallback { + + public Person test(UUID id); + + public String test(Throwable cause); +} +---- + +[NOTE] +==== +Both the fallback class and its methods must be `public`. +==== + +[TIP] +==== +Fallback methods must *not* include `@HttpExchange` or any related annotations. +==== + +=== Configuring Fallbacks + +Fallback class names are configured via properties as a map: + +- *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 eb6f9da0e..c7a5e3f4b 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 | | 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. |spring.cloud.hypermedia.refresh.fixed-delay | `+++5000+++` | 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..635713a2e --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/AbstractCloudHttpClientServiceProperties.java @@ -0,0 +1,51 @@ +/* + * 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.HashMap; +import java.util.Map; + +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 Map fallbackClassNames = new HashMap<>(); + + public Map getFallbackClassNames() { + return fallbackClassNames; + } + + public void setFallbackClassNames(Map fallbackClassNames) { + this.fallbackClassNames = fallbackClassNames; + } + +} 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..94581d193 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CloudHttpClientServiceProperties.java @@ -0,0 +1,53 @@ +/* + * 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; +import java.util.Map; + +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 { + + /** + * Maps properties to groups by group name. + */ + 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/CommonsClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/CommonsClientAutoConfiguration.java index 3d9f4c788..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 @@ -31,6 +31,12 @@ import org.springframework.boot.health.contributor.HealthIndicator; 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.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; @@ -39,6 +45,8 @@ 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; +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} for Spring Cloud Commons Client. @@ -49,8 +57,43 @@ * @author Omer Naci Soydemir */ @Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(CloudHttpClientServiceProperties.class) public class CommonsClientAutoConfiguration { + @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 { + + @Bean + public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClientConfigurer( + CloudHttpClientServiceProperties properties, CircuitBreakerFactory circuitBreakerFactory) { + return new CircuitBreakerRestClientHttpServiceGroupConfigurer(properties, circuitBreakerFactory); + } + + } + + @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 { + + @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) 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 new file mode 100644 index 000000000..3c109cc9f --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecorator.java @@ -0,0 +1,147 @@ +/* + * 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.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.cloud.client.circuitbreaker.CircuitBreaker; +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; + +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.createProxies; +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 cause of failure 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 { + + private static final Log LOG = LogFactory.getLog(CircuitBreakerAdapterDecorator.class); + + private final CircuitBreaker circuitBreaker; + + private final Map> fallbackClasses; + + private volatile Map fallbackProxies; + + public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker, + Map> fallbackClasses) { + super(delegate); + this.circuitBreaker = circuitBreaker; + this.fallbackClasses = fallbackClasses; + } + + @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); + } + + // Visible for tests + CircuitBreaker getCircuitBreaker() { + return circuitBreaker; + } + + // Visible for tests + Map> getFallbackClasses() { + return fallbackClasses; + } + + @SuppressWarnings("unchecked") + private 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; + } + } + + // Visible for tests + Function createFallbackHandler(HttpRequestValues requestValues) { + return throwable -> getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); + } + + private Map getFallbackProxies() { + if (fallbackProxies == null) { + synchronized (this) { + if (fallbackProxies == null) { + fallbackProxies = createProxies(fallbackClasses); + } + } + } + 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 new file mode 100644 index 000000000..164b13777 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerConfigurerUtils.java @@ -0,0 +1,177 @@ +/* + * 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; +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; +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.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. + * + * @author Olga Maciaszek-Sharma + * @since 5.0.0 + */ +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 Map> resolveFallbackClasses(Map fallbackClassNames) { + return fallbackClassNames.entrySet() + .stream() + .collect(Collectors.toMap(java.util.Map.Entry::getKey, entry -> resolveFallbackClass(entry.getValue()))); + } + + @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, Map fallbackProxies, + Map> fallbackClasses) { + Map attributes = requestValues.getAttributes(); + 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.getOrDefault(declaringClassName, + fallbackProxies.get(DEFAULT_FALLBACK_KEY)); + 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); + } + } + + 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(); + 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/CircuitBreakerRequestValueProcessor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java new file mode 100644 index 000000000..3735d7d1c --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRequestValueProcessor.java @@ -0,0 +1,78 @@ +/* + * 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.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.MethodParameter; +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 { + + /** + * 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"; + + /** + * Spring Cloud-specific attribute name for storing method return types. + */ + 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) { + 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()); + 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 new file mode 100644 index 000000000..bf77116d6 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurer.java @@ -0,0 +1,87 @@ +/* + * 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.Map; + +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; + +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.resolveFallbackClasses; + +/** + * 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 { + + // Make sure Boot's configurers run before + private static final int ORDER = 15; + + private static final Log LOG = LogFactory.getLog(CircuitBreakerRestClientHttpServiceGroupConfigurer.class); + + private final 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); + Map fallbackClassNames = (groupProperties != null) ? groupProperties.getFallbackClassNames() + : clientServiceProperties.getFallbackClassNames(); + if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { + return; + } + Map> fallbackClasses = resolveFallbackClasses(fallbackClassNames); + + factoryBuilder.httpRequestValuesProcessor(new CircuitBreakerRequestValueProcessor()); + + factoryBuilder + .exchangeAdapterDecorator(httpExchangeAdapter -> new CircuitBreakerAdapterDecorator(httpExchangeAdapter, + buildCircuitBreaker(groupName), fallbackClasses)); + }); + } + + 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/CircuitBreakerWebClientHttpServiceGroupConfigurer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java new file mode 100644 index 000000000..5eb1d7c54 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurer.java @@ -0,0 +1,96 @@ +/* + * 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.Map; + +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.resolveFallbackClasses; + +/** + * 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 { + + // Make sure Boot's configurers run before + private static final int ORDER = 16; + + 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); + Map fallbackClassNames = (groupProperties != null) ? groupProperties.getFallbackClassNames() + : null; + if (fallbackClassNames == null || fallbackClassNames.isEmpty()) { + return; + } + 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), fallbackClasses); + }); + }); + } + + 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/FallbackProxyCreationException.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java new file mode 100644 index 000000000..a81b964c0 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/FallbackProxyCreationException.java @@ -0,0 +1,30 @@ +/* + * 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; + +/** + * Exception thrown when a fallback proxy cannot be created. + * + * @author Olga Maciaszek-Sharma + * @since 5.0.0 + */ +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 new file mode 100644 index 000000000..75e22555d --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecorator.java @@ -0,0 +1,238 @@ +/* + * 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.Map; +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.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.HttpExchangeAdapterDecorator; +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.createProxies; +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 cause of failure 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 { + + private static final Log LOG = LogFactory.getLog(ReactiveCircuitBreakerAdapterDecorator.class); + + private final ReactiveCircuitBreaker reactiveCircuitBreaker; + + private final CircuitBreaker circuitBreaker; + + private final Map> fallbackClasses; + + private volatile Map fallbackProxies; + + public ReactiveCircuitBreakerAdapterDecorator(ReactorHttpExchangeAdapter delegate, + ReactiveCircuitBreaker reactiveCircuitBreaker, CircuitBreaker circuitBreaker, + Map> fallbackClasses) { + super(delegate); + this.reactiveCircuitBreaker = reactiveCircuitBreaker; + this.circuitBreaker = circuitBreaker; + this.fallbackClasses = fallbackClasses; + } + + @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); + } + + @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), + createBodyMonoFallbackHandler(requestValues)); + } + + // Visible for tests + Function createFallbackHandler(HttpRequestValues requestValues) { + 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, getFallbackProxies(), fallbackClasses)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); + if (fallback == null) { + return Mono.empty(); + } + return castIfPossible(Mono.just(fallback)); + }; + } + + Function> createBodyFluxFallbackHandler(HttpRequestValues requestValues) { + if (((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME))) + .equals(Flux.class)) { + return throwable -> castIfPossible( + getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); + + if (fallback == null) { + return Flux.empty(); + } + return castIfPossible(Flux.just(fallback)); + }; + } + + Function> createHttpHeadersMonoFallbackHandler(HttpRequestValues requestValues) { + if ((requestValues.getAttributes().get(CircuitBreakerRequestValueProcessor.RETURN_TYPE_ATTRIBUTE_NAME)) + .equals(Mono.class)) { + return throwable -> castIfPossible( + getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses)); + } + return throwable -> { + Object fallback = getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses); + if (fallback == null) { + return Mono.empty(); + } + return castIfPossible(Mono.just(fallback)); + }; + } + + // Visible for tests + ReactiveCircuitBreaker getReactiveCircuitBreaker() { + return reactiveCircuitBreaker; + } + + // Visible for tests + CircuitBreaker getCircuitBreaker() { + return circuitBreaker; + } + + // Visible for tests + Map> getFallbackClasses() { + return fallbackClasses; + } + + private Map getFallbackProxies() { + if (fallbackProxies == null) { + synchronized (this) { + if (fallbackProxies == null) { + fallbackProxies = createProxies(fallbackClasses); + } + } + } + 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 new file mode 100644 index 000000000..6363fa072 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerAdapterDecoratorTests.java @@ -0,0 +1,131 @@ +/* + * 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; +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.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.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; + +/** + * Tests for {@link CircuitBreakerAdapterDecorator}. + * + * @author Olga Maciaszek-Sharma + */ +class CircuitBreakerAdapterDecoratorTests { + + private final HttpExchangeAdapter adapter = mock(HttpExchangeAdapter.class); + + private final CircuitBreaker circuitBreaker = mock(CircuitBreaker.class); + + 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, + Map.of(DEFAULT_FALLBACK_KEY, Fallbacks.class, UnusedTestService.class.getCanonicalName(), + EmptyFallbacks.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 }); + 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); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo("testDescription: 5"); + } + + @Test + void shouldCreateFallbackHandlerFromPerClassFallbackClassNames() { + 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<>(); + 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.getCanonicalName()); + 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 }); + 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); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")); + + assertThat(fallback).isEqualTo("java.lang.Throwable: test! testDescription: 5"); + } + + @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) + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + } + +} 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 new file mode 100644 index 000000000..6c55b7169 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerRestClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,194 @@ +/* + * 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.Collections; +import java.util.HashSet; +import java.util.Map; +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.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; +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; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; + +/** + * 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.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, 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(); + CircuitBreakerAdapterDecorator decorator = (CircuitBreakerAdapterDecorator) captured + .apply(new TestHttpExchangeAdapter()); + assertThat(decorator.getCircuitBreaker()).isNotNull(); + assertThat(decorator.getFallbackClasses().get(DEFAULT_FALLBACK_KEY)).isAssignableFrom(Fallbacks.class); + } + + @Test + void shouldThrowExceptionWhenCantLoadClass() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, "org.test.Fallback")); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerRestClientHttpServiceGroupConfigurer configurer = new CircuitBreakerRestClientHttpServiceGroupConfigurer( + clientServiceProperties, circuitBreakerFactory); + + assertThatIllegalStateException().isThrownBy(() -> configurer.configureGroups(groups)); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddDecoratorWhenFallbackClassNamesNullOrEmpty(Map fallbackClassNames) { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassNames(fallbackClassNames); + 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."); + } + + } + +} 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..48fa28744 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/CircuitBreakerWebClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,256 @@ +/* + * 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; +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; + +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; +import static org.springframework.cloud.client.circuitbreaker.httpservice.CircuitBreakerConfigurerUtils.DEFAULT_FALLBACK_KEY; + +/** + * Tests for {@link CircuitBreakerWebClientHttpServiceGroupConfigurer}. + * + * @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 + "-reactive")) + .thenReturn(mock(ReactiveCircuitBreaker.class)); + } + + @SuppressWarnings("unchecked") + @Test + void shouldAddCircuitBreakerAdapterDecorator() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, 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.getFallbackClasses().get(DEFAULT_FALLBACK_KEY)).isAssignableFrom(Fallbacks.class); + } + + @Test + void shouldThrowExceptionWhenCantLoadClass() { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassNames(Collections.singletonMap(DEFAULT_FALLBACK_KEY, "org.test.Fallback")); + clientServiceProperties.getGroup().put(GROUP_NAME, group); + CircuitBreakerWebClientHttpServiceGroupConfigurer configurer = new CircuitBreakerWebClientHttpServiceGroupConfigurer( + clientServiceProperties, reactiveCircuitBreakerFactory, circuitBreakerFactory); + + assertThatIllegalStateException().isThrownBy(() -> configurer.configureGroups(groups)); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldNotAddDecoratorWhenFallbackClassNamesNullOrEmpty(Map fallbackClassNames) { + CloudHttpClientServiceProperties.Group group = new CloudHttpClientServiceProperties.Group(); + group.setFallbackClassNames(fallbackClassNames); + 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."); + } + + } + +} 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/Fallbacks.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java new file mode 100644 index 000000000..b6061fab7 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/Fallbacks.java @@ -0,0 +1,63 @@ +/* + * 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.Collections; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; + +/** + * Test fallback class. + * + * @author Olga Maciaszek-Sharma + */ +public class Fallbacks { + + public void post(String test) { + System.err.println("Fallback String: " + test); + } + + public String test(String description, Integer value) { + return description + ": " + value; + } + + public String testThrowable(Throwable throwable, String description, Integer value) { + 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..a85470ed1 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/circuitbreaker/httpservice/ReactiveCircuitBreakerAdapterDecoratorTests.java @@ -0,0 +1,296 @@ +/* + * 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.assertThatCode; +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.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; + +/** + * 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); + + // Also verifies class fallback won't override default fallback for other classes + private final ReactiveCircuitBreakerAdapterDecorator decorator = new ReactiveCircuitBreakerAdapterDecorator(adapter, + reactiveCircuitBreaker, circuitBreaker, Map.of(DEFAULT_FALLBACK_KEY, Fallbacks.class, + UnusedTestService.class.getCanonicalName(), EmptyFallbacks.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 }); + 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); + + 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 }); + 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); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isEqualTo(TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @Test + void shouldCreateBodyMonoFallbackHandlerFromPerClassFallbackClassNames() { + 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<>(); + 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.getCanonicalName()); + 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(() -> { + 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); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + 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); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + 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); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + 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(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); + + 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); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + 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); + attributes.put(DECLARING_CLASS_ATTRIBUTE_NAME, TestService.class.getCanonicalName()); + 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 }); + 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); + + 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 }); + 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); + + 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 }); + 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); + + Object fallback = fallbackHandler.apply(new RuntimeException("test")).block(); + + assertThat(fallback).isEqualTo("java.lang.Throwable: test! " + TEST_DESCRIPTION + ": " + TEST_VALUE); + } + + @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) + .isThrownBy(() -> fallbackHandler.apply(new RuntimeException("test"))); + } + +} 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 { + +} 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 { + +}