Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
053deb5
Init draft.
OlgaMaciaszek Jun 16, 2025
8606c70
Merge remote-tracking branch 'origin/main' into add-interface-clients…
OlgaMaciaszek Jun 18, 2025
6dfd121
Handle missing fallbacks. Fix configuration.
OlgaMaciaszek Jun 18, 2025
5c1e91f
Create proxy to invoke fallback method.
OlgaMaciaszek Jun 23, 2025
12352c3
Handle proxy target instantiation exceptions.
OlgaMaciaszek Jun 23, 2025
1d9b8a6
Add decorator.
OlgaMaciaszek Jul 1, 2025
034aae7
Switch to using decorator.
OlgaMaciaszek Jul 1, 2025
397ff05
Refactor CircuitBreakerRestClientAdapterDecorator.
OlgaMaciaszek Jul 1, 2025
de9b5ce
Refactor CircuitBreakerRestClientAdapterDecorator and CircuitBreakerR…
OlgaMaciaszek Jul 2, 2025
4c3dac6
Handle empty or null fallbackClassName. Add tests.
OlgaMaciaszek Jul 3, 2025
a77fa3e
Repackage. Add decorator tests.
OlgaMaciaszek Jul 3, 2025
19c065b
Merge remote-tracking branch 'origin/main' into add-interface-clients…
OlgaMaciaszek Jul 3, 2025
d449a70
Add more tests. Refactor.
OlgaMaciaszek Jul 7, 2025
fc81ce7
Draft reactive implementation.
OlgaMaciaszek Jul 8, 2025
34d34fb
Add tests for reactive implementation.
OlgaMaciaszek Jul 9, 2025
200dc59
Fix checkstyle. Adjust to changes in FW. Match fallback method by ret…
OlgaMaciaszek Jul 9, 2025
bd3dbaa
Fix configuration.
OlgaMaciaszek Jul 15, 2025
6dc3bb2
Fix reactive implementation and add more tests.
OlgaMaciaszek Jul 15, 2025
3223d8c
Refactor.
OlgaMaciaszek Jul 16, 2025
f7d0645
Add javadocs.
OlgaMaciaszek Jul 16, 2025
6a290b6
Add property flag to autoconfiguration.
OlgaMaciaszek Jul 17, 2025
d8d6636
Reword javadoc.
OlgaMaciaszek Jul 17, 2025
15dfe57
Add docs.
OlgaMaciaszek Jul 17, 2025
ac02fef
Allow using multiple fallback classes.
OlgaMaciaszek Jul 17, 2025
1fe805b
Fix the logic for resolving fallback from multiple fallback classes.
OlgaMaciaszek Jul 18, 2025
9dd9b1b
Add javadoc and regenerate configprops.
OlgaMaciaszek Jul 18, 2025
dcc8d8a
Allow defining default fallback for all groups. Add more tests.
OlgaMaciaszek Jul 18, 2025
a736cdd
Use String map keys. Handle default settings for all groups. Refactor…
OlgaMaciaszek Jul 18, 2025
ca3ab9f
Init draft.
OlgaMaciaszek Jul 18, 2025
f0e2a29
Switch to using only annotations.
OlgaMaciaszek Jul 21, 2025
3ff7bf0
Rename annotation to `@HttpServiceFallback`. Update documentation.
OlgaMaciaszek Jul 22, 2025
383c130
Rename `@HttpServiceFallback` attributes.
OlgaMaciaszek Jul 24, 2025
6c0c3c5
Update docs.
OlgaMaciaszek Jul 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 107 additions & 5 deletions docs/modules/ROOT/pages/spring-cloud-circuitbreaker.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.

Expand All @@ -102,3 +103,104 @@ Customizer.once(circuitBreaker -> {
.onStateTransition(event -> log.info("{}: {}", event.getCircuitBreakerName(), event.getStateTransition()));
}, CircuitBreaker::getName)
----

[[interface-clients]]
== Spring Interface Clients Support

Spring Cloud provides 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 classes are configured using the `@HttpServiceFallbackAnnotation`,
CircuitBreaker adapter decorators are added:
- `CircuitBreakerAdapterDecorator` is used with `RestClient`
- `ReactiveCircuitBreakerAdapterDecorator` is used with `WebClient`

[NOTE]
====
You can disable CircuitBreaker integration for HTTP service clients by setting the appropriate property:

