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);
+    }
+}

Reply via email to