diff --git a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/channelfactory/AbstractChannelFactory.java b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/channelfactory/AbstractChannelFactory.java index ad6c3d04b..fdf08ba24 100644 --- a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/channelfactory/AbstractChannelFactory.java +++ b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/channelfactory/AbstractChannelFactory.java @@ -24,6 +24,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -48,6 +49,7 @@ import net.devh.boot.grpc.client.config.GrpcChannelProperties; import net.devh.boot.grpc.client.config.GrpcChannelProperties.Security; import net.devh.boot.grpc.client.config.GrpcChannelsProperties; +import net.devh.boot.grpc.client.config.MethodConfig; import net.devh.boot.grpc.client.config.NegotiationType; import net.devh.boot.grpc.client.interceptor.GlobalClientInterceptorRegistry; @@ -56,7 +58,6 @@ * connection pooling and thus needs to be {@link #close() closed} after usage. * * @param The type of builder used by this channel factory. - * * @author Michael (yidongnan@gmail.com) * @author Daniel Theuke (daniel.theuke@heuboe.de) * @since 5/17/16 @@ -168,11 +169,43 @@ protected void configure(final T builder, final String name) { configureSecurity(builder, name); configureLimits(builder, name); configureCompression(builder, name); + configureRetryEnabled(builder, name); for (final GrpcChannelConfigurer channelConfigurer : this.channelConfigurers) { channelConfigurer.accept(builder, name); } } + /** + * Configures the retry options that should be used by the channel. + * + * @param builder The channel builder to configure. + * @param name The name of the client to configure. + */ + protected void configureRetryEnabled(final T builder, final String name) { + final GrpcChannelProperties properties = getPropertiesFor(name); + if (properties.isRetryEnabled()) { + builder.enableRetry(); + // build retry policy by default service config + // TODO: Wrap field in defaultServiceConfig + builder.defaultServiceConfig(buildDefaultServiceConfig(properties)); + } + } + + /** + * Builds the service config object. + * + * @param properties The properties of + * @return The json alike service config. + */ + protected Map buildDefaultServiceConfig(final GrpcChannelProperties properties) { + final Map serviceConfig = new LinkedHashMap<>(); + final List methodConfigList = properties.getMethodConfig(); + if (methodConfigList != null && !methodConfigList.isEmpty()) { + serviceConfig.put("methodConfig", MethodConfig.buildMaps(methodConfigList)); + } + return serviceConfig; + } + /** * Configures the keep alive options that should be used by the channel. * diff --git a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/GrpcChannelProperties.java b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/GrpcChannelProperties.java index 42cdb6ecb..da935a1a2 100644 --- a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/GrpcChannelProperties.java +++ b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/GrpcChannelProperties.java @@ -421,6 +421,43 @@ public void setImmediateConnectTimeout(final Duration immediateConnectTimeout) { // -------------------------------------------------- + private Boolean retryEnabled; + private static final boolean DEFAULT_RETRY_ENABLED = false; + + /** + * Gets whether retry should be enabled. + * + * @return True, if retry should be enabled. False otherwise. + * @see #setRetryEnabled(Boolean) + */ + public boolean isRetryEnabled() { + return this.retryEnabled == null ? DEFAULT_RETRY_ENABLED : this.retryEnabled; + } + + /** + * Set Retry enable + * + * @param retryEnabled Whether retry enabled or null to use the fallback. + * @see ManagedChannelBuilder#enableRetry() + */ + public void setRetryEnabled(final Boolean retryEnabled) { + this.retryEnabled = retryEnabled; + } + + // -------------------------------------------------- + + private List methodConfig; + + public List getMethodConfig() { + return this.methodConfig; + } + + public void setMethodConfig(final List methodConfig) { + this.methodConfig = methodConfig; + } + + // -------------------------------------------------- + private final Security security = new Security(); /** @@ -475,6 +512,13 @@ public void copyDefaultsFrom(final GrpcChannelProperties config) { if (this.immediateConnectTimeout == null) { this.immediateConnectTimeout = config.immediateConnectTimeout; } + if (this.retryEnabled == null) { + this.retryEnabled = config.retryEnabled; + } + if (this.methodConfig == null || this.methodConfig.isEmpty()) { + // TBD: Should we smartly merge the method configs? + this.methodConfig = config.methodConfig == null ? null : MethodConfig.copy(config.methodConfig); + } this.security.copyDefaultsFrom(config.security); } diff --git a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/MethodConfig.java b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/MethodConfig.java new file mode 100644 index 000000000..0ef6cadbf --- /dev/null +++ b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/MethodConfig.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.client.config; + +import static java.util.Objects.requireNonNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import lombok.Data; + + +/** + * The method config for retry policy config. + * + *

+ * For the exact specification refer to: + * A6-client-retries + *

+ * + * @author wushengju + */ +@Data +public class MethodConfig { + + /** + * retry policy config + */ + private RetryPolicyConfig retryPolicy; + /** + * name for list + */ + private List name; + + + /** + * Creates a copy of this instance. + * + * @return The newly created copy. + */ + public MethodConfig copy() { + final MethodConfig copy = new MethodConfig(); + copy.retryPolicy = requireNonNull(this.retryPolicy, "retryPolicy").copy(); + copy.name = NameConfig.copy(this.name); + return copy; + } + + /** + * Creates a copy of the given instances. + * + * @param configs The configs to copy. + * @return The copied instances. + */ + public static List copy(final List configs) { + return requireNonNull(configs, "configs").stream() + .map(MethodConfig::copy) + .collect(Collectors.toList()); + } + + /** + * Builds a json like map from this instance. + * + * @return The json like map representation of this instance. + */ + public Map buildMap() { + final Map map = new LinkedHashMap<>(); + if (this.name != null && !this.name.isEmpty()) { + map.put("name", NameConfig.buildMaps(this.name)); + } + if (this.retryPolicy != null) { + map.put("retryPolicy", this.retryPolicy.buildMap()); + } + return map; + } + + /** + * Builds a json like map from the given instances. + * + * @param configs The configs to convert. + * @return The json like array of maps representation of the instances. + */ + public static List> buildMaps(final List configs) { + return requireNonNull(configs, "configs").stream() + .map(MethodConfig::buildMap) + .collect(Collectors.toList()); + } + +} diff --git a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/NameConfig.java b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/NameConfig.java new file mode 100644 index 000000000..b71810ae8 --- /dev/null +++ b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/NameConfig.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.client.config; + +import static java.util.Objects.requireNonNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import lombok.Data; + +/** + * The name config for service and method. + * + *

+ * If both the service and method name are empty, then this applies to all requests. + *

+ * + *

+ * If only the method name is empty, then this applies to all methods in the given service. + *

+ * + *

+ * For the exact specification refer to: + * A6-client-retries + *

+ * + * @author wushengju + */ +@Data +public class NameConfig { + + /** + * The service name as defined in your proto file. May be empty to match all services, but may never be null. + */ + private String service = ""; + /** + * The method name which you will call. May be empty to match all method within the service, but may never be null. + */ + private String method = ""; + + /** + * Creates a copy of this instance. + * + * @return The newly created copy. + */ + public NameConfig copy() { + final NameConfig copy = new NameConfig(); + copy.service = this.service; + copy.method = this.method; + return copy; + } + + /** + * Creates a copy of the given instances. + * + * @param configs The configs to copy. + * @return The copied instances. + */ + public static List copy(final List configs) { + return requireNonNull(configs, "configs").stream() + .map(NameConfig::copy) + .collect(Collectors.toList()); + } + + /** + * Builds a json like map from this instance. + * + * @return The json like map representation of this instance. + */ + public Map buildMap() { + final Map map = new LinkedHashMap<>(); + map.put("service", this.service); + map.put("method", this.method); + return map; + } + + /** + * Builds a json like map from the given instances. + * + * @param configs The configs to convert. + * @return The json like array of maps representation of the instances. + */ + public static List> buildMaps(final List configs) { + return requireNonNull(configs, "configs").stream() + .map(NameConfig::buildMap) + .collect(Collectors.toList()); + } + +} diff --git a/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/RetryPolicyConfig.java b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/RetryPolicyConfig.java new file mode 100644 index 000000000..22f90ebca --- /dev/null +++ b/grpc-client-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/client/config/RetryPolicyConfig.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.client.config; + +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.validation.constraints.Min; + +import org.springframework.boot.convert.DurationUnit; + +import io.grpc.Status; +import io.grpc.Status.Code; +import lombok.Data; + +/** + * The retry policy config. + * + *

+ * For the exact specification refer to: + * A6-client-retries + *

+ * + * @author wushengju + */ +@Data +public class RetryPolicyConfig { + + /** + * The maximum number of RPC attempts, including the original RPC. This field is required and must be one or + * greater. A value of {@code 1} indicates, no retries after the initial call. + */ + @Min(1) + private int maxAttempts; + + /** + * Exponential backoff parameter: Defines the upper limit for the first backoff time. + * + *

+ * The initial retry attempt will occur at {@code random(0, initialBackoff)}. In general, the n-th attempt since the + * last server pushback response (if any), will occur at + * {@code random(0, min(initialBackoff*backoffMultiplier**(n-1), maxBackoff))}. + *

+ * + *

+ * Default unit seconds. Must be greater than zero. + *

+ */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration initialBackoff; + + /** + * Exponential backoff parameter: The upper duration limit for backing off an attempt. + * + *

+ * Default unit seconds. Must be greater than zero. + *

+ */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration maxBackoff; + + /** + * Exponential backoff parameter: The multiplier to apply to the (initial) backoff. Values below {@code 1.0}, will + * result in faster retries the more attempts have been made. {@code 1.0} will result in averagely same retry + * frequency. Values above {@code 1.0} will slow down the requests with each attempt. + * + *

+ * Must be greater than zero. + *

+ */ + private double backoffMultiplier; + + /** + * The set of status codes which may be retried. Status codes are specified in the integer form or the + * case-insensitive string form (e.g. {@code 14}, {@code "UNAVAILABLE"} or {@code "unavailable"}). This field is + * required and must be non-empty. + */ + private Set retryableStatusCodes; + + /** + * Creates a copy of this instance. + * + * @return The newly created copy. + */ + public RetryPolicyConfig copy() { + final RetryPolicyConfig copy = new RetryPolicyConfig(); + copy.maxAttempts = this.maxAttempts; + copy.initialBackoff = this.initialBackoff; + copy.maxBackoff = this.maxBackoff; + copy.backoffMultiplier = this.backoffMultiplier; + copy.retryableStatusCodes = + new LinkedHashSet<>(requireNonNull(this.retryableStatusCodes, "retryableStatusCodes")); + return copy; + } + + /** + * Builds a json like map from this instance. + * + * @return The json like map representation of this instance. + */ + public Map buildMap() { + final Map map = new LinkedHashMap<>(); + map.put("maxAttempts", (double) this.maxAttempts); + map.put("initialBackoff", formatDuration(requireNonNull(this.initialBackoff, "initialBackoff"))); + map.put("maxBackoff", formatDuration(requireNonNull(this.maxBackoff, "maxBackoff"))); + map.put("backoffMultiplier", this.backoffMultiplier); + map.put("retryableStatusCodes", requireNonNull(this.retryableStatusCodes, "retryableStatusCodes").stream() + .map(Code::name) + .collect(Collectors.toList())); + return map; + } + + private static String formatDuration(final Duration duration) { + if (duration.getNano() == 0) { + // 1s + return duration.getSeconds() + "s"; + } else { + // 1.2s + return String.format("%d.%09ds", duration.getSeconds(), duration.getNano()).replaceAll("0+s", "s"); + } + } + +} diff --git a/grpc-client-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/client/config/GrpcChannelPropertiesMethodConfigTest.java b/grpc-client-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/client/config/GrpcChannelPropertiesMethodConfigTest.java new file mode 100644 index 000000000..06c972e5f --- /dev/null +++ b/grpc-client-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/client/config/GrpcChannelPropertiesMethodConfigTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.client.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Test retry configuration using properties and inheritance. + **/ +@ExtendWith(SpringExtension.class) +@SpringBootTest(properties = { + "grpc.client.GLOBAL.retry-enabled=true", + "grpc.client.GLOBAL.method-config[0].name[0].service=helloworld.Greeter", + "grpc.client.GLOBAL.method-config[0].name[0].method=SayHello", + "grpc.client.GLOBAL.method-config[0].retry-policy.max-attempts=2", + "grpc.client.GLOBAL.method-config[0].retry-policy.initial-backoff=1", + "grpc.client.GLOBAL.method-config[0].retry-policy.max-backoff=1", + "grpc.client.GLOBAL.method-config[0].retry-policy.backoff-multiplier=2", + "grpc.client.GLOBAL.method-config[0].retry-policy.retryable-status-codes=UNKNOWN,UNAVAILABLE", +}) +class GrpcChannelPropertiesMethodConfigTest { + + @Autowired + private GrpcChannelsProperties grpcChannelsProperties; + + @Test + void test() { + final GrpcChannelProperties properties = this.grpcChannelsProperties.getChannel("test"); + assertEquals(true, properties.isRetryEnabled()); + final MethodConfig methodConfig = properties.getMethodConfig().get(0); + final NameConfig nameConfig = methodConfig.getName().get(0); + assertEquals("helloworld.Greeter", nameConfig.getService()); + assertEquals("SayHello", nameConfig.getMethod()); + final RetryPolicyConfig retryPolicy = methodConfig.getRetryPolicy(); + assertEquals(2, retryPolicy.getMaxAttempts()); + assertEquals(1, retryPolicy.getInitialBackoff().getSeconds()); + assertEquals(1, retryPolicy.getMaxBackoff().getSeconds()); + assertEquals(2, retryPolicy.getBackoffMultiplier()); + assertEquals(2, retryPolicy.getRetryableStatusCodes().size()); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/config/DynamicTestServiceConfig.java b/tests/src/test/java/net/devh/boot/grpc/test/config/DynamicTestServiceConfig.java new file mode 100644 index 000000000..421fe3a9c --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/config/DynamicTestServiceConfig.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.config; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.protobuf.Empty; + +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import net.devh.boot.grpc.server.service.GrpcService; +import net.devh.boot.grpc.test.proto.SomeType; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceImplBase; + +@Configuration +public class DynamicTestServiceConfig { + + public static BiConsumer> UNIMPLEMENTED = + errorWith(Status.UNIMPLEMENTED.withDescription("responseFunction not configured")); + + public static BiConsumer> respondWith(final String data) { + return respondWith(SomeType.newBuilder() + .setVersion(data) + .build()); + } + + public static BiConsumer> respondWith(final SomeType data) { + return (request, responseObserver) -> { + responseObserver.onNext(data); + responseObserver.onCompleted(); + }; + } + + public static BiConsumer> errorWith(final Status status) { + return (request, responseObserver) -> { + responseObserver.onError(status.asException()); + }; + } + + public static BiConsumer> increment(final AtomicInteger integer) { + return (request, responseObserver) -> { + integer.incrementAndGet(); + }; + } + + @Bean + AtomicReference>> responseFunction() { + return new AtomicReference<>(UNIMPLEMENTED); + } + + @GrpcService + TestServiceImplBase testServiceImplBase( + final AtomicReference>> responseFunction) { + + return new TestServiceImplBase() { + + @Override + public void normal(final Empty request, final StreamObserver responseObserver) { + responseFunction.get().accept(request, responseObserver); + } + + }; + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/setup/RetryServerClientTest.java b/tests/src/test/java/net/devh/boot/grpc/test/setup/RetryServerClientTest.java new file mode 100644 index 000000000..ca5d7379a --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/setup/RetryServerClientTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.setup; + +import static net.devh.boot.grpc.test.config.DynamicTestServiceConfig.errorWith; +import static net.devh.boot.grpc.test.config.DynamicTestServiceConfig.increment; +import static net.devh.boot.grpc.test.config.DynamicTestServiceConfig.respondWith; +import static net.devh.boot.grpc.test.util.GrpcAssertions.assertThrowsStatus; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.google.protobuf.Empty; + +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import net.devh.boot.grpc.test.config.BaseAutoConfiguration; +import net.devh.boot.grpc.test.config.DynamicTestServiceConfig; +import net.devh.boot.grpc.test.proto.SomeType; +import net.devh.boot.grpc.test.proto.TestServiceGrpc; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceBlockingStub; + +@Slf4j +@SpringBootTest(properties = { + "grpc.server.inProcessName=test", + "grpc.server.port=-1", + "grpc.client.GLOBAL.negotiationType=PLAINTEXT", + "grpc.client.test.address=in-process:test", + "grpc.client.GLOBAL.retry-enabled=true", + "grpc.client.GLOBAL.method-config[0].name[0].service=" + TestServiceGrpc.SERVICE_NAME, + "grpc.client.GLOBAL.method-config[0].name[0].method=", // all methods within that service + "grpc.client.GLOBAL.method-config[0].retry-policy.max-attempts=2", + "grpc.client.GLOBAL.method-config[0].retry-policy.initial-backoff=1200ms", + "grpc.client.GLOBAL.method-config[0].retry-policy.max-backoff=1", + "grpc.client.GLOBAL.method-config[0].retry-policy.backoff-multiplier=2", + "grpc.client.GLOBAL.method-config[0].retry-policy.retryable-status-codes=UNKNOWN,UNAVAILABLE", +}) +@SpringJUnitConfig(classes = { + DynamicTestServiceConfig.class, + BaseAutoConfiguration.class, +}) +@DirtiesContext +class RetryServerClientTest { + + private static final Empty EMPTY = Empty.getDefaultInstance(); + + @GrpcClient("test") + TestServiceBlockingStub stub; + + @Autowired + AtomicReference>> responseFunction; + + @Test + void testRetryConfigSuccess() { + final AtomicInteger counter = new AtomicInteger(); + + this.responseFunction.set((request, observer) -> { + log.info("Failing first request"); + this.responseFunction.set(increment(counter).andThen(respondWith("OK"))); + counter.incrementAndGet(); + errorWith(Status.UNAVAILABLE.withDescription("expected")).accept(request, observer); + }); + + final SomeType response = assertDoesNotThrow(() -> this.stub.normal(EMPTY)); + assertEquals("OK", response.getVersion()); + + assertEquals(2, counter.get()); + } + + @Test + void testRetryConfigFailedAttempts() { + final AtomicInteger counter = new AtomicInteger(); + final Status expectedFailure = Status.UNAVAILABLE.withDescription("unexpected"); + + this.responseFunction.set((request, observer) -> { + log.info("Failing first request"); + this.responseFunction.set((request2, observer2) -> { + log.info("Failing second request"); + this.responseFunction.set(increment(counter).andThen(respondWith("OK"))); + counter.incrementAndGet(); + errorWith(expectedFailure).accept(request2, observer2); + }); + counter.incrementAndGet(); + errorWith(Status.UNAVAILABLE.withDescription("expected")).accept(request, observer); + }); + + assertThrowsStatus(expectedFailure, () -> this.stub.normal(EMPTY)); + + assertEquals(2, counter.get()); + } + + @Test + void testRetryConfigFailedStatus() { + final AtomicInteger counter = new AtomicInteger(); + final Status expectedFailure = Status.UNAUTHENTICATED.withDescription("unexpected"); + + this.responseFunction.set((request, observer) -> { + log.info("Failing request"); + counter.incrementAndGet(); + errorWith(expectedFailure).accept(request, observer); + }); + + assertThrowsStatus(expectedFailure, () -> this.stub.normal(EMPTY)); + + assertEquals(1, counter.get()); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/util/GrpcAssertions.java b/tests/src/test/java/net/devh/boot/grpc/test/util/GrpcAssertions.java index c5fce8885..9bb623492 100644 --- a/tests/src/test/java/net/devh/boot/grpc/test/util/GrpcAssertions.java +++ b/tests/src/test/java/net/devh/boot/grpc/test/util/GrpcAssertions.java @@ -19,7 +19,7 @@ import static net.devh.boot.grpc.test.util.FutureAssertions.assertFutureEquals; import static net.devh.boot.grpc.test.util.FutureAssertions.assertFutureThrows; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.concurrent.ExecutionException; @@ -70,6 +70,20 @@ public static void assertFutureFirstEquals(final T expected, final Stream assertFutureEquals(expected, responseObserver.firstValue(), unwrapper, timeout, timeoutUnit); } + /** + * Assert that the given {@link Executable} throws a {@link StatusRuntimeException} with the expected status code. + * + * @param expectedStatus The expected status. + * @param executable The executable to run. + * @return The status contained in the exception. + * @see Assertions#assertThrows(Class, Executable) + */ + public static Status assertThrowsStatus(final Status expectedStatus, final Executable executable) { + final Status actualStatus = assertThrowsStatus(expectedStatus.getCode(), executable); + assertEquals(expectedStatus.getDescription(), actualStatus.getDescription(), "Status description"); + return actualStatus; + } + /** * Assert that the given {@link Executable} throws a {@link StatusRuntimeException} with the expected status code. * @@ -125,7 +139,7 @@ public static Status assertFutureThrowsStatus(final Status.Code expectedCode, fi */ public static Status assertStatus(final Status.Code expectedCode, final StatusRuntimeException exception) { final Status status = exception.getStatus(); - assertEquals(expectedCode, status.getCode()); + assertEquals(expectedCode, status.getCode(), "Status code"); return status; }