- For blocking (`RestClient`) clients: `spring.cloud.circuitbreaker.http-services.enabled=false`
- For reactive (`WebClient`) clients: `spring.cloud.circuitbreaker.reactive-http-services.enabled=false`

This prevents CircuitBreaker decorators from being applied to interface-based HTTP client groups.
====

=== Declaring Fallbacks with Annotations

Fallbacks are configured using the `@HttpServiceFallback` annotation on configuration classes.
This annotation allows you to declare:

- The fallback implementation class (via `value`)
- The service interfaces the fallback supports (via `forService`, optional)
- The group the fallback applies to (via `forGroup`, optional)

Multiple `@HttpServiceFallback` annotations can be declared on the same class using Java’s `@Repeatable` annotation mechanism.
If no group is specified, the fallback applies to all groups that do not have an explicit per-group fallback for the given service interfaces.

Fallback classes are resolved using the following precedence:

. A fallback class with both matching `forService` and `forGroup`
. A fallback class with matching `forService` and no `forGroup` (global fallback for service)
. A fallback class with no `forService` or `forGroup` (default for all services in group or globally)

==== Example

[source,java]
----
@HttpServiceFallback(value = DefaultFallbacks.class)
@HttpServiceFallback(value = GroupAndServiceSpecificFallbacks.class, forService = {BillingService.class, ShippingService.class}, forGroup = "billing")
public class MyFallbackConfig {
...
}
----

This configuration results in:

- `DefaultFallbacks` used as a global fallback for all services not explicitly handled
- `GroupAndServiceSpecificFallbacks` used only for `BillingService` and `ShippingService` within the `"billing"` group

[NOTE]
====
- The fallback class and its methods must be `public`
- Fallback methods must not be annotated with `@HttpExchange`-specific annotations
====

=== 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);
}
----
9 changes: 0 additions & 9 deletions docs/modules/ROOT/partials/_configprops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@

