This is an automated email from the ASF dual-hosted git repository.

dmsolr pushed a commit to branch fix/p2c-failover-inflight
in repository https://gitbox.apache.org/repos/asf/shenyu.git

commit 23ce9f91679cb682a7cba598ac7a4955f6f04ee9
Author: Luke Haochao Zhuang <[email protected]>
AuthorDate: Thu Oct 22 15:37:05 2026 +0800

    fix: release P2C inflight on failover retry upstream
    
    DefaultRetryStrategy.resend re-selects a retry upstream through 
LoadbalancerUtils.getForExchange, which increments the P2C inflight counter of 
the newly picked upstream. Divide's completion callback only captures the 
initially selected upstream, so nothing released the retry attempt's increment. 
Repeated failovers therefore inflated inflight on retry destinations and 
distorted subsequent P2C load estimates.
    
    Attach a doFinally cleanup that decrements the retry upstream's inflight on 
success, failure and cancellation, guarded to the P2C algorithm (the only 
balancer that increments on selection), so every P2C attempt keeps paired 
accounting on its own selected upstream.
    
    Stacked on the #7974 singleton-selection fix so the release is also correct 
when a single retry candidate remains and P2C selection is short-circuited.
    
    Fixes #7975
---
 .../plugin/httpclient/DefaultRetryStrategy.java    | 13 ++++-
 .../plugin/httpclient/RetryStrategyTest.java       | 57 ++++++++++++++++++++++
 2 files changed, 68 insertions(+), 2 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 01c9c96f3f..ab63834ade 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
@@ -18,6 +18,7 @@
 package org.apache.shenyu.plugin.httpclient;
 
 import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.enums.LoadBalanceEnum;
 import org.apache.shenyu.common.enums.RetryEnum;
 import org.apache.shenyu.common.exception.ShenyuException;
 import org.apache.shenyu.common.utils.UriUtils;
@@ -150,10 +151,18 @@ public class DefaultRetryStrategy<R> implements 
RetryStrategy<R> {
         // 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
+        // Selecting the retry upstream above goes through the load balancer, 
which increments the P2C
+        // inflight counter of the newly picked upstream. Divide's completion 
callback only captures the
+        // initially selected upstream, so release this attempt's increment to 
keep P2C accounting paired
+        // across success, failure and cancellation of the failover. See #7975.
+        Mono<R> retryResponse = 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);
+                .doOnError(e -> LOG.error(e.getMessage(), e));
+        if (LoadBalanceEnum.P2C.getName().equals(loadBalance)) {
+            retryResponse = retryResponse.doFinally(signalType -> 
upstream.getInflight().decrementAndGet());
+        }
+        return UpstreamCircuitBreaker.recordOutcome(retryResponse, breakerKey);
     }
 
     private boolean isSameUpstream(final URI first, final URI second) {
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 6649a340f7..525c0cdfb4 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
@@ -194,6 +194,63 @@ public class RetryStrategyTest {
         assertEquals(URI.create(standby.buildDomain() + "/test?mode=retry"), 
uriCaptor.getValue());
     }
 
+    @Test
+    void testFailoverReleasesP2cInflightOnMultiCandidateRetry() {
+        AbstractHttpClientPlugin<String> plugin = 
mock(AbstractHttpClientPlugin.class);
+        ServerWebExchange exchange = 
createFailoverExchange("http://localhost:8080/test?mode=retry";);
+        exchange.getAttributes().put(Constants.LOAD_BALANCE, "p2c");
+        exchange.getAttributes().put(Constants.DIVIDE_SELECTOR_ID, 
"selector-7975-multi");
+        Upstream failed = Upstream.builder().url("localhost:8080").build();
+        Upstream standbyA = 
Upstream.builder().protocol("http://";).url("localhost:8081").build();
+        Upstream standbyB = 
Upstream.builder().protocol("http://";).url("localhost:8082").build();
+        UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+        
when(cacheManager.findUpstreamListBySelectorId("selector-7975-multi")).thenReturn(Arrays.asList(failed,
 standbyA, standbyB));
+        when(plugin.getCachedRequestBody(exchange)).thenReturn(Flux.empty());
+        when(plugin.doRequest(eq(exchange), eq("GET"), any(URI.class), 
any())).thenReturn(Mono.just("success"));
+        final long baselineA = standbyA.getInflight().get();
+        final long baselineB = standbyB.getInflight().get();
+
+        try (MockedStatic<UpstreamCacheManager> cacheMock = 
mockStatic(UpstreamCacheManager.class)) {
+            
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+            StepVerifier.create(new 
DefaultRetryStrategy<>(plugin).execute(Mono.error(new 
TimeoutException("upstream failed")),
+                            exchange, Duration.ofSeconds(5), 2))
+                    .expectNext("success")
+                    .verifyComplete();
+        }
+        // Two candidates remain after exclusion, so failover takes P2C's 
doSelect path and increments the
+        // picked standby. The retry completion cleanup must release that 
increment, otherwise repeated
+        // failovers inflate inflight on retry destinations and distort later 
load estimates. See #7975.
+        assertEquals(baselineA, standbyA.getInflight().get());
+        assertEquals(baselineB, standbyB.getInflight().get());
+    }
+
+    @Test
+    void testFailoverReleasesP2cInflightOnSingletonRetry() {
+        AbstractHttpClientPlugin<String> plugin = 
mock(AbstractHttpClientPlugin.class);
+        ServerWebExchange exchange = 
createFailoverExchange("http://localhost:8080/test?mode=retry";);
+        exchange.getAttributes().put(Constants.LOAD_BALANCE, "p2c");
+        exchange.getAttributes().put(Constants.DIVIDE_SELECTOR_ID, 
"selector-7975-single");
+        Upstream failed = Upstream.builder().url("localhost:8080").build();
+        Upstream onlyStandby = 
Upstream.builder().protocol("http://";).url("localhost:8083").build();
+        UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+        
when(cacheManager.findUpstreamListBySelectorId("selector-7975-single")).thenReturn(Arrays.asList(failed,
 onlyStandby));
+        when(plugin.getCachedRequestBody(exchange)).thenReturn(Flux.empty());
+        when(plugin.doRequest(eq(exchange), eq("GET"), any(URI.class), 
any())).thenReturn(Mono.just("success"));
+        final long baseline = onlyStandby.getInflight().get();
+
+        try (MockedStatic<UpstreamCacheManager> cacheMock = 
mockStatic(UpstreamCacheManager.class)) {
+            
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+            StepVerifier.create(new 
DefaultRetryStrategy<>(plugin).execute(Mono.error(new 
TimeoutException("upstream failed")),
+                            exchange, Duration.ofSeconds(5), 2))
+                    .expectNext("success")
+                    .verifyComplete();
+        }
+        // Only one candidate remains after exclusion, so P2C selection is 
short-circuited; the singleton
+        // increment paired by #7974 must still be released here, keeping 
inflight at its baseline instead
+        // of leaking on every singleton failover. See #7975.
+        assertEquals(baseline, onlyStandby.getInflight().get());
+    }
+
     @Test
     void testFailoverWithMissingUpstreamList() {
         AbstractHttpClientPlugin<String> httpClientPlugin = 
mock(AbstractHttpClientPlugin.class);

Reply via email to