This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new d6db65a1aa fix(httpclient): budget deadlines for complete retry
sequences (#7266)
d6db65a1aa is described below
commit d6db65a1aa22ee821b693208bd6cb810709f526d
Author: Liming Deng <[email protected]>
AuthorDate: Thu Oct 1 05:53:33 2026 +0800
fix(httpclient): budget deadlines for complete retry sequences (#7266)
---
RELEASE-NOTES.md | 13 +++-
.../plugin/httpclient/DefaultRetryStrategy.java | 2 +
.../ExponentialRetryBackoffStrategy.java | 3 +-
.../plugin/httpclient/FixedRetryStrategy.java | 3 +-
.../plugin/httpclient/RetryTimeoutUtils.java | 41 ++++++++++++
.../plugin/httpclient/RetryStrategyTest.java | 3 +-
.../plugin/httpclient/RetryTimeoutBudgetTest.java | 78 ++++++++++++++++++++++
7 files changed, 138 insertions(+), 5 deletions(-)
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 9b9e5eb010..c573a17e0f 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -2,7 +2,18 @@
### Behavior Changes
-1. The HTTP client now defaults to a fixed connection pool. Connection
acquisition waits up to 3 seconds, and Reactor Netty bounds pending
acquisitions to twice the configured maximum connection count. Set
`shenyu.httpclient.pool.type=ELASTIC` to retain the previous unbounded behavior.
+- HTTP retry strategies budget the entire sequence separately from each
attempt:
+ `(retryTimes + 1) * attemptTimeout + retryTimes * maximumBackoff`.
+ With a 3-second attempt timeout and 3 retries, the `current` strategy has a
+ conservative 72-second ceiling (20-second maximum backoff), fixed delay has
+ an 18-second ceiling (2-second delay), and exponential backoff has a
27-second
+ ceiling (5-second maximum backoff). Actual retry delays may be shorter.
+ Include this envelope when configuring caller and ingress deadlines.
+- For the `current` strategy, exceeding the aggregate budget returns HTTP 504
+ with `Retry sequence took longer than timeout: ...`. Exhausting the retry
+ count returns HTTP 408. The aggregate ceiling now also bounds a source that
+ never completes; it does not replace the per-attempt response timeout.
+- The HTTP client now defaults to a fixed connection pool. Connection
acquisition waits up to 3 seconds, and Reactor Netty bounds pending
acquisitions to twice the configured maximum connection count. Set
`shenyu.httpclient.pool.type=ELASTIC` to retain the previous unbounded behavior.
## [v2.7.0]- 2024-12-23
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
index 0242df1057..a52e68fedb 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/DefaultRetryStrategy.java
@@ -75,7 +75,9 @@ public class DefaultRetryStrategy<R> implements
RetryStrategy<R> {
.onRetryExhaustedThrow((retryBackoffSpecErr, retrySignal)
-> {
throw new ShenyuTimeoutException("Request timeout, the
maximum number of retry times has been exceeded");
});
+ Duration totalTimeout = RetryTimeoutUtils.totalTimeout(duration,
retryTimes, Duration.ofSeconds(20));
return clientResponse.retryWhen(retryBackoffSpec)
+ .timeout(totalTimeout, Mono.error(() -> new
TimeoutException("Retry sequence took longer than timeout: " + totalTimeout)))
.onErrorMap(ShenyuTimeoutException.class, th -> new
ResponseStatusException(HttpStatus.REQUEST_TIMEOUT, th.getMessage(), th))
.onErrorMap(java.util.concurrent.TimeoutException.class,
th -> new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(),
th));
}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/ExponentialRetryBackoffStrategy.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/ExponentialRetryBackoffStrategy.java
index eb72020bfe..346d65129e 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/ExponentialRetryBackoffStrategy.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/ExponentialRetryBackoffStrategy.java
@@ -50,8 +50,9 @@ public class ExponentialRetryBackoffStrategy<R> implements
RetryStrategy<R> {
*/
public Mono<R> execute(final Mono<R> response, final ServerWebExchange
exchange, final Duration duration, final int retryTimes) {
RetryBackoffSpec retrySpec = initDefaultBackoff(retryTimes);
+ Duration totalTimeout = RetryTimeoutUtils.totalTimeout(duration,
retryTimes, Duration.ofSeconds(5));
return response.retryWhen(retrySpec)
- .timeout(duration, Mono.error(() -> new
java.util.concurrent.TimeoutException("Response took longer than timeout: " +
duration)))
+ .timeout(totalTimeout, Mono.error(() -> new
java.util.concurrent.TimeoutException("Retry sequence took longer than timeout:
" + totalTimeout)))
.doOnError(e -> LOG.error(e.getMessage(), e));
}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/FixedRetryStrategy.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/FixedRetryStrategy.java
index 2a71030648..dad179211b 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/FixedRetryStrategy.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/FixedRetryStrategy.java
@@ -49,8 +49,9 @@ public class FixedRetryStrategy<R> implements
RetryStrategy<R> {
*/
public Mono<R> execute(final Mono<R> response, final ServerWebExchange
exchange, final Duration duration, final int retryTimes) {
Retry retrySpec = initFixedBackoff(retryTimes);
+ Duration totalTimeout = RetryTimeoutUtils.totalTimeout(duration,
retryTimes, Duration.ofSeconds(2));
return response.retryWhen(retrySpec)
- .timeout(duration, Mono.error(() -> new
java.util.concurrent.TimeoutException("Response took longer than timeout: " +
duration)))
+ .timeout(totalTimeout, Mono.error(() -> new
java.util.concurrent.TimeoutException("Retry sequence took longer than timeout:
" + totalTimeout)))
.doOnError(e -> LOG.error(e.getMessage(), e));
}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutUtils.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutUtils.java
new file mode 100644
index 0000000000..65ea25c795
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/main/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutUtils.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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
+ *
+ * http://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.apache.shenyu.plugin.httpclient;
+
+import java.time.Duration;
+
+/**
+ * Bounds a retry sequence while allowing each attempt and its maximum backoff.
+ */
+final class RetryTimeoutUtils {
+
+ private RetryTimeoutUtils() {
+ }
+
+ static Duration totalTimeout(final Duration attemptTimeout, final int
retryTimes, final Duration maxBackoff) {
+ long retries = Math.max(0, retryTimes);
+ try {
+ long attempts = Math.multiplyExact(attemptTimeout.toNanos(),
retries + 1);
+ long backoffs = Math.multiplyExact(maxBackoff.toNanos(), retries);
+ return Duration.ofNanos(Math.addExact(attempts, backoffs));
+ } catch (ArithmeticException ex) {
+ return Duration.ofNanos(Long.MAX_VALUE);
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
index 9429d2a55b..14daf1f322 100644
---
a/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryStrategyTest.java
@@ -17,7 +17,6 @@
package org.apache.shenyu.plugin.httpclient;
-import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@@ -99,7 +98,7 @@ public class RetryStrategyTest {
// Use StepVerifier to verify results
StepVerifier.create(result)
- .expectError(TimeoutException.class)
+ .expectErrorMatches(reactor.core.Exceptions::isRetryExhausted)
.verify();
}
}
diff --git
a/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutBudgetTest.java
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutBudgetTest.java
new file mode 100644
index 0000000000..2ec12e1c1d
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-httpclient/src/test/java/org/apache/shenyu/plugin/httpclient/RetryTimeoutBudgetTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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
+ *
+ * http://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.apache.shenyu.plugin.httpclient;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.time.Duration;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+
+class RetryTimeoutBudgetTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"fixed", "exponential", "current"})
+ void allowsEveryConfiguredRetryDespiteBackoffExceedingAttemptTimeout(final
String type) {
+ AtomicInteger attempts = new AtomicInteger();
+ StepVerifier.withVirtualTime(() ->
strategy(type).execute(Mono.defer(() -> attempts.incrementAndGet() < 4
+ ? Mono.error(new IllegalStateException("retry")) :
Mono.just("success")),
+ MockServerWebExchange.from(MockServerHttpRequest.get("/")),
Duration.ofMillis(100), 3))
+ .thenAwait(Duration.ofMinutes(2))
+ .expectNext("success")
+ .verifyComplete();
+ assertEquals(4, attempts.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"fixed", "exponential", "current"})
+ void boundsTheEntireSequenceEvenIfSourceNeverCompletes(final String type) {
+ StepVerifier.withVirtualTime(() -> strategy(type).execute(Mono.never(),
+ MockServerWebExchange.from(MockServerHttpRequest.get("/")),
Duration.ofMillis(100), 3))
+ .thenAwait(Duration.ofMinutes(2))
+ .expectErrorMatches(error -> error instanceof TimeoutException
|| error.getCause() instanceof TimeoutException)
+ .verify();
+ }
+
+ @Test
+ void calculatesBudgetAndSaturatesOverflow() {
+ assertEquals(Duration.ofSeconds(18),
RetryTimeoutUtils.totalTimeout(Duration.ofSeconds(3), 3,
Duration.ofSeconds(2)));
+ assertEquals(Duration.ofSeconds(3),
RetryTimeoutUtils.totalTimeout(Duration.ofSeconds(3), 0,
Duration.ofSeconds(20)));
+ assertEquals(Duration.ofNanos(Long.MAX_VALUE),
RetryTimeoutUtils.totalTimeout(Duration.ofSeconds(Long.MAX_VALUE),
Integer.MAX_VALUE, Duration.ofSeconds(20)));
+ }
+
+ private RetryStrategy<String> strategy(final String type) {
+ AbstractHttpClientPlugin<String> plugin =
mock(AbstractHttpClientPlugin.class);
+ if ("fixed".equals(type)) {
+ return new FixedRetryStrategy<>(plugin);
+ }
+ if ("exponential".equals(type)) {
+ return new ExponentialRetryBackoffStrategy<>(plugin);
+ }
+ return new DefaultRetryStrategy<>(plugin);
+ }
+}