2727import org .apache .shenyu .plugin .api .utils .RequestUrlUtils ;
2828import org .apache .shenyu .plugin .base .circuitbreaker .UpstreamCircuitBreaker ;
2929import org .apache .shenyu .plugin .base .utils .LoadbalancerUtils ;
30+ import org .apache .shenyu .plugin .base .utils .UpstreamAttemptOwner ;
3031import org .apache .shenyu .plugin .base .utils .UpstreamRoutingContext ;
3132import org .apache .shenyu .plugin .httpclient .exception .ShenyuTimeoutException ;
3233import org .slf4j .Logger ;
3536import org .springframework .web .server .ResponseStatusException ;
3637import org .springframework .web .server .ServerWebExchange ;
3738import reactor .core .publisher .Mono ;
39+ import reactor .core .publisher .SignalType ;
3840import reactor .util .retry .Retry ;
3941import reactor .util .retry .RetryBackoffSpec ;
4042
@@ -105,7 +107,19 @@ public Mono<R> execute(final Mono<R> clientResponse, final ServerWebExchange exc
105107 Mono <R > executeFailover (final Mono <R > clientResponse , final ServerWebExchange exchange , final Duration duration ,
106108 final int retryTimes , final Duration backoff , final Predicate <Throwable > retryFilter ) {
107109 final Set <URI > exclude = new HashSet <>(Collections .singletonList (Objects .requireNonNull (exchange .getAttribute (Constants .HTTP_URI ))));
110+ final UpstreamAttemptOwner owner = exchange .getAttribute (UpstreamAttemptOwner .ATTRIBUTE );
108111 Mono <R > result = clientResponse ;
112+ if (Objects .nonNull (owner )) {
113+ // Bind the release to the initial attempt itself, not to the composed failover chain:
114+ // onErrorResume below consumes the initial error, so a chain-level doFinally would only
115+ // run after the retry finished and would keep A reserved through the backoff (#8158).
116+ final Upstream initial = owner .current ();
117+ result = result .doFinally (signal -> {
118+ if (signal != SignalType .ON_COMPLETE ) {
119+ owner .release (initial );
120+ }
121+ });
122+ }
109123 for (int i = 0 ; i < retryTimes ; i ++) {
110124 result = result .onErrorResume (retryFilter , th -> backoff .isZero ()
111125 ? resend (exchange , duration , exclude )
@@ -138,33 +152,44 @@ private Mono<R> mapFailoverErrors(final Mono<R> response) {
138152 .onErrorMap (java .util .concurrent .TimeoutException .class , th -> new ResponseStatusException (HttpStatus .GATEWAY_TIMEOUT , th .getMessage (), th ));
139153 }
140154
141- private Mono <R > resend (final ServerWebExchange exchange ,
142- final Duration duration ,
143- final Set <URI > exclude ) {
144- final String selectorId = exchange .getAttribute (Constants .DIVIDE_SELECTOR_ID );
145- final String loadBalance = exchange .getAttribute (Constants .LOAD_BALANCE );
155+ private List <Upstream > retryCandidates (final ServerWebExchange exchange , final String selectorId ,
156+ final Set <URI > exclude ) {
146157 // Refresh health/topology without losing the request's resolved label partition.
147158 final UpstreamRoutingContext routing = exchange .getAttribute (UpstreamRoutingContext .ATTRIBUTE );
148159 final List <Upstream > availableUpstreamList = Objects .isNull (routing )
149160 ? UpstreamCacheManager .getInstance ().findUpstreamListBySelectorId (selectorId )
150161 : routing .filter (selectorId , UpstreamCacheManager .getInstance ().findAllUpstreamListBySelectorId (selectorId ));
151162 if (Objects .isNull (availableUpstreamList )) {
152- return Mono . error ( new ShenyuException ( "CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER" ) );
163+ return Collections . emptyList ( );
153164 }
154- final List <Upstream > upstreamList = availableUpstreamList
155- .stream ().filter (data -> {
156- final URI upstreamUri = URI .create (data .buildDomain ());
157- for (URI needToExclude : exclude ) {
158- if (isSameUpstream (needToExclude , upstreamUri )) {
159- return false ;
160- }
161- }
162- return true ;
163- })
165+ return availableUpstreamList .stream ().filter (data -> {
166+ final URI upstreamUri = URI .create (data .buildDomain ());
167+ for (URI needToExclude : exclude ) {
168+ if (isSameUpstream (needToExclude , upstreamUri )) {
169+ return false ;
170+ }
171+ }
172+ return true ;
173+ })
164174 //skip upstreams that are still blocked by the built-in circuit breaker
165175 .filter (data -> UpstreamCircuitBreaker .getInstance ()
166176 .isRequestAllowed (UpstreamCircuitBreaker .buildKey (selectorId , data )))
167177 .collect (Collectors .toList ());
178+ }
179+
180+ private Mono <R > resend (final ServerWebExchange exchange ,
181+ final Duration duration ,
182+ final Set <URI > exclude ) {
183+ final UpstreamAttemptOwner owner = exchange .getAttribute (UpstreamAttemptOwner .ATTRIBUTE );
184+ if (Objects .nonNull (owner )) {
185+ // reaching a resend means the previous attempt failed; release that attempt before
186+ // selecting and reserving the next one. The release is idempotent with the failure
187+ // callback that may run just after this method starts (#8158).
188+ owner .releaseCurrent ();
189+ }
190+ final String selectorId = exchange .getAttribute (Constants .DIVIDE_SELECTOR_ID );
191+ final String loadBalance = exchange .getAttribute (Constants .LOAD_BALANCE );
192+ final List <Upstream > upstreamList = retryCandidates (exchange , selectorId , exclude );
168193 if (upstreamList .isEmpty ()) {
169194 // no need to retry anymore
170195 return Mono .error (new ShenyuException ("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER" ));
@@ -174,27 +199,52 @@ private Mono<R> resend(final ServerWebExchange exchange,
174199 // no need to retry anymore
175200 return Mono .error (new ShenyuException ("CANNOT_FIND_HEALTHY_UPSTREAM_URL_AFTER_FAILOVER" ));
176201 }
177- final URI newUri = RequestUrlUtils .buildRequestUri (exchange , upstream .buildDomain ());
178- // in order not to affect the next retry call, newUri needs to be excluded
179- exclude .add (newUri );
180- final String breakerKey = UpstreamCircuitBreaker .buildKey (selectorId , upstream );
181- Mono <R > retryResponse = httpClientPlugin
182- .doRequest (exchange , exchange .getRequest ().getMethod ().name (), newUri , httpClientPlugin .getCachedRequestBody (exchange ))
183- .timeout (duration , Mono .error (() -> new TimeoutException ("Response took longer than timeout: " + duration )))
184- .doOnError (e -> LOG .error (e .getMessage (), e ));
185- if (LoadBalanceEnum .P2C .getName ().equals (loadBalance )) {
186- // Selecting the retry upstream above goes through the load balancer, which increments the P2C
187- // inflight counter of the newly picked upstream. Divide's completion callback only captures the
188- // initially selected upstream, so release this attempt's increment to keep P2C accounting paired
189- // across success, failure and cancellation of the failover. See #7975.
190- retryResponse = retryResponse .doFinally (signalType -> upstream .getInflight ().decrementAndGet ());
191- } else if (LoadBalanceEnum .LEAST_ACTIVITY .getName ().equals (loadBalance )) {
192- // Least active compares outstanding requests but does not reserve on select, so this attempt
193- // stays reserved until it terminates. See #7986.
194- upstream .getInflight ().incrementAndGet ();
195- retryResponse = retryResponse .doFinally (signalType -> upstream .getInflight ().decrementAndGet ());
202+ final boolean ownerPresent = Objects .nonNull (owner );
203+ boolean reserved = false ;
204+ try {
205+ if (LoadBalanceEnum .LEAST_ACTIVITY .getName ().equals (loadBalance )) {
206+ // Least active compares outstanding requests but does not reserve on select.
207+ upstream .getInflight ().incrementAndGet ();
208+ reserved = true ;
209+ } else if (LoadBalanceEnum .P2C .getName ().equals (loadBalance )) {
210+ // P2C selection already incremented the retry target.
211+ reserved = true ;
212+ }
213+ if (ownerPresent ) {
214+ // Transfer ownership before constructing the request: a succeeded attempt is
215+ // released by the terminal callback when its body finishes, a failed one by the
216+ // callback below. A synchronous failure after this point releases the new target.
217+ owner .move (upstream );
218+ }
219+ final URI newUri = RequestUrlUtils .buildRequestUri (exchange , upstream .buildDomain ());
220+ // in order not to affect the next retry call, newUri needs to be excluded
221+ exclude .add (newUri );
222+ final String breakerKey = UpstreamCircuitBreaker .buildKey (selectorId , upstream );
223+ Mono <R > retryResponse = httpClientPlugin
224+ .doRequest (exchange , exchange .getRequest ().getMethod ().name (), newUri , httpClientPlugin .getCachedRequestBody (exchange ))
225+ .timeout (duration , Mono .error (() -> new TimeoutException ("Response took longer than timeout: " + duration )))
226+ .doOnError (e -> LOG .error (e .getMessage (), e ));
227+ if (ownerPresent ) {
228+ retryResponse = retryResponse .doFinally (signal -> {
229+ if (signal != SignalType .ON_COMPLETE ) {
230+ // identity CAS: an immediate resend may already have moved ownership to the
231+ // next attempt, and this callback must never release that newer target
232+ owner .release (upstream );
233+ }
234+ });
235+ } else if (reserved ) {
236+ // no Divide attempt owner (non-Divide caller): keep the previous accounting
237+ retryResponse = retryResponse .doFinally (signal -> upstream .getInflight ().decrementAndGet ());
238+ }
239+ return UpstreamCircuitBreaker .recordOutcome (retryResponse , breakerKey );
240+ } catch (RuntimeException ex ) {
241+ if (ownerPresent ) {
242+ owner .release (upstream );
243+ } else if (reserved ) {
244+ upstream .getInflight ().decrementAndGet ();
245+ }
246+ throw ex ;
196247 }
197- return UpstreamCircuitBreaker .recordOutcome (retryResponse , breakerKey );
198248 }
199249
200250 private boolean isSameUpstream (final URI first , final URI second ) {
0 commit comments