Skip to content

Commit e05cc08

Browse files
committed
Introduce RetryLimiter (line#6409)
Motivation: This changeset attempts to solve the same problem as line#6318. Retry limiting is a concept which limits the number of retries in case a system undergoes a prolonged period of service degradation. gRPC offers a [token-based](https://github.com/grpc/proposal/blob/master/A6-client-retries.md#throttling-retry-attempts-and-hedged-rpcs) configuration which limits retries depending on certain predicates, whereas envoy offers a simple [concurrency limiting configuration](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/circuit_breaker.proto#envoy-v3-api-msg-config-cluster-v3-circuitbreakers-thresholds-retrybudget) based on the number of active retries. To support token-based retry limiters, I propose that `RetryDecision#permits` is added as metadata which could signal to `RetryLimiter` whether retries should be further made. This could be useful for systems behind load balancers, as the load balancer may return certain status codes depending on the health upstream. To support simple concurrency-based retry limiters, I propose that a `RetryLimiter#shouldRetry` method is called right before a retry is executed. Modifications: - Introduced `RetryLimiter` which acts as an extension to dynamically limit retries. - `RetryLimiter#shouldRetry` decides whether a retry should be executed - `RetryLimiter#handleDecision` is invoked when a `RetryDecision` is made. `RetryDecision#permits` may be used to update the internal state and decide whether retries should be allowed. - Added `RetryDecision#permits` - Added APIs so users can set `RetryLimiter` to `RetryConfig` Result: - Users can specify `RetryLimiter` to limit retry requests. - Closes line#6282 <!-- Visit this URL to learn more about how to write a pull request description: https://armeria.dev/community/developer-guide#how-to-write-pull-request-description -->
1 parent 81964a0 commit e05cc08

File tree

15 files changed

+779
-29
lines changed

15 files changed

+779
-29
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.client.retry;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
21+
import java.util.concurrent.atomic.AtomicLong;
22+
23+
import com.google.common.base.MoreObjects;
24+
25+
import com.linecorp.armeria.client.ClientRequestContext;
26+
27+
final class ConcurrencyBasedRetryLimiter implements RetryLimiter {
28+
29+
private final long maxRequests;
30+
private final AtomicLong activeRequests = new AtomicLong();
31+
32+
ConcurrencyBasedRetryLimiter(long maxRequests) {
33+
checkArgument(maxRequests > 0, "maxRequests must be positive: %s.", maxRequests);
34+
this.maxRequests = maxRequests;
35+
}
36+
37+
@Override
38+
public boolean shouldRetry(ClientRequestContext ctx) {
39+
final long cnt = activeRequests.incrementAndGet();
40+
if (cnt > maxRequests) {
41+
activeRequests.decrementAndGet();
42+
return false;
43+
}
44+
ctx.log().whenComplete().thenRun(activeRequests::decrementAndGet);
45+
return true;
46+
}
47+
48+
@Override
49+
public String toString() {
50+
return MoreObjects.toStringHelper(this)
51+
.add("activeRequests", activeRequests)
52+
.add("maxRequests", maxRequests)
53+
.toString();
54+
}
55+
}

core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ static <T extends Response> RetryConfigBuilder<T> builder0(
7878
private final int maxTotalAttempts;
7979
private final long responseTimeoutMillisForEachAttempt;
8080
private final int maxContentLength;
81+
private final RetryLimiter retryLimiter;
8182

8283
@Nullable
8384
private final RetryRule retryRule;
@@ -88,27 +89,30 @@ static <T extends Response> RetryConfigBuilder<T> builder0(
8889
@Nullable
8990
private RetryRuleWithContent<T> fromRetryRule;
9091

91-
RetryConfig(RetryRule retryRule, int maxTotalAttempts, long responseTimeoutMillisForEachAttempt) {
92+
RetryConfig(RetryRule retryRule, int maxTotalAttempts, long responseTimeoutMillisForEachAttempt,
93+
RetryLimiter retryLimiter) {
9294
this(requireNonNull(retryRule, "retryRule"), null,
93-
maxTotalAttempts, responseTimeoutMillisForEachAttempt, 0);
95+
maxTotalAttempts, responseTimeoutMillisForEachAttempt, 0, retryLimiter);
9496
checkArguments(maxTotalAttempts, responseTimeoutMillisForEachAttempt);
9597
}
9698

9799
RetryConfig(
98100
RetryRuleWithContent<T> retryRuleWithContent,
99101
int maxContentLength,
100102
int maxTotalAttempts,
101-
long responseTimeoutMillisForEachAttempt) {
103+
long responseTimeoutMillisForEachAttempt,
104+
RetryLimiter retryLimiter) {
102105
this(null, requireNonNull(retryRuleWithContent, "retryRuleWithContent"),
103-
maxTotalAttempts, responseTimeoutMillisForEachAttempt, maxContentLength);
106+
maxTotalAttempts, responseTimeoutMillisForEachAttempt, maxContentLength, retryLimiter);
104107
}
105108

106109
private RetryConfig(
107110
@Nullable RetryRule retryRule,
108111
@Nullable RetryRuleWithContent<T> retryRuleWithContent,
109112
int maxTotalAttempts,
110113
long responseTimeoutMillisForEachAttempt,
111-
int maxContentLength) {
114+
int maxContentLength, RetryLimiter retryLimiter) {
115+
this.retryLimiter = new RetryLimiters.CatchingRetryLimiter(retryLimiter);
112116
checkArguments(maxTotalAttempts, responseTimeoutMillisForEachAttempt);
113117
this.retryRule = retryRule;
114118
this.retryRuleWithContent = retryRuleWithContent;
@@ -147,7 +151,8 @@ public RetryConfigBuilder<T> toBuilder() {
147151
}
148152
return builder
149153
.maxTotalAttempts(maxTotalAttempts)
150-
.responseTimeoutMillisForEachAttempt(responseTimeoutMillisForEachAttempt);
154+
.responseTimeoutMillisForEachAttempt(responseTimeoutMillisForEachAttempt)
155+
.retryLimiter(retryLimiter);
151156
}
152157

153158
/**
@@ -197,6 +202,13 @@ public boolean needsContentInRule() {
197202
return retryRuleWithContent != null;
198203
}
199204

205+
/**
206+
* Returns the configured {@link RetryLimiter}.
207+
*/
208+
public RetryLimiter retryLimiter() {
209+
return retryLimiter;
210+
}
211+
200212
/**
201213
* Returns whether the associated {@link RetryRule} or {@link RetryRuleWithContent} requires
202214
* response trailers.

core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigBuilder.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.google.common.base.MoreObjects;
2525
import com.google.common.base.MoreObjects.ToStringHelper;
2626

27+
import com.linecorp.armeria.client.retry.RetryLimiters.AlwaysRetryLimiter;
2728
import com.linecorp.armeria.common.Flags;
2829
import com.linecorp.armeria.common.Response;
2930
import com.linecorp.armeria.common.annotation.Nullable;
@@ -42,6 +43,7 @@ public final class RetryConfigBuilder<T extends Response> {
4243
private final RetryRule retryRule;
4344
@Nullable
4445
private final RetryRuleWithContent<T> retryRuleWithContent;
46+
private RetryLimiter retryLimiter = AlwaysRetryLimiter.INSTANCE;
4547

4648
/**
4749
* Creates a {@link RetryConfigBuilder} with this {@link RetryRule}.
@@ -111,19 +113,30 @@ public RetryConfigBuilder<T> responseTimeoutForEachAttempt(Duration responseTime
111113
return this;
112114
}
113115

116+
/**
117+
* Sets a {@link RetryLimiter} which may limit retry requests.
118+
* @see RetryLimiter
119+
*/
120+
public RetryConfigBuilder<T> retryLimiter(RetryLimiter retryLimiter) {
121+
this.retryLimiter = requireNonNull(retryLimiter, "retryLimiter");
122+
return this;
123+
}
124+
114125
/**
115126
* Returns a newly-created {@link RetryConfig} from this {@link RetryConfigBuilder}'s values.
116127
*/
117128
public RetryConfig<T> build() {
118129
if (retryRule != null) {
119-
return new RetryConfig<>(retryRule, maxTotalAttempts, responseTimeoutMillisForEachAttempt);
130+
return new RetryConfig<>(retryRule, maxTotalAttempts, responseTimeoutMillisForEachAttempt,
131+
retryLimiter);
120132
}
121133
assert retryRuleWithContent != null;
122134
return new RetryConfig<>(
123135
retryRuleWithContent,
124136
maxContentLength,
125137
maxTotalAttempts,
126-
responseTimeoutMillisForEachAttempt);
138+
responseTimeoutMillisForEachAttempt,
139+
retryLimiter);
127140
}
128141

129142
@Override
@@ -139,6 +152,7 @@ ToStringHelper toStringHelper() {
139152
.add("retryRuleWithContent", retryRuleWithContent)
140153
.add("maxTotalAttempts", maxTotalAttempts)
141154
.add("responseTimeoutMillisForEachAttempt", responseTimeoutMillisForEachAttempt)
142-
.add("maxContentLength", maxContentLength);
155+
.add("maxContentLength", maxContentLength)
156+
.add("retryLimiter", retryLimiter);
143157
}
144158
}

