Isolate route manifest failures during tool preload
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m29s

This commit is contained in:
2026-09-02 17:40:06 +09:00
parent 8a6c3617b4
commit 6653030f03
2 changed files with 104 additions and 2 deletions

View File

@@ -103,7 +103,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
}
/**
* 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회 route별 in-memory snapshot 후보를 만듭니다.
* 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회하되, 한 route의 실패가 다른 정상 route의 manifest 조회를 막지 않도록 route 단위로 실패를 격리합니다.
*
* @return 조회 또는 변환된 목록 정보를 반환합니다.
*/
@@ -111,7 +111,8 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
public Map<String, List<ToolMetadata>> fetchAllTools() {
ensurePortalRegistryLoaded();
Map<String, List<ToolMetadata>> snapshots = new LinkedHashMap<>();
bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles)));
bundlesByRoute.forEach((routeKey, bundles) ->
fetchRouteToolsSafely(routeKey, bundles).ifPresent(tools -> snapshots.put(routeKey, tools)));
return Map.copyOf(snapshots);
}
@@ -313,6 +314,26 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
return merge(results);
}
/**
* 전체 route preload 중 한 route의 Tool Service manifest 조회 실패를 해당 route로만 제한합니다. 물리 MCP 한 대가 여러 논리 route를 담는 구성에서는 하나의 route 장애 때문에 다른 route의 정상 Tool 목록까지 누락되면 안 되므로, 실패 route는 로그만 남기고 성공 route snapshot 수집을 계속합니다.
*
* @param routeKey 처리 대상 route key입니다.
* @param bundles 처리 대상 목록입니다.
* @return 조회된 선택값을 반환합니다.
*/
private Optional<List<ToolMetadata>> fetchRouteToolsSafely(String routeKey, List<Bundle> bundles) {
try {
return Optional.of(fetchRouteTools(routeKey, bundles));
} catch (RuntimeException exception) {
log.warn(
"Portal route manifest refresh ignored: routeKey={}, reason={}, message={}",
routeKey,
exception.getClass().getSimpleName(),
exception.getMessage());
return Optional.empty();
}
}
/**
* Tool Server 한 대의 routing manifest API를 호출합니다. 응답 JSON shape는 Tool Server와 Agent Builder의 계약이므로 MCP가 필드명을 변환하지 않고 원문 JSON tree를 그대로 반환합니다.
*

View File

@@ -177,6 +177,31 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isEqualTo(1);
}
@Test
void keepsUsableRoutesWhenAnotherRouteManifestIsUnavailable() throws Exception {
MockWebServer unreachableToolServer = new MockWebServer();
unreachableToolServer.start();
try {
portal.enqueue(portalRegistryWithOneBrokenAndThreeUsableRoutes("portal-1", unreachableToolServer));
unreachableToolServer.enqueue(new MockResponse().setResponseCode(503));
toolServer.enqueue(manifestForBundle("was-cus", "cus.customer"));
toolServer.enqueue(manifestForBundle("was-pro", "pro.product"));
toolServer.enqueue(manifestForBundle("was-sys", "sys.health"));
PortalToolRegistryClient client = client();
Map<String, List<ToolMetadata>> snapshots = client.fetchAllTools();
assertThat(snapshots).containsOnlyKeys("cus", "pro", "sys");
assertThat(snapshots.get("cus")).extracting(ToolMetadata::name).containsExactly("cus.customer");
assertThat(snapshots.get("pro")).extracting(ToolMetadata::name).containsExactly("pro.product");
assertThat(snapshots.get("sys")).extracting(ToolMetadata::name).containsExactly("sys.health");
assertThat(unreachableToolServer.getRequestCount()).isEqualTo(1);
assertThat(toolServer.getRequestCount()).isEqualTo(3);
} finally {
unreachableToolServer.shutdown();
}
}
@Test
void fetchesRoutingManifestForTheRequestedPortalRoute() throws Exception {
portal.enqueue(portalRegistry("portal-1"));
@@ -393,6 +418,62 @@ class PortalToolRegistryClientTest {
"""
.formatted(revision, toolServer.url("").toString().replaceAll("/+$", "")));
}
private MockResponse portalRegistryWithOneBrokenAndThreeUsableRoutes(
String revision, MockWebServer unreachableToolServer) {
String usableDomain = toolServer.url("").toString().replaceAll("/+$", "");
return jsonResponse(
"""
{
"registryRevision": "%s",
"routes": [
{
"routeKey": "sal",
"toolServices": [ {
"serviceKey": "was-sal",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"status": "ACTIVE"
} ]
},
{
"routeKey": "cus",
"toolServices": [ {
"serviceKey": "was-cus",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"status": "ACTIVE"
} ]
},
{
"routeKey": "pro",
"toolServices": [ {
"serviceKey": "was-pro",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"status": "ACTIVE"
} ]
},
{
"routeKey": "sys",
"toolServices": [ {
"serviceKey": "was-sys",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"status": "ACTIVE"
} ]
}
]
}
"""
.formatted(
revision,
unreachableToolServer.url("").toString().replaceAll("/+$", ""),
usableDomain,
usableDomain,
usableDomain));
}
private MockResponse manifest(String revision, String toolName) {
return manifestForBundle("external-tool-server", revision, toolName);
}