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 11bf955902 fix(register-client-http): propagate failed admin
responses. (#7555)
11bf955902 is described below
commit 11bf955902db8bd59192af1280d5daef19f8595b
Author: redyyang <[email protected]>
AuthorDate: Mon Oct 12 13:14:18 2026 +0800
fix(register-client-http): propagate failed admin responses. (#7555)
Co-authored-by: redyyang <[email protected]>
---
RELEASE-NOTES.md | 8 ++
.../register/client/http/utils/RegisterUtils.java | 10 ++
.../http/HttpClientRegisterRepositoryTest.java | 124 +++++++++++++++++++++
.../client/http/utils/RegisterUtilsTest.java | 57 ++++++++--
4 files changed, 191 insertions(+), 8 deletions(-)
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index acb4c64256..2278e5f5f3 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -6,6 +6,14 @@
### Behavior Changes
+- HTTP client registration now treats non-success Admin responses as failures,
+ using the same bounded failback policy as transport errors (18 retry attempts
+ per pending task). The text response contract does not reliably distinguish
+ transient failures from permanent validation errors, so both consume that
+ budget. Heartbeat failures are reported to the caller. Unregistration retains
+ its existing policy: any successful Admin response is sufficient, and failure
+ at every Admin is reported without adding a registration retry. (#7440)
+
- Agent Gateway supports opt-in per-tool `mcp.structuredContentRemovals` field
deletion with strict result-copy handling. Complete JSON text copies are
rebuilt
from filtered structuredContent. Opaque text/media, nonempty metadata and
unknown
diff --git
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/main/java/org/apache/shenyu/register/client/http/utils/RegisterUtils.java
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/main/java/org/apache/shenyu/register/client/http/utils/RegisterUtils.java
index 4b2afbfb6a..b27e125207 100644
---
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/main/java/org/apache/shenyu/register/client/http/utils/RegisterUtils.java
+++
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/main/java/org/apache/shenyu/register/client/http/utils/RegisterUtils.java
@@ -35,6 +35,12 @@ import static
org.apache.shenyu.common.constant.Constants.SUCCESS;
/**
* RegisterUtils.
+ *
+ * <p>Registration, heartbeat and unregistration responses other than {@link
Constants#SUCCESS}
+ * propagate through the existing {@link IOException} contract. Registration
failures use the
+ * repository's bounded failback policy, including validation-error responses:
the text response
+ * contract cannot reliably distinguish permanent errors from transient
failures. Heartbeat and
+ * unregistration failures are handled by the caller without enqueueing a
registration retry.
*/
public final class RegisterUtils {
@@ -63,6 +69,7 @@ public final class RegisterUtils {
LOGGER.info("{} client register success: {} ", type, json);
} else {
LOGGER.error("{} client register error: {} ", type, json);
+ throw new IOException("Admin returned a non-success response for "
+ type + " registration at " + url);
}
}
@@ -80,6 +87,7 @@ public final class RegisterUtils {
LOGGER.info("{} client register success: {} ", type, json);
} else {
LOGGER.error("{} client register error: {} ", type, json);
+ throw new IOException("Admin returned a non-success response for "
+ type + " registration at " + url);
}
}
@@ -103,6 +111,7 @@ public final class RegisterUtils {
LOGGER.info("{} success: {} ", type, json);
} else {
LOGGER.error("{} error: {} ", type, json);
+ throw new IOException("Admin returned a non-success response for "
+ type + " at " + url);
}
}
@@ -125,6 +134,7 @@ public final class RegisterUtils {
LOGGER.info("{} client unregister success: {} ", Constants.URI,
json);
} else {
LOGGER.error("{} client unregister error: {} ", Constants.URI,
json);
+ throw new IOException("Admin returned a non-success response for
unregistration at " + url);
}
}
diff --git
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/HttpClientRegisterRepositoryTest.java
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/HttpClientRegisterRepositoryTest.java
index ce7d8e6fe4..eb0dfb23e7 100644
---
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/HttpClientRegisterRepositoryTest.java
+++
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/HttpClientRegisterRepositoryTest.java
@@ -17,8 +17,16 @@
package org.apache.shenyu.register.client.http;
+import okhttp3.Headers;
import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.timer.TaskEntity;
+import org.apache.shenyu.common.timer.Timer;
+import org.apache.shenyu.common.timer.TimerTask;
+import org.apache.shenyu.common.timer.WheelTimerFactory;
import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.register.client.api.FailbackRegistryRepository;
+import org.apache.shenyu.register.client.api.retry.FailureRegistryTask;
+import org.apache.shenyu.register.client.http.utils.OkHttpTools;
import org.apache.shenyu.register.client.http.utils.RegisterUtils;
import org.apache.shenyu.register.client.http.utils.RuntimeUtils;
import org.apache.shenyu.register.common.config.ShenyuRegisterCenterConfig;
@@ -33,22 +41,31 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
/**
* Test cases for {@link HttpClientRegisterRepository}.
@@ -375,6 +392,113 @@ public final class HttpClientRegisterRepositoryTest {
}
}
+ @ParameterizedTest
+ @ValueSource(strings = {FIRST_SERVER, SECOND_SERVER})
+ public void responseFailureShouldScheduleRetryAndRetainOtherFailures(final
String failedServer) throws IOException {
+ final Timer timer = mock(Timer.class);
+ final OkHttpTools http = mock(OkHttpTools.class);
+ try (MockedStatic<WheelTimerFactory> timers =
mockStatic(WheelTimerFactory.class);
+ MockedStatic<OkHttpTools> clients =
mockStatic(OkHttpTools.class);
+ MockedStatic<RuntimeUtils> runtime =
mockStatic(RuntimeUtils.class)) {
+ timers.when(WheelTimerFactory::getSharedTimer).thenReturn(timer);
+ clients.when(OkHttpTools::getInstance).thenReturn(http);
+ runtime.when(() ->
RuntimeUtils.listenByOther(anyInt())).thenReturn(false);
+ when(http.post(anyString(),
anyString())).thenReturn("{\"code\":200,\"data\":{\"token\":\"test-token\"}}");
+ when(http.post(anyString(), anyString(),
any(Headers.class))).thenReturn(Constants.SUCCESS);
+ when(http.post(eq(failedServer + Constants.URI_PATH), anyString(),
any(Headers.class)))
+ .thenReturn("failure", "failure", Constants.SUCCESS);
+ final HttpClientRegisterRepository actual = new
HttpClientRegisterRepository(config(FIRST_SERVER + "," + SECOND_SERVER));
+
+ actual.persistURI(uriRegisterDTO());
+
+ final Map<String, ?> failures = failureMap(actual);
+ assertEquals(1, failures.size());
+ final String uriKey = failures.keySet().iterator().next();
+ ArgumentCaptor<TimerTask> scheduled =
ArgumentCaptor.forClass(TimerTask.class);
+ verify(timer).add(scheduled.capture());
+ final FailureRegistryTask task = (FailureRegistryTask)
scheduled.getValue();
+
+ when(http.post(eq(FIRST_SERVER + Constants.META_PATH),
anyString(), any(Headers.class))).thenReturn("failure");
+ actual.persistInterface(metaDataRegisterDTO());
+ assertEquals(2, failures.size());
+ TaskEntity entity = mock(TaskEntity.class);
+ when(entity.getTimer()).thenReturn(timer);
+ when(entity.getTimerTask()).thenReturn(task);
+
+ task.run(entity);
+ assertEquals(2, failures.size());
+ assertTrue(failures.containsKey(uriKey));
+ task.run(entity);
+ assertEquals(1, failures.size());
+ assertFalse(failures.containsKey(uriKey));
+ for (String server : new String[]{FIRST_SERVER, SECOND_SERVER}) {
+ verify(http, times(3)).post(eq(server + Constants.URI_PATH),
anyString(), any(Headers.class));
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {FIRST_SERVER, SECOND_SERVER})
+ public void
responseHeartbeatFailureShouldPropagateWithoutRegisterRetry(final String
failedServer) throws IOException {
+ final Timer timer = mock(Timer.class);
+ final OkHttpTools http = mock(OkHttpTools.class);
+ try (MockedStatic<WheelTimerFactory> timers =
mockStatic(WheelTimerFactory.class);
+ MockedStatic<OkHttpTools> clients =
mockStatic(OkHttpTools.class);
+ MockedStatic<RuntimeUtils> runtime =
mockStatic(RuntimeUtils.class)) {
+ timers.when(WheelTimerFactory::getSharedTimer).thenReturn(timer);
+ clients.when(OkHttpTools::getInstance).thenReturn(http);
+ runtime.when(() ->
RuntimeUtils.listenByOther(anyInt())).thenReturn(false);
+ when(http.post(anyString(),
anyString())).thenReturn("{\"code\":200,\"data\":{\"token\":\"test-token\"}}");
+ when(http.post(anyString(), anyString(),
any(Headers.class))).thenReturn(Constants.SUCCESS);
+ when(http.post(eq(failedServer + Constants.URI_PATH), anyString(),
any(Headers.class))).thenReturn("failure");
+ HttpClientRegisterRepository actual = new
HttpClientRegisterRepository(config(FIRST_SERVER + "," + SECOND_SERVER));
+
+ RuntimeException failure = assertThrows(RuntimeException.class, ()
-> actual.sendHeartbeat(uriRegisterDTO()));
+
+ assertTrue(failure.getCause() instanceof IOException);
+ assertTrue(failureMap(actual).isEmpty());
+ verifyNoInteractions(timer);
+ for (String server : new String[]{FIRST_SERVER, SECOND_SERVER}) {
+ verify(http).post(eq(server + Constants.URI_PATH),
anyString(), any(Headers.class));
+ }
+ }
+ }
+
+ @Test
+ public void responseUnregisterFailureShouldKeepAnyServerSuccessPolicy()
throws IOException {
+ final Timer timer = mock(Timer.class);
+ final OkHttpTools http = mock(OkHttpTools.class);
+ try (MockedStatic<WheelTimerFactory> timers =
mockStatic(WheelTimerFactory.class);
+ MockedStatic<OkHttpTools> clients =
mockStatic(OkHttpTools.class)) {
+ timers.when(WheelTimerFactory::getSharedTimer).thenReturn(timer);
+ clients.when(OkHttpTools::getInstance).thenReturn(http);
+ when(http.post(anyString(),
anyString())).thenReturn("{\"code\":200,\"data\":{\"token\":\"test-token\"}}");
+ when(http.post(anyString(), anyString(),
any(Headers.class))).thenReturn(Constants.SUCCESS);
+ when(http.post(eq(FIRST_SERVER + Constants.OFFLINE_PATH),
anyString(), any(Headers.class))).thenReturn("failure");
+ HttpClientRegisterRepository actual = new
HttpClientRegisterRepository(config(FIRST_SERVER + "," + SECOND_SERVER));
+
+ assertDoesNotThrow(() -> actual.offline(uriRegisterDTO()));
+ when(http.post(eq(SECOND_SERVER + Constants.OFFLINE_PATH),
anyString(), any(Headers.class))).thenReturn("failure");
+ assertThrows(RuntimeException.class, () ->
actual.offline(uriRegisterDTO()));
+ assertTrue(failureMap(actual).isEmpty());
+ verifyNoInteractions(timer);
+ for (String server : new String[]{FIRST_SERVER, SECOND_SERVER}) {
+ verify(http, times(2)).post(eq(server +
Constants.OFFLINE_PATH), anyString(), any(Headers.class));
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map<String, ?> failureMap(final HttpClientRegisterRepository
actual) {
+ try {
+ Field field =
FailbackRegistryRepository.class.getDeclaredField("concurrentHashMap");
+ field.setAccessible(true);
+ return (Map<String, ?>) field.get(actual);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
private ShenyuRegisterCenterConfig config(final String serverLists) {
Properties props = new Properties();
props.setProperty(Constants.USER_NAME, "admin");
diff --git
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/utils/RegisterUtilsTest.java
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/utils/RegisterUtilsTest.java
index 151f0ac6f9..bc3e047835 100644
---
a/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/utils/RegisterUtilsTest.java
+++
b/shenyu-register-center/shenyu-register-client/shenyu-register-client-http/src/test/java/org/apache/shenyu/register/client/http/utils/RegisterUtilsTest.java
@@ -25,6 +25,9 @@ import
org.apache.shenyu.register.common.enums.RegisterTypeEnum;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import java.io.IOException;
@@ -84,26 +87,64 @@ public final class RegisterUtilsTest {
RegisterUtils.doRegister(json, url,
RegisterTypeEnum.DUBBO.getName());
verify(okHttpTools, times(1)).post(eq(url), eq(json));
RegisterUtils.doRegister(json, url,
RegisterTypeEnum.DUBBO.getName(), accessToken);
- verify(okHttpTools, times(1)).post(eq(url), eq(json));
+ verify(okHttpTools, times(1)).post(eq(url), eq(json), eq(headers));
}
}
- @Test
- public void testDoRegisterWhenError() throws IOException {
- when(okHttpTools.post(url, json)).thenReturn("Error parameter!");
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"Error parameter!",
"{\"code\":400,\"message\":\"invalid parameters\"}"})
+ public void testDoRegisterWhenError(final String response) throws
IOException {
+ when(okHttpTools.post(url, json)).thenReturn(response);
Headers headers = new Headers.Builder().add(Constants.X_ACCESS_TOKEN,
accessToken).build();
- when(okHttpTools.post(url, json, headers)).thenReturn("Error
parameter!");
+ when(okHttpTools.post(url, json, headers)).thenReturn(response);
try (MockedStatic<OkHttpTools> okHttpToolsMockedStatic =
mockStatic(OkHttpTools.class)) {
okHttpToolsMockedStatic.when(OkHttpTools::getInstance).thenReturn(okHttpTools);
- RegisterUtils.doRegister(json, url,
RegisterTypeEnum.DUBBO.getName());
- verify(okHttpTools, times(1)).post(eq(url), eq(json));
- RegisterUtils.doRegister(json, url,
RegisterTypeEnum.DUBBO.getName(), accessToken);
+ assertThrows(IOException.class, () ->
RegisterUtils.doRegister(json, url, RegisterTypeEnum.DUBBO.getName()));
verify(okHttpTools, times(1)).post(eq(url), eq(json));
+ assertThrows(IOException.class, () ->
RegisterUtils.doRegister(json, url, RegisterTypeEnum.DUBBO.getName(),
accessToken));
+ verify(okHttpTools, times(1)).post(eq(url), eq(json), eq(headers));
RegisterUtils.doRegister(json, url,
RegisterTypeEnum.DUBBO.getName(), null);
}
}
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"failure", "{\"code\":400,\"message\":\"invalid
parameters\"}"})
+ public void testHeartbeatRejectsNonSuccessResponse(final String response)
throws IOException {
+ Headers headers = new Headers.Builder().add(Constants.X_ACCESS_TOKEN,
accessToken).build();
+ when(okHttpTools.post(url, json, headers)).thenReturn(response);
+ try (MockedStatic<OkHttpTools> mocked = mockStatic(OkHttpTools.class))
{
+ mocked.when(OkHttpTools::getInstance).thenReturn(okHttpTools);
+ assertThrows(IOException.class, () ->
RegisterUtils.doHeartBeat(json, url, Constants.HEARTBEAT, accessToken));
+ }
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"failure", "{\"code\":400,\"message\":\"invalid
parameters\"}"})
+ public void testUnregisterRejectsNonSuccessResponse(final String response)
throws IOException {
+ Headers headers = new Headers.Builder().add(Constants.X_ACCESS_TOKEN,
accessToken).build();
+ when(okHttpTools.post(url, json, headers)).thenReturn(response);
+ try (MockedStatic<OkHttpTools> mocked = mockStatic(OkHttpTools.class))
{
+ mocked.when(OkHttpTools::getInstance).thenReturn(okHttpTools);
+ assertThrows(IOException.class, () ->
RegisterUtils.doUnregister(json, url, accessToken));
+ }
+ }
+
+ @Test
+ public void testHeartbeatAndUnregisterAcceptSuccess() throws IOException {
+ Headers headers = new Headers.Builder().add(Constants.X_ACCESS_TOKEN,
accessToken).build();
+ when(okHttpTools.post(url, json,
headers)).thenReturn(Constants.SUCCESS);
+ try (MockedStatic<OkHttpTools> mocked = mockStatic(OkHttpTools.class))
{
+ mocked.when(OkHttpTools::getInstance).thenReturn(okHttpTools);
+ RegisterUtils.doHeartBeat(json, url, Constants.HEARTBEAT,
accessToken);
+ RegisterUtils.doUnregister(json, url, accessToken);
+ verify(okHttpTools, times(2)).post(url, json, headers);
+ }
+ }
+
@Test
public void testDoRegisterWhenThrowException() throws IOException {
when(okHttpTools.post(url, json)).thenThrow(IOException.class);