diff --git a/README.adoc b/README.adoc index d4e5b749..5110b8b4 100644 --- a/README.adoc +++ b/README.adoc @@ -28,6 +28,8 @@ image:https://api.codacy.com/project/badge/Grade/97b04c4e609c4b4f86b415e4437a648 :project-full-name: Spring Cloud OpenFeign :all: {asterisk}{asterisk} +:core_path: {project-root}/spring-cloud-openfeign-core + This project provides OpenFeign integrations for Spring Boot apps through autoconfiguration and binding to the Spring Environment and other Spring programming model idioms. diff --git a/docs/src/main/asciidoc/_attributes.adoc b/docs/src/main/asciidoc/_attributes.adoc index 882edab8..6efc1231 100644 --- a/docs/src/main/asciidoc/_attributes.adoc +++ b/docs/src/main/asciidoc/_attributes.adoc @@ -14,3 +14,5 @@ :sc-ext: java :project-full-name: Spring Cloud OpenFeign :all: {asterisk}{asterisk} + +:core_path: {project-root}/spring-cloud-openfeign-core diff --git a/docs/src/main/asciidoc/spring-cloud-openfeign.adoc b/docs/src/main/asciidoc/spring-cloud-openfeign.adoc index 65a069d0..c0367d00 100644 --- a/docs/src/main/asciidoc/spring-cloud-openfeign.adoc +++ b/docs/src/main/asciidoc/spring-cloud-openfeign.adoc @@ -15,7 +15,7 @@ To use Feign create an interface and annotate it. It has pluggable annotation support including Feign annotations and JAX-RS annotations. Feign also supports pluggable encoders and decoders. Spring Cloud adds support for Spring MVC annotations and for using the same `HttpMessageConverters` used by default in Spring Web. -Spring Cloud integrates Ribbon and Eureka, as well as Spring Cloud LoadBalancer to provide a load-balanced http client when using Feign. +Spring Cloud integrates Ribbon and Eureka, Spring Cloud CircuitBreaker, as well as Spring Cloud LoadBalancer to provide a load-balanced http client when using Feign. [[netflix-feign-starter]] === How to Include Feign @@ -55,7 +55,7 @@ public interface StoreClient { } ---- -In the `@FeignClient` annotation the String value ("stores" above) is an arbitrary client name, which is used to create either a https://github.com/Netflix/ribbon[Ribbon] load-balancer (see <>) or https://github.com/spring-cloud/spring-cloud-commons/blob/master/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java[Spring Cloud LoadBalancer]. +In the `@FeignClient` annotation the String value ("stores" above) is an arbitrary client name, which is used to create either a https://github.com/Netflix/ribbon[Ribbon] load-balancer (see <> and <>) or https://github.com/spring-cloud/spring-cloud-commons/blob/master/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java[Spring Cloud LoadBalancer]. You can also specify a URL using the `url` attribute (absolute value or just a hostname). The name of the bean in the application context is the fully qualified name of the interface. @@ -119,6 +119,7 @@ Spring Cloud OpenFeign provides the following beans by default for feign (`BeanT * `Logger` feignLogger: `Slf4jLogger` * `Contract` feignContract: `SpringMvcContract` * `Feign.Builder` feignBuilder: `HystrixFeign.Builder` +* `Feign.Builder` feignBuilder: `FeignCircuitBreaker.Builder` * `Client` feignClient: if Ribbon is in the classpath and is enabled it is a `LoadBalancerFeignClient`, otherwise if Spring Cloud LoadBalancer is in the classpath, `FeignBlockingLoadBalancerClient` is used. If none of them is in the classpath, the default feign client is used. @@ -430,6 +431,44 @@ static class HystrixClientFallbackFactory implements FallbackFactory_`. When calling a `@FeignClient` with name `foo` and the called interface method is `bar` then the circuit breaker name will be `foo_bar`. + +[[spring-cloud-feign-circuitbreaker-fallback]] +=== Feign Spring Cloud CircuitBreaker Fallbacks + +Spring Cloud CircuitBreaker supports the notion of a fallback: a default code path that is executed when they circuit is open or there is an error. To enable fallbacks for a given `@FeignClient` set the `fallback` attribute to the class name that implements the fallback. You also need to declare your implementation as a Spring bean. + +[source,java,indent=0] +---- +include::{core_path}/src/test/java/org/springframework/cloud/openfeign/circuitbreaker/CirciutBreakerTests.java[tags=client_with_fallback, indent=0] +---- + +If one needs access to the cause that made the fallback trigger, one can use the `fallbackFactory` attribute inside `@FeignClient`. + +[source,java,indent=0] +---- +include::{core_path}/src/test/java/org/springframework/cloud/openfeign/circuitbreaker/CirciutBreakerTests.java[tags=client_with_fallback_factory, indent=0] +---- + === Feign and `@Primary` When using Feign with Hystrix fallbacks, there are multiple beans in the `ApplicationContext` of the same type. This will cause `@Autowired` to not work because there isn't exactly one bean, or one marked as primary. To work around this, Spring Cloud OpenFeign marks all Feign instances as `@Primary`, so Spring Framework will know which bean to inject. In some cases, this may not be desirable. To turn off this behavior set the `primary` attribute of `@FeignClient` to false. diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FallbackFactory.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FallbackFactory.java new file mode 100644 index 00000000..5effdaee --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FallbackFactory.java @@ -0,0 +1,87 @@ +/* + * Copyright 2013-2020 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.openfeign; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import static feign.Util.checkNotNull; + +/** + * Used to control the fallback given its cause. + * + * Ex. + * + *
+ * {@code
+ * // This instance will be invoked if there are errors of any kind.
+ * FallbackFactory fallbackFactory = cause -> (owner, repo) -> {
+ *   if (cause instanceof FeignException && ((FeignException) cause).status() == 403) {
+ *     return Collections.emptyList();
+ *   } else {
+ *     return Arrays.asList("yogi");
+ *   }
+ * };
+ *
+ * GitHub github = FeignCircuitBreaker.builder()
+ *                             ...
+ *                             .target(GitHub.class, "https://api.github.com", fallbackFactory);
+ * }
+ * 
+ * + * @param the feign interface type + */ +public interface FallbackFactory { + + /** + * Returns an instance of the fallback appropriate for the given cause. + * @param cause cause of an exception. + * @return fallback + */ + T create(Throwable cause); + + final class Default implements FallbackFactory { + + final Log logger; + + final T constant; + + public Default(T constant) { + this(constant, LogFactory.getLog(Default.class)); + } + + Default(T constant, Log logger) { + this.constant = checkNotNull(constant, "fallback"); + this.logger = checkNotNull(logger, "logger"); + } + + @Override + public T create(Throwable cause) { + if (logger.isTraceEnabled()) { + logger.trace("fallback due to: " + cause.getMessage(), cause); + } + return constant; + } + + @Override + public String toString() { + return constant.toString(); + } + + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java index 4f17ac1c..875b7f1a 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java @@ -39,12 +39,15 @@ import org.apache.http.conn.HttpClientConnectionManager; import org.apache.http.impl.client.CloseableHttpClient; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.commons.httpclient.ApacheHttpClientConnectionManagerFactory; import org.springframework.cloud.commons.httpclient.ApacheHttpClientFactory; import org.springframework.cloud.commons.httpclient.OkHttpClientConnectionPoolFactory; @@ -83,6 +86,19 @@ public class FeignAutoConfiguration { return context; } + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingClass({ "feign.hystrix.HystrixFeign", + "org.springframework.cloud.client.circuitbreaker.CircuitBreaker" }) + protected static class DefaultFeignTargeterConfiguration { + + @Bean + @ConditionalOnMissingBean + public Targeter feignTargeter() { + return new DefaultTargeter(); + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(name = "feign.hystrix.HystrixFeign") protected static class HystrixFeignTargeterConfiguration { @@ -96,15 +112,24 @@ public class FeignAutoConfiguration { } @Configuration(proxyBeanMethods = false) - @ConditionalOnMissingClass("feign.hystrix.HystrixFeign") - protected static class DefaultFeignTargeterConfiguration { + @ConditionalOnClass(CircuitBreaker.class) + @ConditionalOnProperty("feign.circuitbreaker.enabled") + protected static class CircuitBreakerPresentFeignTargeterConfiguration { @Bean - @ConditionalOnMissingBean - public Targeter feignTargeter() { + @ConditionalOnMissingBean(CircuitBreakerFactory.class) + public Targeter defaultFeignTargeter() { return new DefaultTargeter(); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(CircuitBreakerFactory.class) + public Targeter circuitBreakerFeignTargeter( + CircuitBreakerFactory circuitBreakerFactory) { + return new FeignCircuitBreakerTargeter(circuitBreakerFactory); + } + } // the following configuration is for alternate feign clients if diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreaker.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreaker.java new file mode 100644 index 00000000..8d306890 --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreaker.java @@ -0,0 +1,93 @@ +/* + * Copyright 2013-2020 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.openfeign; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +import feign.Feign; +import feign.InvocationHandlerFactory; +import feign.Target; + +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; + +/** + * Allows Feign interfaces to work with {@link CircuitBreaker}. + * + * @author Marcin Grzejszczak + * @since 3.0.0 + */ +public final class FeignCircuitBreaker { + + private FeignCircuitBreaker() { + throw new IllegalStateException("Don't instantiate a utility class"); + } + + /** + * @return builder for Feign CircuitBreaker integration + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for Feign CircuitBreaker integration. + */ + public static final class Builder extends Feign.Builder { + + private CircuitBreakerFactory circuitBreakerFactory; + + private String feignClientName; + + Builder circuitBreakerFactory(CircuitBreakerFactory circuitBreakerFactory) { + this.circuitBreakerFactory = circuitBreakerFactory; + return this; + } + + Builder feignClientName(String feignClientName) { + this.feignClientName = feignClientName; + return this; + } + + public T target(Target target, T fallback) { + return build( + fallback != null ? new FallbackFactory.Default(fallback) : null) + .newInstance(target); + } + + public T target(Target target, + FallbackFactory fallbackFactory) { + return build(fallbackFactory).newInstance(target); + } + + public Feign build(final FallbackFactory nullableFallbackFactory) { + super.invocationHandlerFactory(new InvocationHandlerFactory() { + @Override + public InvocationHandler create(Target target, + Map dispatch) { + return new FeignCircuitBreakerInvocationHandler(circuitBreakerFactory, + feignClientName, target, dispatch, nullableFallbackFactory); + } + }); + return super.build(); + } + + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerDisabledConditions.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerDisabledConditions.java new file mode 100644 index 00000000..77e3cf1a --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerDisabledConditions.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013-2020 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.openfeign; + +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +class FeignCircuitBreakerDisabledConditions extends AnyNestedCondition { + + FeignCircuitBreakerDisabledConditions() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("org.springframework.cloud.client.circuitbreaker.CircuitBreaker") + static class CircuitBreakerClassMissing { + + } + + @ConditionalOnProperty(value = "feign.circuitbreaker.enabled", havingValue = "false", + matchIfMissing = true) + static class CircuitBreakerDisabled { + + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerInvocationHandler.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerInvocationHandler.java new file mode 100644 index 00000000..6dbb5d43 --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerInvocationHandler.java @@ -0,0 +1,152 @@ +/* + * Copyright 2013-2020 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.openfeign; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import feign.InvocationHandlerFactory; +import feign.Target; + +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; + +import static feign.Util.checkNotNull; + +class FeignCircuitBreakerInvocationHandler implements InvocationHandler { + + private final CircuitBreakerFactory factory; + + private final String feignClientName; + + private final Target target; + + private final Map dispatch; + + private final FallbackFactory nullableFallbackFactory; + + private final Map fallbackMethodMap; + + FeignCircuitBreakerInvocationHandler(CircuitBreakerFactory factory, + String feignClientName, Target target, + Map dispatch, + FallbackFactory nullableFallbackFactory) { + this.factory = factory; + this.feignClientName = feignClientName; + this.target = checkNotNull(target, "target"); + this.dispatch = checkNotNull(dispatch, "dispatch"); + this.fallbackMethodMap = toFallbackMethod(dispatch); + this.nullableFallbackFactory = nullableFallbackFactory; + } + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + throws Throwable { + // early exit if the invoked method is from java.lang.Object + // code is the same as ReflectiveFeign.FeignInvocationHandler + if ("equals".equals(method.getName())) { + try { + Object otherHandler = args.length > 0 && args[0] != null + ? Proxy.getInvocationHandler(args[0]) : null; + return equals(otherHandler); + } + catch (IllegalArgumentException e) { + return false; + } + } + else if ("hashCode".equals(method.getName())) { + return hashCode(); + } + else if ("toString".equals(method.getName())) { + return toString(); + } + String circuitName = this.feignClientName + "_" + method.getName(); + CircuitBreaker circuitBreaker = this.factory.create(circuitName); + Supplier supplier = asSupplier(method, args); + if (this.nullableFallbackFactory != null) { + Function fallbackFunction = throwable -> { + Object fallback = this.nullableFallbackFactory.create(throwable); + try { + return this.fallbackMethodMap.get(method).invoke(fallback, args); + } + catch (Exception e) { + throw new IllegalStateException(e); + } + }; + return circuitBreaker.run(supplier, fallbackFunction); + } + return circuitBreaker.run(supplier); + } + + private Supplier asSupplier(final Method method, final Object[] args) { + return () -> { + try { + return this.dispatch.get(method).invoke(args); + } + catch (RuntimeException throwable) { + throw throwable; + } + catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + }; + } + + /** + * If the method param of InvocationHandler.invoke is not accessible, i.e in a + * package-private interface, the fallback call will cause of access restrictions. But + * methods in dispatch are copied methods. So setting access to dispatch method + * doesn't take effect to the method in InvocationHandler.invoke. Use map to store a + * copy of method to invoke the fallback to bypass this and reducing the count of + * reflection calls. + * @return cached methods map for fallback invoking + */ + static Map toFallbackMethod( + Map dispatch) { + Map result = new LinkedHashMap(); + for (Method method : dispatch.keySet()) { + method.setAccessible(true); + result.put(method, method); + } + return result; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof FeignCircuitBreakerInvocationHandler) { + FeignCircuitBreakerInvocationHandler other = (FeignCircuitBreakerInvocationHandler) obj; + return this.target.equals(other.target); + } + return false; + } + + @Override + public int hashCode() { + return this.target.hashCode(); + } + + @Override + public String toString() { + return this.target.toString(); + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerTargeter.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerTargeter.java new file mode 100644 index 00000000..eedc735b --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignCircuitBreakerTargeter.java @@ -0,0 +1,97 @@ +/* + * Copyright 2013-2020 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.openfeign; + +import feign.Feign; +import feign.Target; + +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.util.StringUtils; + +@SuppressWarnings("unchecked") +class FeignCircuitBreakerTargeter implements Targeter { + + private final CircuitBreakerFactory circuitBreakerFactory; + + FeignCircuitBreakerTargeter(CircuitBreakerFactory circuitBreakerFactory) { + this.circuitBreakerFactory = circuitBreakerFactory; + } + + @Override + public T target(FeignClientFactoryBean factory, Feign.Builder feign, + FeignContext context, Target.HardCodedTarget target) { + if (!(feign instanceof FeignCircuitBreaker.Builder)) { + return feign.target(target); + } + FeignCircuitBreaker.Builder builder = (FeignCircuitBreaker.Builder) feign; + String name = !StringUtils.hasText(factory.getContextId()) ? factory.getName() + : factory.getContextId(); + Class fallback = factory.getFallback(); + if (fallback != void.class) { + return targetWithFallback(name, context, target, builder, fallback); + } + Class fallbackFactory = factory.getFallbackFactory(); + if (fallbackFactory != void.class) { + return targetWithFallbackFactory(name, context, target, builder, + fallbackFactory); + } + return builder(name, builder).target(target); + } + + private T targetWithFallbackFactory(String feignClientName, FeignContext context, + Target.HardCodedTarget target, FeignCircuitBreaker.Builder builder, + Class fallbackFactoryClass) { + FallbackFactory fallbackFactory = (FallbackFactory) getFromContext( + "fallbackFactory", feignClientName, context, fallbackFactoryClass, + FallbackFactory.class); + return builder(feignClientName, builder).target(target, fallbackFactory); + } + + private T targetWithFallback(String feignClientName, FeignContext context, + Target.HardCodedTarget target, FeignCircuitBreaker.Builder builder, + Class fallback) { + T fallbackInstance = getFromContext("fallback", feignClientName, context, + fallback, target.type()); + return builder(feignClientName, builder).target(target, fallbackInstance); + } + + private T getFromContext(String fallbackMechanism, String feignClientName, + FeignContext context, Class beanType, Class targetType) { + Object fallbackInstance = context.getInstance(feignClientName, beanType); + if (fallbackInstance == null) { + throw new IllegalStateException(String.format( + "No " + fallbackMechanism + + " instance of type %s found for feign client %s", + beanType, feignClientName)); + } + + if (!targetType.isAssignableFrom(beanType)) { + throw new IllegalStateException(String.format("Incompatible " + + fallbackMechanism + + " instance. Fallback/fallbackFactory of type %s is not assignable to %s for feign client %s", + beanType, targetType, feignClientName)); + } + return (T) fallbackInstance; + } + + private FeignCircuitBreaker.Builder builder(String feignClientName, + FeignCircuitBreaker.Builder builder) { + return builder.circuitBreakerFactory(this.circuitBreakerFactory) + .feignClientName(feignClientName); + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClient.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClient.java index 7bdc6a3c..965e86d0 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClient.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClient.java @@ -109,6 +109,7 @@ public @interface FeignClient { * annotated by {@link FeignClient}. The fallback factory must be a valid spring bean. * * @see feign.hystrix.FallbackFactory for details. + * @see FallbackFactory for details. * @return fallback factory for the specified Feign client interface */ Class fallbackFactory() default void.class; diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java index 46cf1474..1eb6c663 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java @@ -35,12 +35,15 @@ import feign.optionals.OptionalDecoder; import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.web.SpringDataWebProperties; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; import org.springframework.cloud.openfeign.clientconfig.FeignClientConfigurer; import org.springframework.cloud.openfeign.support.AbstractFormWriter; import org.springframework.cloud.openfeign.support.PageJacksonModule; @@ -212,4 +215,26 @@ public class FeignClientsConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(CircuitBreaker.class) + @ConditionalOnProperty("feign.circuitbreaker.enabled") + protected static class CircuitBreakerPresentFeignBuilderConfiguration { + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean({ Feign.Builder.class, CircuitBreakerFactory.class }) + public Feign.Builder defaultFeignBuilder(Retryer retryer) { + return Feign.builder().retryer(retryer); + } + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + @ConditionalOnBean(CircuitBreakerFactory.class) + public Feign.Builder circuitBreakerFeignBuilder() { + return FeignCircuitBreaker.builder(); + } + + } + } diff --git a/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 23a732c1..cd91eaa9 100644 --- a/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-openfeign-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -8,6 +8,12 @@ "description": "If true, an OpenFeign client will be wrapped with a Hystrix circuit breaker.", "defaultValue": "false" }, + { + "name": "feign.circuitbreaker.enabled", + "type": "java.lang.Boolean", + "description": "If true, an OpenFeign client will be wrapped with a Spring Cloud CircuitBreaker circuit breaker.", + "defaultValue": "false" + }, { "name": "feign.httpclient.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/circuitbreaker/CirciutBreakerTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/circuitbreaker/CirciutBreakerTests.java new file mode 100644 index 00000000..0555aafe --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/circuitbreaker/CirciutBreakerTests.java @@ -0,0 +1,317 @@ +/* + * Copyright 2013-2020 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.openfeign.circuitbreaker; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.cloud.client.circuitbreaker.ConfigBuilder; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.test.NoSecurityConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Component; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.SocketUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Spencer Gibb + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = CirciutBreakerTests.Application.class, + webEnvironment = WebEnvironment.DEFINED_PORT, + value = { "spring.application.name=springcircuittest", "spring.jmx.enabled=false", + "feign.circuitbreaker.enabled=true" }) +@DirtiesContext +public class CirciutBreakerTests { + + @Autowired + MyCircuitBreaker myCircuitBreaker; + + @Autowired + TestClient testClient; + + @Autowired + TestClientWithFactory testClientWithFactory; + + @LocalServerPort + private int port = 0; + + @BeforeAll + public static void beforeClass() { + System.setProperty("server.port", + String.valueOf(SocketUtils.findAvailableTcpPort())); + } + + @AfterAll + public static void afterClass() { + System.clearProperty("server.port"); + } + + @Before + public void setup() { + this.myCircuitBreaker.clear(); + } + + @Test + public void testSimpleTypeWithFallback() { + Hello hello = testClient.getHello(); + + assertThat(hello).as("hello was null").isNotNull(); + assertThat(hello).as("first hello didn't match") + .isEqualTo(new Hello("hello world 1")); + assertThat(myCircuitBreaker.runWasCalled).as("Circuit Breaker was called") + .isTrue(); + } + + @Test + public void test404WithFallback() { + assertThat(testClient.getException()).isEqualTo("Fixed response"); + } + + @Test + public void testSimpleTypeWithFallbackFactory() { + Hello hello = testClientWithFactory.getHello(); + + assertThat(hello).as("hello was null").isNotNull(); + assertThat(hello).as("first hello didn't match") + .isEqualTo(new Hello("hello world 1")); + assertThat(myCircuitBreaker.runWasCalled).as("Circuit Breaker was called") + .isTrue(); + } + + @Test + public void test404WithFallbackFactory() { + assertThat(testClientWithFactory.getException()).isEqualTo("Fixed response"); + } + + // tag::client_with_fallback[] + @FeignClient(name = "test", url = "http://localhost:${server.port}/", + fallback = Fallback.class) + protected interface TestClient { + + @RequestMapping(method = RequestMethod.GET, value = "/hello") + Hello getHello(); + + @RequestMapping(method = RequestMethod.GET, value = "/hellonotfound") + String getException(); + + } + + @Component + static class Fallback implements TestClient { + + @Override + public Hello getHello() { + throw new NoFallbackAvailableException("Boom!", new RuntimeException()); + } + + @Override + public String getException() { + return "Fixed response"; + } + + } + // end::client_with_fallback[] + + // tag::client_with_fallback_factory[] + @FeignClient(name = "testClientWithFactory", url = "http://localhost:${server.port}/", + fallbackFactory = TestFallbackFactory.class) + protected interface TestClientWithFactory { + + @RequestMapping(method = RequestMethod.GET, value = "/hello") + Hello getHello(); + + @RequestMapping(method = RequestMethod.GET, value = "/hellonotfound") + String getException(); + + } + + @Component + static class TestFallbackFactory implements FallbackFactory { + + @Override + public FallbackWithFactory create(Throwable cause) { + return new FallbackWithFactory(); + } + + } + + static class FallbackWithFactory implements TestClientWithFactory { + + @Override + public Hello getHello() { + throw new NoFallbackAvailableException("Boom!", new RuntimeException()); + } + + @Override + public String getException() { + return "Fixed response"; + } + + } + // end::client_with_fallback_factory[] + + public static class Hello { + + private String message; + + public Hello() { + } + + public Hello(String message) { + this.message = message; + } + + public String getMessage() { + return this.message; + } + + public void setMessage(String message) { + this.message = message; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Hello that = (Hello) o; + return Objects.equals(this.message, that.message); + } + + @Override + public int hashCode() { + return Objects.hash(this.message); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + @RestController + @EnableFeignClients(clients = { TestClient.class, TestClientWithFactory.class }) + @Import(NoSecurityConfiguration.class) + protected static class Application implements TestClient { + + static final Log log = LogFactory.getLog(Application.class); + + @Bean + MyCircuitBreaker myCircuitBreaker() { + return new MyCircuitBreaker(); + } + + @Bean + CircuitBreakerFactory circuitBreakerFactory(MyCircuitBreaker myCircuitBreaker) { + return new CircuitBreakerFactory() { + @Override + public CircuitBreaker create(String id) { + log.info("Creating a circuit breaker with id [" + id + "]"); + return myCircuitBreaker; + } + + @Override + protected ConfigBuilder configBuilder(String id) { + return Object::new; + } + + @Override + public void configureDefault(Function defaultConfiguration) { + + } + }; + } + + @Override + public Hello getHello() { + return new Hello("hello world 1"); + } + + @Override + public String getException() { + throw new IllegalStateException("BOOM!"); + } + + @Bean + Fallback fallback() { + return new Fallback(); + } + + @Bean + TestFallbackFactory testFallbackFactory() { + return new TestFallbackFactory(); + } + + } + + static class MyCircuitBreaker implements CircuitBreaker { + + AtomicBoolean runWasCalled = new AtomicBoolean(); + + @Override + public T run(Supplier toRun) { + this.runWasCalled.set(true); + return toRun.get(); + } + + @Override + public T run(Supplier toRun, Function fallback) { + try { + return run(toRun); + } + catch (Throwable throwable) { + return fallback.apply(throwable); + } + } + + public void clear() { + this.runWasCalled.set(false); + } + + } + +}