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 e9e15a3589 fix(httpclient): preserve failover with fixed retry backoff 
(#7777)
e9e15a3589 is described below

commit e9e15a35896f993ef5c2a22f9786499c11605292
Author: lymerin <[email protected]>
AuthorDate: Sun Oct 18 00:09:27 2026 +0800

    fix(httpclient): preserve failover with fixed retry backoff (#7777)
---
 .../plugin/httpclient/DefaultRetryStrategy.java    | 114 +++++++++++----------
 .../plugin/httpclient/FixedRetryStrategy.java      |  20 +++-
 .../plugin/httpclient/RetryStrategyTest.java       | 105 +++++++++++++++++++
 3 files changed, 181 insertions(+), 58 deletions(-)

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 799b928f7f..01c9c96f3f 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
@@ -46,6 +46,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 
@@ -84,70 +85,75 @@ public class DefaultRetryStrategy<R> implements 
RetryStrategy<R> {
                     .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));
         }
-        final Set<URI> exclude = new 
HashSet<>(Collections.singletonList(Objects.requireNonNull(exchange.getAttribute(Constants.HTTP_URI))));
-        return resend(clientResponse, exchange, duration, exclude, retryTimes)
-                .onErrorMap(ShenyuException.class, th -> new 
ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
-                        "CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER", th))
-                .onErrorMap(java.util.concurrent.TimeoutException.class, th -> 
new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(), th));
+        return executeFailover(clientResponse, exchange, duration, retryTimes, 
Duration.ZERO, th -> true);
     }
 
-    private Mono<R> resend(final Mono<R> clientResponse,
-                           final ServerWebExchange exchange,
-                           final Duration duration,
-                           final Set<URI> exclude,
-                           final int retryTimes) {
+    /**
+     * Retry eligible failures on another upstream after the configured delay.
+     *
+     * @param clientResponse original request response
+     * @param exchange current exchange
+     * @param duration timeout per request
+     * @param retryTimes number of retries
+     * @param backoff delay before selecting the next upstream
+     * @param retryFilter eligible failures
+     * @return response after failover
+     */
+    Mono<R> executeFailover(final Mono<R> clientResponse, final 
ServerWebExchange exchange, final Duration duration,
+                           final int retryTimes, final Duration backoff, final 
Predicate<Throwable> retryFilter) {
+        final Set<URI> exclude = new 
HashSet<>(Collections.singletonList(Objects.requireNonNull(exchange.getAttribute(Constants.HTTP_URI))));
         Mono<R> result = clientResponse;
         for (int i = 0; i < retryTimes; i++) {
-            result = resend(result, exchange, duration, exclude);
+            result = result.onErrorResume(retryFilter, th -> backoff.isZero()
+                    ? resend(exchange, duration, exclude)
+                    : Mono.delay(backoff).flatMap(ignored -> resend(exchange, 
duration, exclude)));
         }
-        return result;
+        return result.onErrorMap(ShenyuException.class, th -> new 
ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                        "CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER", th))
+                .onErrorMap(java.util.concurrent.TimeoutException.class, th -> 
new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(), th));
     }
 
