diff --git a/core/src/main/java/com/linecorp/armeria/client/circuitbreaker/KeyedCircuitBreakerMapping.java b/core/src/main/java/com/linecorp/armeria/client/circuitbreaker/KeyedCircuitBreakerMapping.java index 90021d5b165..bb4de27c72d 100644 --- a/core/src/main/java/com/linecorp/armeria/client/circuitbreaker/KeyedCircuitBreakerMapping.java +++ b/core/src/main/java/com/linecorp/armeria/client/circuitbreaker/KeyedCircuitBreakerMapping.java @@ -1,7 +1,7 @@ /* - * Copyright 2016 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -16,9 +16,9 @@ package com.linecorp.armeria.client.circuitbreaker; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.host; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.method; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.path; +import static com.linecorp.armeria.internal.common.RequestContextUtil.host; +import static com.linecorp.armeria.internal.common.RequestContextUtil.method; +import static com.linecorp.armeria.internal.common.RequestContextUtil.path; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; diff --git a/core/src/main/java/com/linecorp/armeria/client/retry/KeyedRetryConfigMapping.java b/core/src/main/java/com/linecorp/armeria/client/retry/KeyedRetryConfigMapping.java index 3ada68f0815..fd22c8b9708 100644 --- a/core/src/main/java/com/linecorp/armeria/client/retry/KeyedRetryConfigMapping.java +++ b/core/src/main/java/com/linecorp/armeria/client/retry/KeyedRetryConfigMapping.java @@ -1,7 +1,7 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -16,11 +16,17 @@ package com.linecorp.armeria.client.retry; +import static com.linecorp.armeria.internal.common.RequestContextUtil.host; +import static com.linecorp.armeria.internal.common.RequestContextUtil.method; +import static com.linecorp.armeria.internal.common.RequestContextUtil.path; import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.joining; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.BiFunction; +import java.util.stream.Stream; import com.linecorp.armeria.client.ClientRequestContext; import com.linecorp.armeria.common.Request; @@ -38,6 +44,27 @@ final class KeyedRetryConfigMapping implements RetryConfigMa this.retryConfigFactory = requireNonNull(retryConfigFactory, "retryConfigFactory"); } + KeyedRetryConfigMapping( + boolean perHost, boolean perMethod, boolean perPath, RetryConfigFactory retryConfigFactory) { + requireNonNull(retryConfigFactory, "retryConfigFactory"); + + keyFactory = (ctx, req) -> { + final String host = perHost ? host(ctx) : null; + final String method = perMethod ? method(ctx) : null; + final String path = perPath ? path(ctx) : null; + return Stream.of(host, method, path) + .filter(Objects::nonNull) + .collect(joining("#")); + }; + + this.retryConfigFactory = (ctx, req) -> { + final String host = perHost ? host(ctx) : null; + final String method = perMethod ? method(ctx) : null; + final String path = perPath ? path(ctx) : null; + return retryConfigFactory.apply(host, method, path); + }; + } + @Override public RetryConfig get(ClientRequestContext ctx, Request req) { final String key = keyFactory.apply(ctx, req); diff --git a/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java index 31f7892815d..9dc82769f48 100644 --- a/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java +++ b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java @@ -1,7 +1,7 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -26,6 +26,7 @@ import com.linecorp.armeria.common.Response; import com.linecorp.armeria.common.RpcResponse; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.UnmodifiableFuture; /** * Holds retry config used by a {@link RetryingClient}. @@ -36,6 +37,14 @@ public final class RetryConfig { private static final Logger logger = LoggerFactory.getLogger(RetryConfig.class); + private static final RetryConfig NO_RETRY_CONFIG = builder( + (ctx, cause) -> UnmodifiableFuture.completedFuture(RetryDecision.noRetry())).build(); + + @SuppressWarnings("unchecked") + public static RetryConfig noRetry() { + return (RetryConfig) NO_RETRY_CONFIG; + } + /** * Returns a new {@link RetryConfigBuilder} with the specified {@link RetryRule}. */ diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/package-info.java b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigFactory.java similarity index 55% rename from core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/package-info.java rename to core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigFactory.java index 19ae17bdb41..9194cc95036 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/package-info.java +++ b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigFactory.java @@ -1,7 +1,7 @@ /* - * Copyright 2022 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -14,11 +14,12 @@ * under the License. */ -/** - * CircuitBreaker related classes used internally. - * Anything in this package can be changed or removed at any time. - */ -@NonNullByDefault -package com.linecorp.armeria.internal.common.circuitbreaker; +package com.linecorp.armeria.client.retry; + +import com.linecorp.armeria.common.Response; +import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.annotation.NonNullByDefault; +@FunctionalInterface +public interface RetryConfigFactory { + RetryConfig apply(@Nullable String host, @Nullable String method, @Nullable String path); +} diff --git a/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMapping.java b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMapping.java index dfd5ba4654e..1b22c771717 100644 --- a/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMapping.java +++ b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMapping.java @@ -1,7 +1,7 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -16,7 +16,10 @@ package com.linecorp.armeria.client.retry; +import static java.util.Objects.requireNonNull; + import java.util.function.BiFunction; +import java.util.function.Function; import com.linecorp.armeria.client.ClientRequestContext; import com.linecorp.armeria.common.Request; @@ -24,7 +27,7 @@ /** * Returns a {@link RetryConfig} given the {@link ClientRequestContext}. - * Allows users to change retry behavior according to any context element, like host, method, path ...etc. + * Allows users to change retry behavior according to any context element, like host, method, path, etc. */ @FunctionalInterface public interface RetryConfigMapping { @@ -56,6 +59,40 @@ static RetryConfigMapping of( return new KeyedRetryConfigMapping<>(keyFactory, retryConfigFactory); } + static RetryConfigMappingBuilder builder() { + return new RetryConfigMappingBuilder<>(); + } + + static RetryConfigMapping perMethod(Function> factory) { + requireNonNull(factory, "factory"); + return RetryConfigMapping.builder() + .perMethod() + .build((host, method, path) -> factory.apply(method)); + } + + static RetryConfigMapping perHost(Function> factory) { + requireNonNull(factory, "factory"); + return RetryConfigMapping.builder() + .perHost() + .build((host, method, path) -> factory.apply(host)); + } + + static RetryConfigMapping perPath(Function> factory) { + requireNonNull(factory, "factory"); + return RetryConfigMapping.builder() + .perPath() + .build((host, method, path) -> factory.apply(path)); + } + + static RetryConfigMapping perHostAndMethod( + BiFunction> factory) { + requireNonNull(factory, "factory"); + return RetryConfigMapping.builder() + .perHost() + .perMethod() + .build((host, method, path) -> factory.apply(host, method)); + } + /** * Returns the {@link RetryConfig} that maps to the given {@link ClientRequestContext} and {@link Request}. */ diff --git a/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMappingBuilder.java b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMappingBuilder.java new file mode 100644 index 00000000000..d309b0ae515 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigMappingBuilder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you 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 com.linecorp.armeria.client.retry; + +import com.linecorp.armeria.common.Response; + +public final class RetryConfigMappingBuilder { + private boolean perHost; + private boolean perMethod; + private boolean perPath; + + public RetryConfigMappingBuilder perHost() { + perHost = true; + return this; + } + + public RetryConfigMappingBuilder perMethod() { + perMethod = true; + return this; + } + + public RetryConfigMappingBuilder perPath() { + perPath = true; + return this; + } + + private boolean validateMappingKeys() { + return perHost || perMethod || perPath; + } + + public RetryConfigMapping build(RetryConfigFactory retryConfigFactory) { + if (!validateMappingKeys()) { + throw new IllegalStateException( + "A RetryConfigMapping created by this builder must be per host, method and/or path"); + } + + return new KeyedRetryConfigMapping<>( + perHost, perMethod, perPath, retryConfigFactory + ); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/RequestContextUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/RequestContextUtil.java index 78d6521976b..4f4a327d85e 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/RequestContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/RequestContextUtil.java @@ -1,7 +1,7 @@ /* - * Copyright 2019 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -30,12 +30,15 @@ import com.google.common.collect.MapMaker; import com.google.errorprone.annotations.MustBeClosed; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.common.ContextHolder; import com.linecorp.armeria.common.Flags; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.common.RequestContextStorage; import com.linecorp.armeria.common.RequestContextStorageProvider; +import com.linecorp.armeria.common.RpcRequest; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.SafeCloseable; import com.linecorp.armeria.common.util.Sampler; @@ -267,5 +270,29 @@ public static void ensureSameCtx(RequestContext ctx, ContextHolder contextHolder } } + public static String host(ClientRequestContext ctx) { + final Endpoint endpoint = ctx.endpoint(); + if (endpoint == null) { + return "UNKNOWN"; + } else { + final String ipAddr = endpoint.ipAddr(); + if (ipAddr == null || endpoint.isIpAddrOnly()) { + return endpoint.authority(); + } else { + return endpoint.authority() + '/' + ipAddr; + } + } + } + + public static String method(ClientRequestContext ctx) { + final RpcRequest rpcReq = ctx.rpcRequest(); + return rpcReq != null ? rpcReq.method() : ctx.method().name(); + } + + public static String path(ClientRequestContext ctx) { + final HttpRequest request = ctx.request(); + return request == null ? "" : request.path(); + } + private RequestContextUtil() {} } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/CircuitBreakerMappingUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/CircuitBreakerMappingUtil.java deleted file mode 100644 index 6f8e2ec7823..00000000000 --- a/core/src/main/java/com/linecorp/armeria/internal/common/circuitbreaker/CircuitBreakerMappingUtil.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2022 LINE Corporation - * - * LINE Corporation licenses this file to you 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 com.linecorp.armeria.internal.common.circuitbreaker; - -import com.linecorp.armeria.client.ClientRequestContext; -import com.linecorp.armeria.client.Endpoint; -import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.RpcRequest; - -public final class CircuitBreakerMappingUtil { - - public static String host(ClientRequestContext ctx) { - final Endpoint endpoint = ctx.endpoint(); - if (endpoint == null) { - return "UNKNOWN"; - } else { - final String ipAddr = endpoint.ipAddr(); - if (ipAddr == null || endpoint.isIpAddrOnly()) { - return endpoint.authority(); - } else { - return endpoint.authority() + '/' + ipAddr; - } - } - } - - public static String method(ClientRequestContext ctx) { - final RpcRequest rpcReq = ctx.rpcRequest(); - return rpcReq != null ? rpcReq.method() : ctx.method().name(); - } - - public static String path(ClientRequestContext ctx) { - final HttpRequest request = ctx.request(); - return request == null ? "" : request.path(); - } - - private CircuitBreakerMappingUtil() {} -} diff --git a/core/src/test/java/com/linecorp/armeria/client/circuitbreaker/RetryConfigMappingTest.java b/core/src/test/java/com/linecorp/armeria/client/circuitbreaker/RetryConfigMappingTest.java new file mode 100644 index 00000000000..c1d867fa49c --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/circuitbreaker/RetryConfigMappingTest.java @@ -0,0 +1,415 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you 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 com.linecorp.armeria.client.circuitbreaker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.client.retry.Backoff; +import com.linecorp.armeria.client.retry.RetryConfig; +import com.linecorp.armeria.client.retry.RetryConfigMapping; +import com.linecorp.armeria.client.retry.RetryConfigMappingBuilder; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.RpcRequest; + +import io.netty.util.AttributeKey; + +class RetryConfigMappingTest { + @Test + void createsCorrectMappingWithPerMethod() { + final RetryConfig configForGet = RetryConfig.builder(RetryRule.failsafe()).build(); + final RetryConfig configForPost = RetryConfig.noRetry(); + + final RetryConfigMapping mapping = RetryConfigMapping.perMethod(method -> { + switch (method) { + case "GET": + return configForGet; + case "POST": + return configForPost; + default: + fail(); + // Make compiler happy. + return configForGet; + } + }); + + final RetryConfigMapping mappingFromBuilder = RetryConfigMapping.builder() + .perMethod() + .build( + (host, method, path) -> { + assertThat( + method).isNotNull(); + + switch (method) { + case "GET": + return configForGet; + case "POST": + return configForPost; + default: + fail(); + // Make compiler happy. + return configForGet; + } + } + ); + + final ClientRequestContext requestContextForGet = createHttpContext("example.com", HttpMethod.GET, + "/anypath"); + final ClientRequestContext requestContextForPost = createHttpContext("example.com", HttpMethod.POST, + "/anypath"); + + assertThat(mapping.get(requestContextForGet, requestContextForGet.request())) + .isEqualTo(configForGet); + assertThat(mapping.get(requestContextForPost, requestContextForPost.request())) + .isEqualTo(configForPost); + + assertThat(mappingFromBuilder.get(requestContextForGet, requestContextForGet.request())) + .isEqualTo(configForGet); + assertThat(mappingFromBuilder.get(requestContextForPost, requestContextForPost.request())) + .isEqualTo(configForPost); + } + + @Test + void createsCorrectMappingWithPerRpcMethod() { + final RetryConfig configForQuery = RetryConfig.builder(RetryRule.failsafe()).build(); + final RetryConfig configForMutation = RetryConfig.noRetry(); + + final RetryConfigMapping mapping = RetryConfigMapping.perMethod(method -> { + switch (method) { + case "query": + return configForQuery; + case "mutate": + return configForMutation; + default: + fail(); + // Make compiler happy. + return configForQuery; + } + }); + + final RetryConfigMapping mappingFromBuilder = RetryConfigMapping.builder() + .perMethod() + .build( + (host, method, path) -> { + assertThat( + method).isNotNull(); + + switch (method) { + case "query": + return configForQuery; + case "mutate": + return configForMutation; + default: + fail(); + // Make compiler happy. + return configForQuery; + } + } + ); + + + final ClientRequestContext requestContextForQuery = createRpcContext("example.com", TestService.class, + "query"); + final ClientRequestContext requestContextForMutation = createRpcContext("example.com", TestService.class, + "mutate"); + + assertThat(mapping.get(requestContextForQuery, requestContextForQuery.rpcRequest())) + .isEqualTo(configForQuery); + assertThat(mapping.get(requestContextForMutation, requestContextForMutation.rpcRequest())) + .isEqualTo(configForMutation); + + assertThat(mappingFromBuilder.get(requestContextForQuery, requestContextForQuery.rpcRequest())) + .isEqualTo(configForQuery); + assertThat(mappingFromBuilder.get(requestContextForMutation, requestContextForMutation.rpcRequest())) + .isEqualTo(configForMutation); + } + + @Test + void createsCorrectMappingWithPerHost() { + final RetryConfig configForExampleHost = RetryConfig.builder(RetryRule.failsafe()).build(); + final RetryConfig configForLocalHost = RetryConfig.noRetry(); + + final RetryConfigMapping mapping = RetryConfigMapping.perHost(host -> { + switch (host) { + case "example.com": + return configForExampleHost; + case "localhost": + return configForLocalHost; + default: + fail(); + // Make compiler happy. + return configForExampleHost; + } + }); + + final RetryConfigMapping mappingFromBuilder = RetryConfigMapping.builder() + .perHost() + .build( + (host, method, path) -> { + assertThat( + host).isNotNull(); + + switch (host) { + case "example.com": + return configForExampleHost; + case "localhost": + return configForLocalHost; + default: + fail(); + // Make compiler happy. + return configForExampleHost; + } + } + ); + + final ClientRequestContext requestContextForExample = createHttpContext("example.com", HttpMethod.GET, + "/anypath"); + final ClientRequestContext requestContextForLocalhost = createHttpContext("localhost", HttpMethod.GET, + "/anypath"); + + assertThat(mapping.get(requestContextForExample, requestContextForExample.request())) + .isEqualTo(configForExampleHost); + assertThat(mapping.get(requestContextForLocalhost, requestContextForLocalhost.request())) + .isEqualTo(configForLocalHost); + + assertThat(mappingFromBuilder.get(requestContextForExample, requestContextForExample.request())) + .isEqualTo(configForExampleHost); + assertThat(mappingFromBuilder.get(requestContextForLocalhost, requestContextForLocalhost.request())) + .isEqualTo(configForLocalHost); + } + + @Test + void createsCorrectMappingWithPerHostAndRpcRequest() { + final RetryConfig configForProdHost = RetryConfig.builder(RetryRule.failsafe()).build(); + final RetryConfig configForTestHost = RetryConfig.noRetry(); + + final RetryConfigMapping mapping = RetryConfigMapping.perHost(host -> { + switch (host) { + case "api.prod.example.com": + return configForProdHost; + case "api.test.example.com": + return configForTestHost; + default: + fail(); + // Make compiler happy. + return configForProdHost; + } + }); + + final ClientRequestContext requestContextForProdHost = createRpcContext("api.prod.example.com", + TestService.class, "query"); + final ClientRequestContext requestContextForTestHost = createRpcContext("api.test.example.com", + TestService.class, "query"); + + assertThat(mapping.get(requestContextForProdHost, requestContextForProdHost.rpcRequest())) + .isEqualTo(configForProdHost); + assertThat(mapping.get(requestContextForTestHost, requestContextForTestHost.rpcRequest())) + .isEqualTo(configForTestHost); + } + + @Test + void createsCorrectMappingWithPerPath() { + final RetryConfig configForApiPath = RetryConfig.builder(RetryRule.failsafe()).build(); + final RetryConfig configForHealthPath = RetryConfig.noRetry(); + + final RetryConfigMapping mapping = RetryConfigMapping.perPath(path -> { + if (path.startsWith("/api/")) { + return configForApiPath; + } else if (path.startsWith("/health")) { + return configForHealthPath; + } else { + fail(); + // Make compiler happy. + return configForApiPath; + } + }); + + final RetryConfigMapping mappingFromBuilder = RetryConfigMapping.builder() + .perPath() + .build( + (host, method, path) -> { + assertThat(path).isNotNull(); + + if (path.startsWith("/api/")) { + return configForApiPath; + } else if (path.startsWith("/health")) { + return configForHealthPath; + } else { + fail(); + // Make compiler happy. + return configForApiPath; + } + } + ); + + final HttpRequest apiRequest = HttpRequest.of(HttpMethod.GET, "/api/resources"); + final HttpRequest healthRequest = HttpRequest.of(HttpMethod.GET, "/health"); + + final ClientRequestContext apiContext = createHttpContext("example.com", HttpMethod.GET, "/api/resources"); + final ClientRequestContext healthContext = createHttpContext("example.com", HttpMethod.GET, "/health"); + + assertThat(mapping.get(apiContext, apiRequest)).isEqualTo(configForApiPath); + assertThat(mapping.get(healthContext, healthRequest)).isEqualTo(configForHealthPath); + + assertThat(mappingFromBuilder.get(apiContext, apiRequest)).isEqualTo(configForApiPath); + assertThat(mappingFromBuilder.get(healthContext, healthRequest)).isEqualTo(configForHealthPath); + } + + @Test + void createsCorrectMappingWithPerHostAndMethod() { + final RetryConfig configForIpAddrGet = + RetryConfig.builder(RetryRule.onException()).maxTotalAttempts(5).build(); + final RetryConfig configForIpAddrPost = + RetryConfig.noRetry(); + + + // Duplicated just to see that we differentiate between the methods. + final RetryConfig configForUnknownGet = + RetryConfig.builder(RetryRule.onException()).maxTotalAttempts(10).build(); + // Not using RetryConfig.noRetry() here in order to have two different configs for configForIpAddrPost + // and configForUnknownPost + final RetryConfig configForUnknownPost = RetryConfig.builder( + RetryRule.builder().onException().thenNoRetry()).build(); + + + final RetryConfigMapping mapping = RetryConfigMapping.perHostAndMethod((host, method) -> { + if ("192.168.1.1".equals(host)) { + if ("GET".equals(method)) { + return configForIpAddrGet; + } else if ("POST".equals(method)) { + return configForIpAddrPost; + } + } else if ("UNKNOWN".equals(host)) { + if ("GET".equals(method)) { + return configForUnknownGet; + } else if ("POST".equals(method)) { + return configForUnknownPost; + } + } + fail(); + // Make compiler happy. + return configForIpAddrGet; + }); + + final ClientRequestContext ipAddrGetContext = createHttpContext("192.168.1.1", HttpMethod.GET, "/api" + + "/resources"); + final ClientRequestContext ipAddrPostContext = createHttpContext("192.168.1.1", HttpMethod.POST, + "/api/resources"); + + // Create contexts with null endpoint (will be handled as "UNKNOWN") + final HttpRequest getRequest = HttpRequest.of(HttpMethod.GET, "/api/resources"); + final HttpRequest postRequest = HttpRequest.of(HttpMethod.POST, "/api/resources"); + + final ClientRequestContext unknownGetContext = + ClientRequestContext.builder(getRequest) + .endpointGroup(EndpointGroup.of()) + .build(); + + final ClientRequestContext unknownPostContext = + ClientRequestContext.builder(postRequest) + .endpointGroup(EndpointGroup.of()) + .build(); + + assertThat(mapping.get(ipAddrGetContext, getRequest)).isEqualTo(configForIpAddrGet); + assertThat(mapping.get(ipAddrPostContext, postRequest)).isEqualTo(configForIpAddrPost); + assertThat(mapping.get(unknownGetContext, getRequest)).isEqualTo(configForUnknownGet); + assertThat(mapping.get(unknownPostContext, postRequest)).isEqualTo(configForUnknownPost); + } + + + @Test + void createsCorrectMappingWithOf() { + final AttributeKey maxRetryAttemptsAttr = + AttributeKey.valueOf("maxRetryAttemptsAttr"); + + final RetryConfigMapping mapping = + RetryConfigMapping.of((ctx, req) -> (ctx.hasAttr(maxRetryAttemptsAttr) ? + ctx.attr(maxRetryAttemptsAttr) : + Integer.valueOf(1)).toString() + , (ctx, req) -> + RetryConfig.builder( + RetryRule + .builder() + .onException() + .thenBackoff(Backoff.fixed(10)) + ) + .maxTotalAttempts(ctx.hasAttr(maxRetryAttemptsAttr) ? ctx.attr(maxRetryAttemptsAttr) : 1) + .build() + ); + + + final ClientRequestContext requestContextWithoutBackoffAttr = + createHttpContext("a.example.com", HttpMethod.GET, "/foopath"); + final ClientRequestContext requestContextWithBackoffAttr = createRpcContext("b.example.com", TestService.class, + "query"); + requestContextWithBackoffAttr.setAttr(maxRetryAttemptsAttr, 42); + + final ClientRequestContext anotherRequestContextWithSameBackoffAttr = + createRpcContext("c.example.com", TestService.class, "mutate"); + anotherRequestContextWithSameBackoffAttr.setAttr(maxRetryAttemptsAttr, 42); + + + assertThat(mapping.get(requestContextWithoutBackoffAttr, requestContextWithoutBackoffAttr.request()).maxTotalAttempts()) + .isEqualTo(1); + assertThat(mapping.get(requestContextWithBackoffAttr, requestContextWithBackoffAttr.request()).maxTotalAttempts()) + .isEqualTo(42); + assertThat(mapping.get(anotherRequestContextWithSameBackoffAttr, anotherRequestContextWithSameBackoffAttr.request()).maxTotalAttempts()) + .isEqualTo(42); + + // Check that they are cached + assertThat(mapping.get(requestContextWithBackoffAttr, requestContextWithBackoffAttr.request())) + .isEqualTo(mapping.get(anotherRequestContextWithSameBackoffAttr, anotherRequestContextWithSameBackoffAttr.request())); + } + + @Test + void throwsExceptionWhenNoMappingKeysSet() { + final RetryConfigMappingBuilder builder = RetryConfigMapping.builder(); + + assertThat(assertThrows( + IllegalStateException.class, + () -> builder.build((host, method, path) -> RetryConfig.builder(RetryRule.failsafe()).build()) + ).getMessage()).isEqualTo("A RetryConfigMapping created by this builder must be per host, method and/or path"); + } + + private static ClientRequestContext createHttpContext(String host, HttpMethod method, String path) { + final HttpRequest request = HttpRequest.of(method, path); + return ClientRequestContext.builder(request) + .endpointGroup(Endpoint.of(host)) + .build(); + } + + private static ClientRequestContext createRpcContext(String host, Class serviceClass, String method) { + final RpcRequest request = RpcRequest.of(serviceClass, method); + return ClientRequestContext.builder(request, URI.create("http://" + host + ":80/testservice")) + .endpointGroup(Endpoint.of(host)) + .build(); + } + + private static class TestService {} +} + diff --git a/core/src/test/java/com/linecorp/armeria/client/retry/RetryingClientTest.java b/core/src/test/java/com/linecorp/armeria/client/retry/RetryingClientTest.java index c68446ad7e3..dc714ff768e 100644 --- a/core/src/test/java/com/linecorp/armeria/client/retry/RetryingClientTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/retry/RetryingClientTest.java @@ -1,7 +1,7 @@ /* - * Copyright 2017 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -663,6 +663,21 @@ void shouldGetExceptionWhenFactoryIsClosed() { "(?i).*(factory has been closed|not accepting a task).*")); } + @Test + void doNotRetryWhenNoRetryConfigIsGiven() throws InterruptedException { + // test with /500-then-success. check response code and retry count + final WebClient client = client(RetryConfig.noRetry(), 10000); + final AggregatedHttpResponse res = client.get("/retry-after-with-http-date").aggregate().join(); + + assertThat(res.status()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(reqCount.get()).isEqualTo(1); + + // Sleep 1 second more to check if there was another retry. + Thread.sleep(1000); + + assertThat(reqCount.get()).isEqualTo(1); + } + @Test void doNotRetryWhenResponseIsAborted() throws Exception { final List abortCauses = @@ -852,36 +867,36 @@ private WebClient client(RetryConfigMapping mapping) { private WebClient client(RetryRule retryRule, long responseTimeoutMillis, long responseTimeoutForEach, int maxTotalAttempts) { - final Function retryingDecorator = - RetryingClient.builder( - RetryConfig.builder0(retryRule) - .responseTimeoutMillisForEachAttempt(responseTimeoutForEach) - .maxTotalAttempts(maxTotalAttempts) - .build()) - .useRetryAfter(true) - .newDecorator(); - - return WebClient.builder(server.httpUri()) - .factory(clientFactory) - .responseTimeoutMillis(responseTimeoutMillis) - .decorator(retryingDecorator) - .build(); + return client( + RetryConfig.builder(retryRule) + .responseTimeoutMillisForEachAttempt(responseTimeoutForEach) + .maxTotalAttempts(maxTotalAttempts) + .build() + , responseTimeoutMillis + ); } private WebClient client(RetryRuleWithContent retryRuleWithContent, long responseTimeoutMillis, long responseTimeoutForEach, int maxTotalAttempts) { + return client( + RetryConfig.builder(retryRuleWithContent) + .responseTimeoutMillisForEachAttempt(responseTimeoutForEach) + .maxTotalAttempts(maxTotalAttempts) + .build() + , responseTimeoutMillis + ); + } + + private WebClient client(RetryConfig retryConfig, long responseTimeoutMillis) { final Function retryingDecorator = - RetryingClient.builder(retryRuleWithContent) - .responseTimeoutMillisForEachAttempt(responseTimeoutForEach) + RetryingClient.builder(retryConfig) .useRetryAfter(true) - .maxTotalAttempts(maxTotalAttempts) .newDecorator(); return WebClient.builder(server.httpUri()) .factory(clientFactory) .responseTimeoutMillis(responseTimeoutMillis) - .decorator(LoggingClient.newDecorator()) .decorator(retryingDecorator) .build(); } diff --git a/resilience4j2/src/main/java/com/linecorp/armeria/resilience4j/circuitbreaker/client/KeyedResilience4jCircuitBreakerMapping.java b/resilience4j2/src/main/java/com/linecorp/armeria/resilience4j/circuitbreaker/client/KeyedResilience4jCircuitBreakerMapping.java index 5c1e343cf5f..1d19a0b85d5 100644 --- a/resilience4j2/src/main/java/com/linecorp/armeria/resilience4j/circuitbreaker/client/KeyedResilience4jCircuitBreakerMapping.java +++ b/resilience4j2/src/main/java/com/linecorp/armeria/resilience4j/circuitbreaker/client/KeyedResilience4jCircuitBreakerMapping.java @@ -1,7 +1,7 @@ /* - * Copyright 2022 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -16,9 +16,9 @@ package com.linecorp.armeria.resilience4j.circuitbreaker.client; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.host; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.method; -import static com.linecorp.armeria.internal.common.circuitbreaker.CircuitBreakerMappingUtil.path; +import static com.linecorp.armeria.internal.common.RequestContextUtil.host; +import static com.linecorp.armeria.internal.common.RequestContextUtil.method; +import static com.linecorp.armeria.internal.common.RequestContextUtil.path; import com.google.common.base.MoreObjects; diff --git a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/it/client/retry/RetryingRpcClientTest.java b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/it/client/retry/RetryingRpcClientTest.java index 1e56c8b0472..b99b7f07a1c 100644 --- a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/it/client/retry/RetryingRpcClientTest.java +++ b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/it/client/retry/RetryingRpcClientTest.java @@ -1,7 +1,7 @@ /* - * Copyright 2017 LINE Corporation + * Copyright 2025 LY Corporation * - * LINE Corporation licenses this file to you under the Apache License, + * LY Corporation licenses this file to you 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: * @@ -239,16 +239,19 @@ private HelloService.Iface helloClient(RetryConfigMapping mapping) } private HelloService.Iface helloClient(RetryRuleWithContent rule, int maxAttempts) { + return helloClient(RetryConfig.builderForRpc(rule) + .maxTotalAttempts(maxAttempts) + .build()); + } + + private HelloService.Iface helloClient(RetryConfig retryConfig) { return ThriftClients.builder(server.httpUri()) .path("/thrift") - .rpcDecorator( - RetryingRpcClient.builder(RetryConfig.builderForRpc(rule) - .maxTotalAttempts(maxAttempts) - .build()) - .newDecorator()) + .rpcDecorator(RetryingRpcClient.newDecorator(retryConfig)) .build(HelloService.Iface.class); } + private HelloService.Iface helloClient(RetryRuleWithContent rule, int maxAttempts, BlockingQueue logQueue) { return ThriftClients.builder(server.httpUri()) @@ -326,6 +329,29 @@ void shouldGetExceptionWhenFactoryIsClosed() throws Exception { "(?i).*(factory has been closed|not accepting a task).*")); } + @Test + void doNotRetryWhenNoRetryConfigIsGiven() throws Exception { + final HelloService.Iface client = helloClient(RetryConfig.noRetry()); + + when(serviceHandler.hello(anyString())) + .thenThrow(new IllegalArgumentException()) + .thenReturn("world"); + + + final Throwable t = catchThrowable(() -> client.hello("hello")); + + assertThat(t).isInstanceOf(TApplicationException.class); + assertThat(((TApplicationException) t).getType()).isEqualTo(TApplicationException.INTERNAL_ERROR); + + // Verify that the service was called only once. + verify(serviceHandler, only()).hello("hello"); + + // Sleep for 1 second to check if there was another retry. + TimeUnit.SECONDS.sleep(1); + + verify(serviceHandler, only()).hello("hello"); + } + @Test void doNotRetryWhenResponseIsCancelled() throws Exception { final AtomicReference context = new AtomicReference<>();