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 1797bd3729 fix: add lightweight circuit breaker to divide retry path
(#7507)
1797bd3729 is described below
commit 1797bd3729b90156664d45bb68f4700e49f7a078
Author: wy471x <[email protected]>
AuthorDate: Sat Oct 10 13:51:30 2026 +0800
fix: add lightweight circuit breaker to divide retry path (#7507)
Co-authored-by: wy471x <[email protected]>
---
.../circuitbreaker/UpstreamCircuitBreaker.java | 274 +++++++++++++++++++++
.../circuitbreaker/UpstreamCircuitBreakerTest.java | 209 ++++++++++++++++
.../plugin/httpclient/DefaultRetryStrategy.java | 13 +-
.../plugin/httpclient/RetryStrategyTest.java | 83 +++++++
.../apache/shenyu/plugin/divide/DividePlugin.java | 78 +++++-
.../divide/DividePluginCircuitBreakerTest.java | 181 ++++++++++++++
6 files changed, 824 insertions(+), 14 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreaker.java
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreaker.java
new file mode 100644
index 0000000000..18c3c40918
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreaker.java
@@ -0,0 +1,274 @@
+/*
+ * 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.base.circuitbreaker;
+
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.SignalType;
+
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * A lightweight built-in circuit breaker for divide/http proxy upstreams.
+ *
+ * <p>State is tracked per selector-upstream pair. When an upstream accumulates
+ * {@code failureThreshold} consecutive failures, its breaker opens and further
+ * requests fail fast without sending any traffic to the upstream. After
+ * {@code halfOpenWaitMillis} the breaker allows a single half-open probe
+ * request; if the probe succeeds the breaker closes again, otherwise it
+ * opens for another wait window.
+ *
+ * <p>This breaker is enabled by default and is fully independent from the
+ * optional resilience4j plugin: when no failure occurs it only performs a few
+ * map lookups, so the overhead on the happy path is negligible.
+ */
+public final class UpstreamCircuitBreaker {
+
+ /**
+ * The default number of consecutive failures before the breaker opens.
+ */
+ public static final int DEFAULT_FAILURE_THRESHOLD = 3;
+
+ /**
+ * The default wait time in milliseconds before a half-open probe is
allowed.
+ */
+ public static final long DEFAULT_HALF_OPEN_WAIT_MILLIS = 10000L;
+
+ private static final UpstreamCircuitBreaker INSTANCE =
+ new UpstreamCircuitBreaker(DEFAULT_FAILURE_THRESHOLD,
DEFAULT_HALF_OPEN_WAIT_MILLIS);
+
+ private final int failureThreshold;
+
+ private final long halfOpenWaitMillis;
+
+ private final ConcurrentMap<String, CircuitBreakerState> breakers;
+
+ private UpstreamCircuitBreaker(final int failureThreshold, final long
halfOpenWaitMillis) {
+ this.failureThreshold = failureThreshold;
+ this.halfOpenWaitMillis = halfOpenWaitMillis;
+ this.breakers = new ConcurrentHashMap<>(16);
+ }
+
+ /**
+ * Get the singleton instance used by the gateway plugins.
+ *
+ * @return the shared {@link UpstreamCircuitBreaker} instance
+ */
+ public static UpstreamCircuitBreaker getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Create a standalone breaker with custom thresholds, mainly for testing.
+ *
+ * @param failureThreshold consecutive failures required to open the
breaker
+ * @param halfOpenWaitMillis wait time in milliseconds before a half-open
probe is allowed
+ * @return a new {@link UpstreamCircuitBreaker} instance
+ */
+ public static UpstreamCircuitBreaker create(final int failureThreshold,
final long halfOpenWaitMillis) {
+ return new UpstreamCircuitBreaker(failureThreshold,
halfOpenWaitMillis);
+ }
+
+ /**
+ * Build the breaker key for the given selector and upstream.
+ *
+ * @param selectorId the selector id the upstream belongs to
+ * @param upstream the upstream
+ * @return the breaker key
+ */
+ public static String buildKey(final String selectorId, final Upstream
upstream) {
+ return selectorId + ":" + upstream.buildDomain();
+ }
+
+ /**
+ * Whether a request to the given upstream is currently allowed.
+ *
+ * <p>Requests are allowed when the breaker is closed, half-open, or open
+ * but already past its half-open wait window (i.e. eligible to be probed).
+ *
+ * @param key the breaker key
+ * @return true if a request may be sent to the upstream
+ */
+ public boolean isRequestAllowed(final String key) {
+ CircuitBreakerState state = this.breakers.get(key);
+ if (Objects.isNull(state)) {
+ return true;
+ }
+ return state.isRequestAllowed();
+ }
+
+ /**
+ * Atomically move an open breaker past its wait window into the half-open
+ * state and grant a single probe request.
+ *
+ * @param key the breaker key
+ * @return true if the caller is granted the probe request
+ */
+ public boolean tryAcquireHalfOpenProbe(final String key) {
+ CircuitBreakerState state = this.breakers.get(key);
+ if (Objects.isNull(state)) {
+ return false;
+ }
+ return state.tryAcquireHalfOpen();
+ }
+
+ /**
+ * Record a successful request, closing the breaker and clearing failures.
+ *
+ * @param key the breaker key
+ */
+ public void recordSuccess(final String key) {
+ CircuitBreakerState state = this.breakers.get(key);
+ if (Objects.nonNull(state)) {
+ state.recordSuccess();
+ }
+ }
+
+ /**
+ * Record a failed request; the breaker opens once the consecutive failure
+ * threshold is reached or a half-open probe fails.
+ *
+ * @param key the breaker key
+ */
+ public void recordFailure(final String key) {
+ this.breakers.computeIfAbsent(key, k -> new
CircuitBreakerState(this.failureThreshold, this.halfOpenWaitMillis))
+ .recordFailure();
+ }
+
+ /**
+ * Whether the breaker currently blocks requests for the given key.
+ *
+ * @param key the breaker key
+ * @return true if the breaker is open and still within its wait window
+ */
+ public boolean isBlocking(final String key) {
+ CircuitBreakerState state = this.breakers.get(key);
+ if (Objects.isNull(state)) {
+ return false;
+ }
+ return state.isBlocking();
+ }
+
+ /**
+ * Remove the state for the given key, mainly for testing.
+ *
+ * @param key the breaker key
+ */
+ public void reset(final String key) {
+ this.breakers.remove(key);
+ }
+
+ /**
+ * Wrap a forwarded {@link Mono} so that its terminal signal is recorded as
+ * a success or a failure of the upstream identified by the given key.
+ *
+ * @param <T> the element type of the source mono
+ * @param source the mono executing the upstream call
+ * @param key the breaker key
+ * @return the wrapped mono with outcome recording attached
+ */
+ public static <T> Mono<T> recordOutcome(final Mono<T> source, final String
key) {
+ return source.doFinally(signal -> {
+ if (signal == SignalType.ON_COMPLETE) {
+ INSTANCE.recordSuccess(key);
+ } else if (signal == SignalType.ON_ERROR) {
+ INSTANCE.recordFailure(key);
+ }
+ });
+ }
+
+ /**
+ * Mutable state of a single breaker.
+ */
+ private static final class CircuitBreakerState {
+
+ private enum State {
+ /**
+ * Requests flow normally.
+ */
+ CLOSED,
+ /**
+ * Requests fail fast until the wait window elapses.
+ */
+ OPEN,
+ /**
+ * A single probe request is in flight.
+ */
+ HALF_OPEN
+ }
+
+ private final int failureThreshold;
+
+ private final long halfOpenWaitMillis;
+
+ private State state;
+
+ private int consecutiveFailures;
+
+ private long openedAtMillis;
+
+ CircuitBreakerState(final int failureThreshold, final long
halfOpenWaitMillis) {
+ this.failureThreshold = failureThreshold;
+ this.halfOpenWaitMillis = halfOpenWaitMillis;
+ this.state = State.CLOSED;
+ }
+
+ synchronized boolean isRequestAllowed() {
+ if (this.state == State.OPEN) {
+ return System.currentTimeMillis() - this.openedAtMillis >=
this.halfOpenWaitMillis;
+ }
+ return true;
+ }
+
+ synchronized boolean isBlocking() {
+ return !isRequestAllowed();
+ }
+
+ synchronized boolean tryAcquireHalfOpen() {
+ if (this.state == State.OPEN
+ && System.currentTimeMillis() - this.openedAtMillis >=
this.halfOpenWaitMillis) {
+ this.state = State.HALF_OPEN;
+ return true;
+ }
+ return false;
+ }
+
+ synchronized void recordSuccess() {
+ this.state = State.CLOSED;
+ this.consecutiveFailures = 0;
+ }
+
+ synchronized void recordFailure() {
+ if (this.state == State.HALF_OPEN) {
+ open();
+ return;
+ }
+ this.consecutiveFailures++;
+ if (this.consecutiveFailures >= this.failureThreshold) {
+ open();
+ }
+ }
+
+ private void open() {
+ this.state = State.OPEN;
+ this.openedAtMillis = System.currentTimeMillis();
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreakerTest.java
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreakerTest.java
new file mode 100644
index 0000000000..35b3c718c3
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/circuitbreaker/UpstreamCircuitBreakerTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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.base.circuitbreaker;
+
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test for {@link UpstreamCircuitBreaker}.
+ */
+public final class UpstreamCircuitBreakerTest {
+
+ private static final String KEY = "selector:http://127.0.0.1:9090";
+
+ @Test
+ public void testRequestsAllowedByDefault() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(3,
100L);
+ assertTrue(breaker.isRequestAllowed(KEY));
+ assertFalse(breaker.isBlocking(KEY));
+ assertFalse(breaker.tryAcquireHalfOpenProbe(KEY));
+ }
+
+ @Test
+ public void testOpensAfterConsecutiveFailures() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(3,
100L);
+ breaker.recordFailure(KEY);
+ breaker.recordFailure(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ assertFalse(breaker.isRequestAllowed(KEY));
+ }
+
+ @Test
+ public void testSuccessResetsConsecutiveFailures() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(3,
100L);
+ breaker.recordFailure(KEY);
+ breaker.recordFailure(KEY);
+ breaker.recordSuccess(KEY);
+ breaker.recordFailure(KEY);
+ breaker.recordFailure(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testSuccessClosesOpenBreaker() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(1,
100000L);
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ breaker.recordSuccess(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ assertTrue(breaker.isRequestAllowed(KEY));
+ }
+
+ @Test
+ public void testHalfOpenProbeAllowedAfterWaitWindow() throws
InterruptedException {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(1,
100L);
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ // within the wait window no probe is granted
+ assertFalse(breaker.tryAcquireHalfOpenProbe(KEY));
+ TimeUnit.MILLISECONDS.sleep(150L);
+ // the first caller is granted the single probe
+ assertTrue(breaker.tryAcquireHalfOpenProbe(KEY));
+ // no second probe while the first one is in flight
+ assertFalse(breaker.tryAcquireHalfOpenProbe(KEY));
+ // a failed probe re-opens the breaker
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testSuccessfulProbeClosesBreaker() throws InterruptedException
{
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(2,
100L);
+ breaker.recordFailure(KEY);
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ TimeUnit.MILLISECONDS.sleep(150L);
+ assertTrue(breaker.tryAcquireHalfOpenProbe(KEY));
+ breaker.recordSuccess(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ // breaker fully recovered, a single new failure stays below the
threshold
+ breaker.recordFailure(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testConcurrentFailuresOpenBreaker() throws
InterruptedException {
+ final int threads = 32;
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(3,
100L);
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(threads);
+ for (int i = 0; i < threads; i++) {
+ executor.execute(() -> {
+ try {
+ start.await();
+ breaker.recordFailure(KEY);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertTrue(done.await(10, TimeUnit.SECONDS));
+ executor.shutdownNow();
+ assertTrue(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testOnlyOneConcurrentHalfOpenProbeGranted() throws
InterruptedException {
+ final int threads = 16;
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(1, 50L);
+ breaker.recordFailure(KEY);
+ TimeUnit.MILLISECONDS.sleep(100L);
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(threads);
+ AtomicInteger granted = new AtomicInteger();
+ for (int i = 0; i < threads; i++) {
+ executor.execute(() -> {
+ try {
+ start.await();
+ if (breaker.tryAcquireHalfOpenProbe(KEY)) {
+ granted.incrementAndGet();
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertTrue(done.await(10, TimeUnit.SECONDS));
+ executor.shutdownNow();
+ assertEquals(1, granted.get());
+ }
+
+ @Test
+ public void testRecordOutcomeRecordsTerminalSignals() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(2,
100L);
+
StepVerifier.create(UpstreamCircuitBreaker.recordOutcome(Mono.error(new
IllegalStateException("upstream down")), KEY))
+ .expectError(IllegalStateException.class)
+ .verify();
+
StepVerifier.create(UpstreamCircuitBreaker.recordOutcome(Mono.just("ok"), KEY))
+ .expectNext("ok")
+ .verifyComplete();
+ // one failure recorded and then reset by the success, so one more
failure keeps the breaker closed
+ breaker.recordFailure(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testBuildKeyUsesSelectorIdAndDomain() {
+ Upstream upstream =
Upstream.builder().protocol("http://").url("127.0.0.1:9090").build();
+ assertEquals("selector:http://127.0.0.1:9090",
UpstreamCircuitBreaker.buildKey("selector", upstream));
+ }
+
+ @Test
+ public void testResetClearsState() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(1,
100000L);
+ breaker.recordFailure(KEY);
+ assertTrue(breaker.isBlocking(KEY));
+ breaker.reset(KEY);
+ assertFalse(breaker.isBlocking(KEY));
+ }
+
+ @Test
+ public void testMultipleKeysAreIndependent() {
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.create(1,
100000L);
+ List<String> keys = List.of("selector-a:http://a:8080",
"selector-b:http://b:8080");
+ breaker.recordFailure(keys.get(0));
+ assertTrue(breaker.isBlocking(keys.get(0)));
+ assertFalse(breaker.isBlocking(keys.get(1)));
+ }
+}
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 79b6727dfd..5ab621c1ad 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
@@ -24,6 +24,7 @@ import org.apache.shenyu.common.utils.UriUtils;
import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
import org.apache.shenyu.loadbalancer.entity.Upstream;
import org.apache.shenyu.plugin.api.utils.RequestUrlUtils;
+import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
import org.apache.shenyu.plugin.base.utils.LoadbalancerUtils;
import org.apache.shenyu.plugin.httpclient.exception.ShenyuTimeoutException;
import org.slf4j.Logger;
@@ -120,7 +121,11 @@ public class DefaultRetryStrategy<R> implements
RetryStrategy<R> {
}
}
return true;
- }).collect(Collectors.toList());
+ })
+ //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"));
@@ -133,9 +138,11 @@ public class DefaultRetryStrategy<R> implements
RetryStrategy<R> {
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);
- return httpClientPlugin.doRequest(exchange,
exchange.getRequest().getMethod().name(), newUri,
httpClientPlugin.getCachedRequestBody(exchange))
+ 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));
+ .doOnError(e -> LOG.error(e.getMessage(), e)), breakerKey);
});
}
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 7b8e7e71ba..efea42791f 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,6 +22,7 @@ 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.base.circuitbreaker.UpstreamCircuitBreaker;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -44,7 +45,9 @@ import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -158,6 +161,86 @@ public class RetryStrategyTest {
assertEquals(URI.create(standby.buildDomain() + "/test?mode=retry"),
uriCaptor.getValue());
}
+ @Test
+ void testFailoverSkipsBlockedUpstream() {
+ AbstractHttpClientPlugin<String> plugin =
mock(AbstractHttpClientPlugin.class);
+ ServerWebExchange exchange =
createFailoverExchange("http://localhost:8080/test?mode=retry");
+ Upstream failed = Upstream.builder().url("localhost:8080").build();
+ Upstream standby =
Upstream.builder().protocol("http://").url("localhost:8081").build();
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+
when(cacheManager.findUpstreamListBySelectorId("selector-7475")).thenReturn(Arrays.asList(failed,
standby));
+ final String standbyKey =
UpstreamCircuitBreaker.buildKey("selector-7475", standby);
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+ for (int i = 0; i < 3; i++) {
+ breaker.recordFailure(standbyKey);
+ }
+ 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))
+ .expectErrorSatisfies(this::assertFailoverExhausted)
+ .verify();
+ } finally {
+ breaker.reset(standbyKey);
+ }
+ verifyNoInteractions(plugin);
+ }
+
+ @Test
+ void testFailoverResendRecordsFailureOutcome() {
+ AbstractHttpClientPlugin<String> plugin =
mock(AbstractHttpClientPlugin.class);
+ ServerWebExchange exchange =
createFailoverExchange("http://localhost:8080/test?mode=retry");
+ Upstream failed = Upstream.builder().url("localhost:8080").build();
+ Upstream standby =
Upstream.builder().protocol("http://").url("localhost:8081").build();
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+
when(cacheManager.findUpstreamListBySelectorId("selector-7475")).thenReturn(Arrays.asList(failed,
standby));
+ when(plugin.getCachedRequestBody(exchange)).thenReturn(Flux.empty());
+ when(plugin.doRequest(eq(exchange), eq("GET"), any(URI.class),
any())).thenReturn(Mono.error(new TimeoutException("resend failed")));
+ final String standbyKey =
UpstreamCircuitBreaker.buildKey("selector-7475", standby);
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+ 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))
+ .expectErrorSatisfies(this::assertFailoverExhausted)
+ .verify();
+ }
+ // the failed resend recorded one failure, one more below the
threshold keeps the breaker closed
+ breaker.recordFailure(standbyKey);
+ assertFalse(breaker.isBlocking(standbyKey));
+ breaker.recordFailure(standbyKey);
+ assertTrue(breaker.isBlocking(standbyKey));
+ breaker.reset(standbyKey);
+ }
+
+ @Test
+ void testFailoverResendRecordsSuccessOutcome() {
+ AbstractHttpClientPlugin<String> plugin =
mock(AbstractHttpClientPlugin.class);
+ ServerWebExchange exchange =
createFailoverExchange("http://localhost:8080/test?mode=retry");
+ Upstream failed = Upstream.builder().url("localhost:8080").build();
+ Upstream standby =
Upstream.builder().protocol("http://").url("localhost:8081").build();
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+
when(cacheManager.findUpstreamListBySelectorId("selector-7475")).thenReturn(Arrays.asList(failed,
standby));
+ when(plugin.getCachedRequestBody(exchange)).thenReturn(Flux.empty());
+ when(plugin.doRequest(eq(exchange), eq("GET"), any(URI.class),
any())).thenReturn(Mono.just("success"));
+ final String standbyKey =
UpstreamCircuitBreaker.buildKey("selector-7475", standby);
+ UpstreamCircuitBreaker breaker = UpstreamCircuitBreaker.getInstance();
+ breaker.recordFailure(standbyKey);
+ breaker.recordFailure(standbyKey);
+ 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();
+ }
+ // the successful resend reset the failure counter, so two more
failures stay below the threshold
+ breaker.recordFailure(standbyKey);
+ breaker.recordFailure(standbyKey);
+ assertFalse(breaker.isBlocking(standbyKey));
+ breaker.reset(standbyKey);
+ }
+
@Test
void testFixedRetryStrategyRetriesTransientGetFailures() {
AbstractHttpClientPlugin<String> httpClientPlugin =
mock(AbstractHttpClientPlugin.class);
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
index 0c55ed7250..ef8deefe99 100644
---
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/DividePlugin.java
@@ -37,6 +37,7 @@ import org.apache.shenyu.plugin.api.result.ShenyuResultWrap;
import org.apache.shenyu.plugin.api.utils.RequestUrlUtils;
import org.apache.shenyu.plugin.api.utils.WebFluxResultUtils;
import org.apache.shenyu.plugin.base.AbstractShenyuPlugin;
+import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
import org.apache.shenyu.plugin.base.utils.LoadbalancerUtils;
import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
@@ -46,7 +47,9 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -96,11 +99,17 @@ public class DividePlugin extends AbstractShenyuPlugin {
Object error = ShenyuResultWrap.error(exchange,
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
return WebFluxResultUtils.result(exchange, error);
}
+ final List<Upstream> breakeredUpstreamList =
filterByCircuitBreaker(selector.getId(), upstreamList);
+ if (CollectionUtils.isEmpty(breakeredUpstreamList)) {
+ LOG.warn("all upstreams of selector {} are blocked by the circuit
breaker, fail fast", selector.getId());
+ Object error = ShenyuResultWrap.error(exchange,
ShenyuResultEnum.CANNOT_FIND_HEALTHY_UPSTREAM_URL);
+ return WebFluxResultUtils.result(exchange, error);
+ }
List<String> specifyDomains =
exchange.getRequest().getHeaders().get(Constants.SPECIFY_DOMAIN);
Upstream upstream;
if (CollectionUtils.isNotEmpty(specifyDomains)) {
String requested = specifyDomains.get(0);
- upstream = upstreamList.stream()
+ upstream = breakeredUpstreamList.stream()
.filter(u -> u.getUrl().equals(requested))
.findFirst()
.map(u -> Upstream.builder()
@@ -110,9 +119,9 @@ public class DividePlugin extends AbstractShenyuPlugin {
.warmup(u.getWarmup())
.status(u.isStatus())
.build())
- .orElseGet(() ->
LoadbalancerUtils.getForExchange(upstreamList, ruleHandle.getLoadBalance(),
exchange));
+ .orElseGet(() ->
LoadbalancerUtils.getForExchange(breakeredUpstreamList,
ruleHandle.getLoadBalance(), exchange));
} else {
- upstream = LoadbalancerUtils.getForExchange(upstreamList,
ruleHandle.getLoadBalance(), exchange);
+ upstream = LoadbalancerUtils.getForExchange(breakeredUpstreamList,
ruleHandle.getLoadBalance(), exchange);
}
if (Objects.isNull(upstream)) {
LOG.error("divide has no upstream");
@@ -130,14 +139,7 @@ public class DividePlugin extends AbstractShenyuPlugin {
exchange.getAttributes().put(Constants.RETRY_STRATEGY,
StringUtils.defaultIfEmpty(ruleHandle.getRetryStrategy(),
RetryEnum.CURRENT.getName()));
exchange.getAttributes().put(Constants.LOAD_BALANCE,
StringUtils.defaultIfEmpty(ruleHandle.getLoadBalance(),
LoadBalanceEnum.RANDOM.getName()));
exchange.getAttributes().put(Constants.DIVIDE_SELECTOR_ID,
selector.getId());
- if (ruleHandle.getLoadBalance().equals(P2C)) {
- return chain.execute(exchange).doFinally(signalType ->
responseTrigger(upstream));
- } else if (ruleHandle.getLoadBalance().equals(SHORTEST_RESPONSE)) {
- long beginTime = System.currentTimeMillis();
- return chain.execute(exchange).doOnSuccess(e ->
successResponseTrigger(upstream, beginTime
- ));
- }
- return chain.execute(exchange);
+ return forwardWithCircuitBreaker(exchange, chain, ruleHandle,
selector.getId(), upstream);
}
@Override
@@ -165,10 +167,64 @@ public class DividePlugin extends AbstractShenyuPlugin {
return WebFluxResultUtils.noRuleResult(pluginName, exchange);
}
+ /**
+ * Forward the request through the plugin chain and record its outcome into
+ * the built-in circuit breaker, keeping the load-balance specific
triggers.
+ *
+ * @param exchange the current server exchange
+ * @param chain the plugin chain
+ * @param ruleHandle the divide rule handle
+ * @param selectorId the selector id
+ * @param upstream the chosen upstream
+ * @return {@code Mono<Void>} to indicate when request processing is
complete
+ */
+ private Mono<Void> forwardWithCircuitBreaker(final ServerWebExchange
exchange, final ShenyuPluginChain chain,
+ final DivideRuleHandle
ruleHandle, final String selectorId, final Upstream upstream) {
+ String breakerKey = UpstreamCircuitBreaker.buildKey(selectorId,
upstream);
+ if (ruleHandle.getLoadBalance().equals(P2C)) {
+ return UpstreamCircuitBreaker.recordOutcome(chain.execute(exchange)
+ .doFinally(signalType -> responseTrigger(upstream)),
breakerKey);
+ } else if (ruleHandle.getLoadBalance().equals(SHORTEST_RESPONSE)) {
+ long beginTime = System.currentTimeMillis();
+ return UpstreamCircuitBreaker.recordOutcome(chain.execute(exchange)
+ .doOnSuccess(e -> successResponseTrigger(upstream,
beginTime)), breakerKey);
+ }
+ return UpstreamCircuitBreaker.recordOutcome(chain.execute(exchange),
breakerKey);
+ }
+
private DivideRuleHandle buildRuleHandle(final RuleData rule) {
return
DividePluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(rule));
}
+ /**
+ * Filter out upstreams blocked by the built-in circuit breaker.
+ *
+ * <p>When every upstream of the selector is blocked, a single half-open
+ * probe request is granted to one of them so the breaker can recover;
+ * otherwise the caller should fail fast.
+ *
+ * @param selectorId the selector id the upstreams belong to
+ * @param upstreamList the healthy upstream list from the cache
+ * @return the upstreams allowed to receive requests, possibly empty
+ */
+ private List<Upstream> filterByCircuitBreaker(final String selectorId,
final List<Upstream> upstreamList) {
+ List<Upstream> allowed = new ArrayList<>(upstreamList.size());
+ for (Upstream upstream : upstreamList) {
+ if
(UpstreamCircuitBreaker.getInstance().isRequestAllowed(UpstreamCircuitBreaker.buildKey(selectorId,
upstream))) {
+ allowed.add(upstream);
+ }
+ }
+ if (CollectionUtils.isNotEmpty(allowed)) {
+ return allowed;
+ }
+ for (Upstream upstream : upstreamList) {
+ if
(UpstreamCircuitBreaker.getInstance().tryAcquireHalfOpenProbe(UpstreamCircuitBreaker.buildKey(selectorId,
upstream))) {
+ return Collections.singletonList(upstream);
+ }
+ }
+ return Collections.emptyList();
+ }
+
private void responseTrigger(final Upstream upstream) {
long now = System.currentTimeMillis();
upstream.getInflight().decrementAndGet();
diff --git
a/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginCircuitBreakerTest.java
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginCircuitBreakerTest.java
new file mode 100644
index 0000000000..69e67575e8
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/DividePluginCircuitBreakerTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.divide;
+
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.api.result.DefaultShenyuResult;
+import org.apache.shenyu.plugin.api.result.ShenyuResult;
+import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
+import org.apache.shenyu.plugin.base.circuitbreaker.UpstreamCircuitBreaker;
+import org.apache.shenyu.plugin.divide.handler.DividePluginDataHandler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test for the built-in circuit breaker behaviour of {@link DividePlugin}.
+ */
+public final class DividePluginCircuitBreakerTest {
+
+ private static final String SELECTOR_ID = "breaker-selector";
+
+ private static final String UPSTREAM_URL = "127.0.0.1:9090";
+
+ private DividePlugin dividePlugin;
+
+ private ShenyuPluginChain chain;
+
+ private ServerWebExchange exchange;
+
+ private SelectorData selectorData;
+
+ private RuleData ruleData;
+
+ private Upstream upstream;
+
+ private String breakerKey;
+
+ private MockedStatic<UpstreamCacheManager> cacheMock;
+
+ @BeforeEach
+ public void setup() {
+ this.dividePlugin = new DividePlugin();
+ this.chain = mock(ShenyuPluginChain.class);
+ this.selectorData = mock(SelectorData.class);
+ this.ruleData = mock(RuleData.class);
+ this.exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/test").build());
+ ShenyuContext context = mock(ShenyuContext.class);
+ exchange.getAttributes().put(Constants.CONTEXT, context);
+ this.upstream =
Upstream.builder().protocol("http://").url(UPSTREAM_URL).build();
+ this.breakerKey = UpstreamCircuitBreaker.buildKey(SELECTOR_ID,
upstream);
+ when(selectorData.getId()).thenReturn(SELECTOR_ID);
+ when(ruleData.getHandle()).thenReturn(GsonUtils.getGson().toJson(new
DivideRuleHandle()));
+ DividePluginDataHandler dividePluginDataHandler = new
DividePluginDataHandler();
+ dividePluginDataHandler.handlerRule(ruleData);
+ dividePluginDataHandler.handlerSelector(selectorData);
+ ConfigurableApplicationContext applicationContext =
mock(ConfigurableApplicationContext.class);
+
SpringBeanUtils.getInstance().setApplicationContext(applicationContext);
+ when(applicationContext.getBean(ShenyuResult.class)).thenReturn(new
DefaultShenyuResult());
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+
when(cacheManager.findUpstreamListBySelectorId(SELECTOR_ID)).thenReturn(Collections.singletonList(upstream));
+ this.cacheMock = mockStatic(UpstreamCacheManager.class);
+
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ cacheMock.close();
+ UpstreamCircuitBreaker.getInstance().reset(breakerKey);
+ }
+
+ @Test
+ public void testFailFastWhenAllUpstreamsBlocked() {
+ openBreaker();
+ when(chain.execute(exchange)).thenReturn(Mono.empty());
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData))
+ .verifyComplete();
+ verify(chain, times(0)).execute(any());
+ }
+
+ @Test
+ public void testConsecutiveFailuresOpenBreakerThenFailFast() {
+ when(chain.execute(exchange)).thenReturn(Mono.error(new
IllegalStateException("upstream down")));
+ for (int i = 0; i < 3; i++) {
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData))
+ .expectError(IllegalStateException.class)
+ .verify();
+ }
+
assertTrue(UpstreamCircuitBreaker.getInstance().isBlocking(breakerKey));
+ // the next request fails fast without reaching the chain any more
+ when(chain.execute(exchange)).thenReturn(Mono.empty());
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData))
+ .verifyComplete();
+ verify(chain, times(3)).execute(any());
+ }
+
+ @Test
+ public void testHalfOpenProbeRecoversBreaker() throws Exception {
+ openBreaker();
+ // rewind the open timestamp so the half-open wait window has elapsed
+ Field breakersField =
UpstreamCircuitBreaker.class.getDeclaredField("breakers");
+ breakersField.setAccessible(true);
+ ConcurrentMap<String, ?> breakers = (ConcurrentMap<String, ?>)
breakersField.get(UpstreamCircuitBreaker.getInstance());
+ Object state = breakers.get(breakerKey);
+ Field openedAtField =
state.getClass().getDeclaredField("openedAtMillis");
+ openedAtField.setAccessible(true);
+ openedAtField.setLong(state, System.currentTimeMillis() - 60000L);
+ // the probe request is forwarded and its success closes the breaker
+ when(chain.execute(exchange)).thenReturn(Mono.empty());
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData))
+ .verifyComplete();
+ verify(chain, times(1)).execute(any());
+
assertFalse(UpstreamCircuitBreaker.getInstance().isBlocking(breakerKey));
+ }
+
+ @Test
+ public void testHealthyUpstreamsBypassBreaker() {
+ Upstream healthy =
Upstream.builder().protocol("http://").url("127.0.0.1:9091").build();
+
UpstreamCircuitBreaker.getInstance().reset(UpstreamCircuitBreaker.buildKey(SELECTOR_ID,
healthy));
+ openBreaker();
+ UpstreamCacheManager cacheManager = mock(UpstreamCacheManager.class);
+ List<Upstream> upstreams = List.of(upstream, healthy);
+
when(cacheManager.findUpstreamListBySelectorId(SELECTOR_ID)).thenReturn(upstreams);
+
cacheMock.when(UpstreamCacheManager::getInstance).thenReturn(cacheManager);
+ // the blocked upstream is filtered out, the request still goes
through the healthy one
+ when(chain.execute(exchange)).thenReturn(Mono.empty());
+ StepVerifier.create(dividePlugin.doExecute(exchange, chain,
selectorData, ruleData))
+ .verifyComplete();
+ verify(chain, times(1)).execute(any());
+
UpstreamCircuitBreaker.getInstance().reset(UpstreamCircuitBreaker.buildKey(SELECTOR_ID,
healthy));
+ }
+
+ private void openBreaker() {
+ for (int i = 0; i < 3; i++) {
+ UpstreamCircuitBreaker.getInstance().recordFailure(breakerKey);
+ }
+ }
+}