-    private Mono<R> resend(final Mono<R> response,
-                           final ServerWebExchange exchange,
+    private Mono<R> resend(final ServerWebExchange exchange,
                            final Duration duration,
                            final Set<URI> exclude) {
-        // does it necessary to add backoff interval time ?
-        return response.onErrorResume(th -> {
-            final String selectorId = 
exchange.getAttribute(Constants.DIVIDE_SELECTOR_ID);
-            final String loadBalance = 
exchange.getAttribute(Constants.LOAD_BALANCE);
-            //always query the latest available list
-            final List<Upstream> availableUpstreamList = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selectorId);
-            if (Objects.isNull(availableUpstreamList)) {
-                return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
-            }
-            final List<Upstream> upstreamList = availableUpstreamList
-                    .stream().filter(data -> {
-                        final URI upstreamUri = URI.create(data.buildDomain());
-                        for (URI needToExclude : exclude) {
-                            if (isSameUpstream(needToExclude, upstreamUri)) {
-                                return false;
-                            }
+        final String selectorId = 
exchange.getAttribute(Constants.DIVIDE_SELECTOR_ID);
+        final String loadBalance = 
exchange.getAttribute(Constants.LOAD_BALANCE);
+        //always query the latest available list
+        final List<Upstream> availableUpstreamList = 
UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selectorId);
+        if (Objects.isNull(availableUpstreamList)) {
+            return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
+        }
+        final List<Upstream> upstreamList = availableUpstreamList
+                .stream().filter(data -> {
+                    final URI upstreamUri = URI.create(data.buildDomain());
+                    for (URI needToExclude : exclude) {
+                        if (isSameUpstream(needToExclude, upstreamUri)) {
+                            return false;
                         }
-                        return true;
-                    })
-                    //skip upstreams that are still blocked by the built-in 
circuit breaker
-                    .filter(data -> UpstreamCircuitBreaker.getInstance()
-                            
.isRequestAllowed(UpstreamCircuitBreaker.buildKey(selectorId, data)))
-                    .collect(Collectors.toList());
-            if (upstreamList.isEmpty()) {
-                // no need to retry anymore
-                return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
-            }
-            final Upstream upstream = 
LoadbalancerUtils.getForExchange(upstreamList, loadBalance, exchange);
-            if (Objects.isNull(upstream)) {
-                // no need to retry anymore
-                return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
-            }
-            final URI newUri = RequestUrlUtils.buildRequestUri(exchange, 
upstream.buildDomain());
-            // in order not to affect the next retry call, newUri needs to be 
excluded
-            exclude.add(newUri);
-            final String breakerKey = 
UpstreamCircuitBreaker.buildKey(selectorId, upstream);
-            return UpstreamCircuitBreaker.recordOutcome(httpClientPlugin
-                    .doRequest(exchange, 
exchange.getRequest().getMethod().name(), newUri, 
httpClientPlugin.getCachedRequestBody(exchange))
-                    .timeout(duration, Mono.error(() -> new 
TimeoutException("Response took longer than timeout: " + duration)))
-                    .doOnError(e -> LOG.error(e.getMessage(), e)), breakerKey);
-        });
+                    }
+                    return true;
+                })
+                //skip upstreams that are still blocked by the built-in 
circuit breaker
+                .filter(data -> UpstreamCircuitBreaker.getInstance()
+                        
.isRequestAllowed(UpstreamCircuitBreaker.buildKey(selectorId, data)))
+                .collect(Collectors.toList());
+        if (upstreamList.isEmpty()) {
+            // no need to retry anymore
+            return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
+        }
+        final Upstream upstream = 
LoadbalancerUtils.getForExchange(upstreamList, loadBalance, exchange);
+        if (Objects.isNull(upstream)) {
+            // no need to retry anymore
+            return Mono.error(new 
ShenyuException("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER"));
+        }
+        final URI newUri = RequestUrlUtils.buildRequestUri(exchange, 
upstream.buildDomain());
+        // in order not to affect the next retry call, newUri needs to be 
excluded
+        exclude.add(newUri);
+        final String breakerKey = UpstreamCircuitBreaker.buildKey(selectorId, 
upstream);
+        return UpstreamCircuitBreaker.recordOutcome(httpClientPlugin
+                .doRequest(exchange, exchange.getRequest().getMethod().name(), 
newUri, httpClientPlugin.getCachedRequestBody(exchange))
+                .timeout(duration, Mono.error(() -> new 
TimeoutException("Response took longer than timeout: " + duration)))
+                .doOnError(e -> LOG.error(e.getMessage(), e)), breakerKey);
     }
 
     private boolean isSameUpstream(final URI first, final URI second) {
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 078ab2a862..6ff21713a3 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
@@ -19,6 +19,9 @@ package org.apache.shenyu.plugin.httpclient;
 
 import io.netty.channel.ConnectTimeoutException;
 import io.netty.handler.timeout.ReadTimeoutException;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.enums.RetryEnum;
+import 
org.apache.shenyu.plugin.httpclient.exception.ShenyuUpstreamStatusException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpMethod;
@@ -40,6 +43,8 @@ import java.util.concurrent.TimeoutException;
 public class FixedRetryStrategy<R> implements RetryStrategy<R> {
     private static final Logger LOG = 
LoggerFactory.getLogger(FixedRetryStrategy.class);
 
+    private static final Duration BACKOFF = Duration.ofSeconds(2);
+
     private final AbstractHttpClientPlugin<R> httpClientPlugin;
 
     public FixedRetryStrategy(final AbstractHttpClientPlugin<R> 
httpClientPlugin) {
@@ -56,15 +61,22 @@ public class FixedRetryStrategy<R> implements 
RetryStrategy<R> {
      * @return Response Mono object after retry processing
      */
     public Mono<R> execute(final Mono<R> response, final ServerWebExchange 
exchange, final Duration duration, final int retryTimes) {
-        Retry retrySpec = initFixedBackoff(exchange, retryTimes);
-        Duration totalTimeout = RetryTimeoutUtils.totalTimeout(duration, 
retryTimes, Duration.ofSeconds(2));
-        return response.retryWhen(retrySpec)
+        Mono<R> retriedResponse;
+        if 
(RetryEnum.FAILOVER.getName().equals(exchange.getAttribute(Constants.RETRY_STRATEGY)))
 {
+            retriedResponse = new 
DefaultRetryStrategy<>(httpClientPlugin).executeFailover(response, exchange, 
duration, retryTimes, BACKOFF,
+                    throwable -> 
HttpMethod.GET.equals(exchange.getRequest().getMethod())
+                            && (isTransientFailure(throwable) || throwable 
instanceof ShenyuUpstreamStatusException));
+        } else {
+            retriedResponse = response.retryWhen(initFixedBackoff(exchange, 
retryTimes));
+        }
+        Duration totalTimeout = RetryTimeoutUtils.totalTimeout(duration, 
retryTimes, BACKOFF);
+        return retriedResponse
                 .timeout(totalTimeout, Mono.error(() -> new 
TimeoutException("Retry sequence took longer than timeout: " + totalTimeout)))
                 .doOnError(e -> LOG.error(e.getMessage(), e));
     }
 
     private Retry initFixedBackoff(final ServerWebExchange exchange, final int 
retryTimes) {
-        return Retry.fixedDelay(retryTimes, Duration.ofSeconds(2))
+        return Retry.fixedDelay(retryTimes, BACKOFF)
                 .filter(throwable -> 
HttpMethod.GET.equals(exchange.getRequest().getMethod()) && 
isTransientFailure(throwable))
                 .onRetryExhaustedThrow((retrySpec, retrySignal) -> 
retrySignal.failure());
     }
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 9a0a0a4a9a..6649a340f7 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
@@ -22,7 +22,9 @@ import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.enums.RetryEnum;
 import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
 import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
 import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
+import 
org.apache.shenyu.plugin.httpclient.exception.ShenyuUpstreamStatusException;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -32,6 +34,7 @@ import org.junit.jupiter.params.provider.CsvSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.MockedStatic;
 import org.springframework.http.HttpStatus;
+import org.springframework.core.io.buffer.DataBuffer;
 import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
 import org.springframework.mock.web.server.MockServerWebExchange;
 import org.springframework.web.server.ResponseStatusException;
@@ -41,12 +44,16 @@ import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
 
+import java.net.ConnectException;
 import java.net.URI;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -343,6 +350,104 @@ public class RetryStrategyTest {
         assertEquals(1, attempts.get());
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testFixedFailoverWaitsBeforeSendingToStandby(final boolean 
serverError) {
+        ServerWebExchange exchange = 
createFixedFailoverExchange(HttpMethod.GET, 1);
+        URI primaryUri = exchange.getAttribute(Constants.HTTP_URI);
+        URI standbyUri = URI.create("http://localhost:8081/test?mode=retry";);
+        Throwable failure = serverError ? new 
ShenyuUpstreamStatusException(503) : new ConnectException("connection refused");
+        List<URI> attempts = new ArrayList<>();
+        AbstractHttpClientPlugin<String> plugin = recordingPlugin(attempts,
+                uri -> primaryUri.equals(uri) ? Mono.error(failure) : 
Mono.just("success"));
+        Upstream standby = Upstream.builder().url("localhost:8081").build();
+        UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+        
when(cacheManager.findUpstreamListBySelectorId("fixed-failover")).thenReturn(Collections.singletonList(standby));
+
+        try (MockedStatic<UpstreamCacheManager> cacheMock = 
mockStatic(UpstreamCacheManager.class)) {
+            
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+            StepVerifier.withVirtualTime(() -> plugin.execute(exchange, 
ignored -> Mono.empty()))
+                    .thenAwait(Duration.ofMillis(1999))
+                    .then(() -> {
+                        assertEquals(Collections.singletonList(primaryUri), 
attempts);
+                        verifyNoInteractions(cacheManager);
+                    })
+                    .thenAwait(Duration.ofMillis(1))
+                    .verifyComplete();
+        }
+        assertEquals(Arrays.asList(primaryUri, standbyUri), attempts);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"POST", "GET"})
+    void testFixedFailoverPreservesRetryEligibility(final String method) {
+        ServerWebExchange exchange = 
createFixedFailoverExchange(HttpMethod.valueOf(method), 1);
+        Throwable failure = "POST".equals(method) ? new 
ConnectException("connection refused") : new 
IllegalArgumentException("permanent failure");
+        List<URI> attempts = new ArrayList<>();
+        AbstractHttpClientPlugin<String> plugin = recordingPlugin(attempts, 
uri -> Mono.error(failure));
+
+        StepVerifier.create(plugin.execute(exchange, ignored -> Mono.empty()))
+                .expectErrorMatches(error -> error == failure)
+                .verify();
+        
assertEquals(Collections.singletonList(exchange.getAttribute(Constants.HTTP_URI)),
 attempts);
+    }
+
+    @Test
+    void testFixedFailoverRetriesAcrossDistinctUpstreams() {
+        ServerWebExchange exchange = 
createFixedFailoverExchange(HttpMethod.GET, 2);
+        URI primaryUri = exchange.getAttribute(Constants.HTTP_URI);
+        URI standbyUri = URI.create("http://localhost:8081/test?mode=retry";);
+        URI healthyUri = URI.create("http://localhost:8082/test?mode=retry";);
+        ShenyuUpstreamStatusException failure = new 
ShenyuUpstreamStatusException(503);
+        List<URI> attempts = new ArrayList<>();
+        AbstractHttpClientPlugin<String> plugin = recordingPlugin(attempts,
+                uri -> healthyUri.equals(uri) ? Mono.just("success") : 
Mono.error(failure));
+        Upstream primary = Upstream.builder().url("localhost:8080").build();
+        Upstream standby = Upstream.builder().url("localhost:8081").build();
+        Upstream healthy = Upstream.builder().url("localhost:8082").build();
+        UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+        when(cacheManager.findUpstreamListBySelectorId("fixed-failover"))
+                .thenReturn(Arrays.asList(primary, standby), 
Arrays.asList(primary, standby, healthy));
+
+        try (MockedStatic<UpstreamCacheManager> cacheMock = 
mockStatic(UpstreamCacheManager.class)) {
+            
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+            StepVerifier.withVirtualTime(() -> plugin.execute(exchange, 
ignored -> Mono.empty()))
+                    .thenAwait(Duration.ofSeconds(4))
+                    .verifyComplete();
+        } finally {
+            
UpstreamCircuitBreaker.getInstance().reset(UpstreamCircuitBreaker.buildKey("fixed-failover",
 standby));
+        }
+        assertEquals(Arrays.asList(primaryUri, standbyUri, healthyUri), 
attempts);
+    }
+
+    private ServerWebExchange createFixedFailoverExchange(final HttpMethod 
method, final int retryTimes) {
+        ServerWebExchange exchange = 
createFailoverExchange("http://localhost:8080/test?mode=retry";)
+                .mutate().request(request -> request.method(method)).build();
+        exchange.getAttributes().put(Constants.CONTEXT, new ShenyuContext());
+        exchange.getAttributes().put(Constants.HTTP_RETRY_BACK_OFF_SPEC, 
"fixed");
+        exchange.getAttributes().put(Constants.HTTP_RETRY, retryTimes);
+        exchange.getAttributes().put(Constants.HTTP_TIME_OUT, 10000L);
+        exchange.getAttributes().put(Constants.DIVIDE_SELECTOR_ID, 
"fixed-failover");
+        return exchange;
+    }
+
+    private AbstractHttpClientPlugin<String> recordingPlugin(final List<URI> 
attempts, final Function<URI, Mono<String>> response) {
+        return new AbstractHttpClientPlugin<String>(1024) {
+            @Override
+            protected Mono<String> doRequest(final ServerWebExchange exchange, 
final String httpMethod, final URI uri, final Flux<DataBuffer> body) {
+                return Mono.defer(() -> {
+                    attempts.add(uri);
+                    return response.apply(uri);
+                });
+            }
+
+            @Override
+            public int getOrder() {
+                return 0;
+            }
+        };
+    }
+
     private ServerWebExchange createFailoverExchange(final String currentUri) {
         ServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("/test?mode=retry"));
         exchange.getAttributes().put(Constants.HTTP_URI, 
URI.create(currentUri));

Reply via email to