core/src/main/java/com/linecorp/armeria/client/retry/RetryDecision.java

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import static java.util.Objects.requireNonNull;
2020

21+
import com.google.common.base.MoreObjects;
22+
import com.google.common.base.MoreObjects.ToStringHelper;
23+
2124
import com.linecorp.armeria.common.annotation.Nullable;
2225

2326
/**
@@ -26,27 +29,44 @@
2629
*/
2730
public final class RetryDecision {
2831

29-
private static final RetryDecision NO_RETRY = new RetryDecision(null);
30-
private static final RetryDecision NEXT = new RetryDecision(null);
31-
static final RetryDecision DEFAULT = new RetryDecision(Backoff.ofDefault());
32+
private static final RetryDecision NO_RETRY = new RetryDecision(null, -1);
33+
private static final RetryDecision NEXT = new RetryDecision(null, 0);
34+
static final RetryDecision DEFAULT = new RetryDecision(Backoff.ofDefault(), 1);
3235

3336
/**
3437
* Returns a {@link RetryDecision} that retries with the specified {@link Backoff}.
38+
* The permits will be {@code 1} by default.
3539
*/
3640
public static RetryDecision retry(Backoff backoff) {
37-
if (backoff == Backoff.ofDefault()) {
41+
return retry(backoff, 1);
42+
}
43+
44+
/**
45+
* Returns a {@link RetryDecision} that retries with the specified {@link Backoff}.
46+
*/
47+
@SuppressWarnings("FloatingPointEquality")
48+
public static RetryDecision retry(Backoff backoff, double permits) {
49+
if (backoff == DEFAULT.backoff() && permits == DEFAULT.permits()) {
3850
return DEFAULT;
3951
}
40-
return new RetryDecision(requireNonNull(backoff, "backoff"));
52+
return new RetryDecision(requireNonNull(backoff, "backoff"), permits);
4153
}
4254

4355
/**
4456
* Returns a {@link RetryDecision} that never retries.
57+
* The permits will be {@code -1} by default.
4558
*/
4659
public static RetryDecision noRetry() {
4760
return NO_RETRY;
4861
}
4962

63+
/**
64+
* Returns a {@link RetryDecision} that never retries.
65+
*/
66+
public static RetryDecision noRetry(double permits) {
67+
return new RetryDecision(null, permits);
68+
}
69+
5070
/**
5171
* Returns a {@link RetryDecision} that skips the current {@link RetryRule} and
5272
* tries to retry with the next {@link RetryRule}.
@@ -57,24 +77,41 @@ public static RetryDecision next() {
5777

5878
@Nullable
5979
private final Backoff backoff;
80+
private final double permits;
6081

61-
private RetryDecision(@Nullable Backoff backoff) {
82+
private RetryDecision(@Nullable Backoff backoff, double permits) {
6283
this.backoff = backoff;
84+
this.permits = permits;
6385
}
6486

6587
@Nullable
6688
Backoff backoff() {
6789
return backoff;
6890
}
6991

92+
/**
93+
* The number of permits associated with this {@link RetryDecision}.
94+
* This may be used by {@link RetryLimiter} to determine whether retry requests should
95+
* be limited or not. The semantics of whether or how the returned value affects {@link RetryLimiter}
96+
* depends on what type of {@link RetryLimiter} is used.
97+
*/
98+
public double permits() {
99+
return permits;
100+
}
101+
70102
@Override
71103
public String toString() {
72-
if (this == NO_RETRY) {
73-
return "RetryDecision(NO_RETRY)";
74-
} else if (this == NEXT) {
75-
return "RetryDecision(NEXT)";
104+
final ToStringHelper stringHelper = MoreObjects.toStringHelper(this);
105+
if (this == NEXT) {
106+
stringHelper.add("type", "NEXT");
107+
} else if (backoff != null) {
108+
stringHelper.add("type", "RETRY");
76109
} else {
77-
return "RetryDecision(RETRY(" + backoff + "))";
110+
stringHelper.add("type", "NO_RETRY");
78111
}
112+
return stringHelper.omitNullValues()
113+
.add("backoff", backoff)
114+
.add("permits", permits)
115+
.toString();
79116
}
80117
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.client.retry;
18+
19+
import com.linecorp.armeria.common.Flags;
20+
21+
/**
22+
* An exception thrown when a retry is limited by a {@link RetryLimiter}.
23+
*/
24+
public final class RetryLimitedException extends RuntimeException {
25+
26+
private static final long serialVersionUID = 7203512016805562689L;
27+
28+
private static final RetryLimitedException INSTANCE = new RetryLimitedException(false);
29+
30+
/**
31+
* Returns an instance of {@link RetryLimitedException} sampled by {@link Flags#verboseExceptionSampler()}.
32+
*/
33+
public static RetryLimitedException of() {
34+
return isSampled() ? new RetryLimitedException(true) : INSTANCE;
35+
}
36+
37+
private RetryLimitedException(boolean enableSuppression) {
38+
super(null, null, enableSuppression, isSampled());
39+
}
40+
41+
private static boolean isSampled() {
42+
return Flags.verboseExceptionSampler().isSampled(RetryLimitedException.class);
43+
}
44+
}

0 commit comments

Comments
 (0)