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 {
+
+}