|spring.cloud.compatibility-verifier.compatible-boot-versions | `+++4.0.x+++` | Default accepted versions for the Spring Boot dependency. You can set {@code x} for the patch version if you don't want to specify a concrete value. Example: {@code 3.5.x}
|spring.cloud.compatibility-verifier.enabled | `+++false+++` | Enables creation of Spring Cloud compatibility verification.
|spring.cloud.config.allow-override | `+++true+++` | Flag to indicate that {@link #isOverrideSystemProperties() systemPropertiesOverride} can be used. Set to false to prevent users from changing the default accidentally. Default true.
|spring.cloud.config.initialize-on-context-refresh | `+++false+++` | Flag to initialize bootstrap configuration on context refresh event. Default false.
|spring.cloud.config.override-none | `+++false+++` | Flag to indicate that when {@link #setAllowOverride(boolean) allowOverride} is true, external properties should take lowest priority and should not override any existing property sources (including local config files). Default false. This will only have an effect when using config first bootstrap.
|spring.cloud.config.override-system-properties | `+++true+++` | Flag to indicate that the external properties should override system properties. Default true.
|spring.cloud.decrypt-environment-post-processor.enabled | `+++true+++` | Enable the DecryptEnvironmentPostProcessor.
|spring.cloud.discovery.client.composite-indicator.enabled | `+++true+++` | Enables discovery client composite health indicator.
|spring.cloud.discovery.client.health-indicator.enabled | `+++true+++` |
|spring.cloud.discovery.client.health-indicator.include-description | `+++false+++` |
Expand Down Expand Up @@ -73,11 +68,7 @@
|spring.cloud.loadbalancer.subset.size | `+++100+++` | Max subset size of deterministic subsetting.
|spring.cloud.loadbalancer.x-forwarded.enabled | `+++false+++` | To Enable X-Forwarded Headers.
|spring.cloud.loadbalancer.zone | | Spring Cloud LoadBalancer zone.
|spring.cloud.refresh.additional-property-sources-to-retain | | Additional property sources to retain during a refresh. Typically only system property sources are retained. This property allows property sources, such as property sources created by EnvironmentPostProcessors to be retained as well.
|spring.cloud.refresh.enabled | `+++true+++` | Enables autoconfiguration for the refresh scope and associated features.
|spring.cloud.refresh.extra-refreshable | `+++true+++` | Additional bean names or class names for beans to post process into refresh scope.
|spring.cloud.refresh.never-refreshable | `+++true+++` | Comma separated list of bean names or class names for beans to never be refreshed or rebound.
|spring.cloud.refresh.on-restart.enabled | `+++true+++` | Enable refreshing context on start.
|spring.cloud.service-registry.auto-registration.enabled | `+++true+++` | Whether service auto-registration is enabled. Defaults to true.
|spring.cloud.service-registry.auto-registration.fail-fast | `+++false+++` | Whether startup fails if there is no AutoServiceRegistration. Defaults to false.
|spring.cloud.service-registry.auto-registration.register-management | `+++true+++` | Whether to register the management as a service. Defaults to true.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -51,6 +59,39 @@
@Configuration(proxyBeanMethods = false)
public class CommonsClientAutoConfiguration {

@ConditionalOnClass({ CircuitBreaker.class, RestClientHttpServiceGroupConfigurer.class })
@ConditionalOnBean(CircuitBreakerFactory.class)
@ConditionalOnProperty(value = "spring.cloud.circuitbreaker.http-services.enabled", havingValue = "true",
matchIfMissing = true)
@Configuration(proxyBeanMethods = false)
protected static class CircuitBreakerInterfaceClientsAutoConfiguration {

@Bean
public CircuitBreakerRestClientHttpServiceGroupConfigurer circuitBreakerRestClientConfigurer(
CircuitBreakerFactory<?, ?> circuitBreakerFactory) {
return new CircuitBreakerRestClientHttpServiceGroupConfigurer(circuitBreakerFactory);
}

}

@ConditionalOnClass({ CircuitBreaker.class, ReactiveCircuitBreaker.class,
WebClientHttpServiceGroupConfigurer.class })
@ConditionalOnBean({ CircuitBreakerFactory.class, ReactiveCircuitBreakerFactory.class })
@ConditionalOnProperty(value = "spring.cloud.circuitbreaker.reactive-http-services.enabled", havingValue = "true",
matchIfMissing = true)
@Configuration(proxyBeanMethods = false)
protected static class ReactiveCircuitBreakerInterfaceClientsAutoConfiguration {

@Bean
public CircuitBreakerWebClientHttpServiceGroupConfigurer circuitBreakerWebClientConfigurer(
ReactiveCircuitBreakerFactory<?, ?> reactiveCircuitBreakerFactory,
CircuitBreakerFactory<?, ?> circuitBreakerFactory) {
return new CircuitBreakerWebClientHttpServiceGroupConfigurer(reactiveCircuitBreakerFactory,
circuitBreakerFactory);
}

}

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(HealthIndicator.class)
@EnableConfigurationProperties(DiscoveryClientHealthIndicatorProperties.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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}
* <p>
* 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:
* <ul>
* <li>A method with the same name and argument types as the original method, or</li>
* <li>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.</li>
* </ul>
* 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.
* </p>
*
* @author Olga Maciaszek-Sharma
* @since 5.0.0
* @see HttpServiceFallback
*/
public class CircuitBreakerAdapterDecorator extends HttpExchangeAdapterDecorator {

private static final Log LOG = LogFactory.getLog(CircuitBreakerAdapterDecorator.class);

private final CircuitBreaker circuitBreaker;

private final Map<String, Class<?>> fallbackClasses;

private volatile Map<String, Object> fallbackProxies;

public CircuitBreakerAdapterDecorator(HttpExchangeAdapter delegate, CircuitBreaker circuitBreaker,
Map<String, Class<?>> 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 <T> @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
Object result = circuitBreaker.run(() -> super.exchangeForBody(values, bodyType),
createFallbackHandler(values));
return castIfPossible(result);
}

@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
Object result = circuitBreaker.run(() -> super.exchangeForBodilessEntity(values),
createFallbackHandler(values));
return castIfPossible(result);
}

@Override
public <T> ResponseEntity<T> exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference<T> 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<String, Class<?>> getFallbackClasses() {
return fallbackClasses;
}

@SuppressWarnings("unchecked")
private <T> 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<Throwable, Object> createFallbackHandler(HttpRequestValues requestValues) {
return throwable -> getFallback(requestValues, throwable, getFallbackProxies(), fallbackClasses);
}

private Map<String, Object> getFallbackProxies() {
if (fallbackProxies == null) {
synchronized (this) {
if (fallbackProxies == null) {
fallbackProxies = createProxies(fallbackClasses);
}
}
}
return fallbackProxies;
}

}
Loading
Loading