diff --git a/api/src/testFixtures/java/io/grpc/StatusMatcher.java b/api/src/testFixtures/java/io/grpc/StatusMatcher.java index f464b2d709d..08e9fffb013 100644 --- a/api/src/testFixtures/java/io/grpc/StatusMatcher.java +++ b/api/src/testFixtures/java/io/grpc/StatusMatcher.java @@ -26,7 +26,7 @@ */ public final class StatusMatcher implements ArgumentMatcher { public static StatusMatcher statusHasCode(ArgumentMatcher codeMatcher) { - return new StatusMatcher(codeMatcher, null); + return new StatusMatcher(codeMatcher, null, null); } public static StatusMatcher statusHasCode(Status.Code code) { @@ -35,17 +35,20 @@ public static StatusMatcher statusHasCode(Status.Code code) { private final ArgumentMatcher codeMatcher; private final ArgumentMatcher descriptionMatcher; + private final ArgumentMatcher causeMatcher; private StatusMatcher( ArgumentMatcher codeMatcher, - ArgumentMatcher descriptionMatcher) { + ArgumentMatcher descriptionMatcher, + ArgumentMatcher causeMatcher) { this.codeMatcher = checkNotNull(codeMatcher, "codeMatcher"); this.descriptionMatcher = descriptionMatcher; + this.causeMatcher = causeMatcher; } public StatusMatcher andDescription(ArgumentMatcher descriptionMatcher) { checkState(this.descriptionMatcher == null, "Already has a description matcher"); - return new StatusMatcher(codeMatcher, descriptionMatcher); + return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher); } public StatusMatcher andDescription(String description) { @@ -56,11 +59,21 @@ public StatusMatcher andDescriptionContains(String substring) { return andDescription(new StringContainsMatcher(substring)); } + public StatusMatcher andCause(ArgumentMatcher causeMatcher) { + checkState(this.causeMatcher == null, "Already has a cause matcher"); + return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher); + } + + public StatusMatcher andCause(Throwable cause) { + return andCause(new EqualsMatcher<>(cause)); + } + @Override public boolean matches(Status status) { return status != null && codeMatcher.matches(status.getCode()) - && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription())); + && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription())) + && (causeMatcher == null || causeMatcher.matches(status.getCause())); } @Override @@ -72,6 +85,10 @@ public String toString() { sb.append(", description="); sb.append(descriptionMatcher); } + if (causeMatcher != null) { + sb.append(", cause="); + sb.append(causeMatcher); + } sb.append("}"); return sb.toString(); } diff --git a/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java b/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java index 919836ddd9c..a0af5974175 100644 --- a/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java +++ b/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java @@ -30,6 +30,7 @@ import io.grpc.Status; import io.grpc.StatusOr; import io.grpc.SynchronizationContext; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.RetryingNameResolver; import io.grpc.xds.Endpoints.LocalityLbEndpoints; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; @@ -652,13 +653,10 @@ public void onResourceChanged(StatusOr update) { data = update; subscribeToChildren(update.getValue()); } else { - Status status = update.getStatus(); - Status translatedStatus = Status.UNAVAILABLE.withDescription( - String.format("Error retrieving %s: %s. Details: %s%s", - toContextString(), - status.getCode(), - status.getDescription() != null ? status.getDescription() : "", - nodeInfo())); + Status translatedStatus = GrpcUtil.statusWithDetails( + Status.Code.UNAVAILABLE, + "Error retrieving " + toContextString() + nodeInfo(), + update.getStatus()); data = StatusOr.fromStatus(translatedStatus); } diff --git a/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java b/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java index 928520aded7..ff4813fe6a8 100644 --- a/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java +++ b/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java @@ -255,9 +255,9 @@ public void nonAggregateCluster_resourceNotExist_returnErrorPicker() { startXdsDepManager(); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - String expectedDescription = "Error retrieving CDS resource " + CLUSTER + ": NOT_FOUND. " - + "Details: Timed out waiting for resource " + CLUSTER - + " from xDS server nodeID: " + NODE_ID; + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; Status unavailable = Status.UNAVAILABLE.withDescription(expectedDescription); assertPickerStatus(pickerCaptor.getValue(), unavailable); assertThat(childBalancers).isEmpty(); @@ -311,8 +311,9 @@ public void nonAggregateCluster_resourceRevoked() { controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of()); assertThat(childBalancer.shutdown).isTrue(); - String expectedDescription = "Error retrieving CDS resource " + CLUSTER + ": NOT_FOUND. " - + "Details: Resource " + CLUSTER + " does not exist nodeID: " + NODE_ID; + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Resource " + CLUSTER + " does not exist"; Status unavailable = Status.UNAVAILABLE.withDescription(expectedDescription); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); @@ -515,9 +516,9 @@ public void aggregateCluster_noNonAggregateClusterExits_returnErrorPicker() { verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - String expectedDescription = "Error retrieving CDS resource " + cluster1 + ": NOT_FOUND. " - + "Details: Timed out waiting for resource " + cluster1 + " from xDS server nodeID: " - + NODE_ID; + String expectedDescription = "Error retrieving CDS resource " + cluster1 + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Timed out waiting for resource " + cluster1 + " from xDS server"; Status status = Status.UNAVAILABLE.withDescription(expectedDescription); assertPickerStatus(pickerCaptor.getValue(), status); assertThat(childBalancers).isEmpty(); diff --git a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java index c6e5db08526..51ac60dd740 100644 --- a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java @@ -690,8 +690,8 @@ public void onlyEdsClusters_resourceNeverExist_returnErrorPicker() { verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - String expectedDescription = "Error retrieving CDS resource " + CLUSTER + ": NOT_FOUND. " - + "Details: Timed out waiting for resource " + CLUSTER + " from xDS server nodeID: node-id"; + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); assertPicker(pickerCaptor.getValue(), expectedError, null); } @@ -713,8 +713,8 @@ public void cdsMissing_handledDirectly() { assertThat(childBalancers).hasSize(0); // no child LB policy created verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - String expectedDescription = "Error retrieving CDS resource " + CLUSTER + ": NOT_FOUND. " - + "Details: Timed out waiting for resource " + CLUSTER + " from xDS server nodeID: node-id"; + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); assertPicker(pickerCaptor.getValue(), expectedError, null); assertPicker(pickerCaptor.getValue(), expectedError, null); @@ -744,8 +744,8 @@ public void cdsRevoked_handledDirectly() { controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of()); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - String expectedDescription = "Error retrieving CDS resource " + CLUSTER + ": NOT_FOUND. " - + "Details: Resource " + CLUSTER + " does not exist nodeID: node-id"; + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Resource " + CLUSTER + " does not exist"; Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); assertPicker(pickerCaptor.getValue(), expectedError, null); assertThat(childBalancer.shutdown).isTrue(); @@ -760,8 +760,8 @@ public void edsMissing_failsRpcs() { verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); String expectedDescription = "Error retrieving EDS resource " + EDS_SERVICE_NAME - + ": NOT_FOUND. Details: Timed out waiting for resource " + EDS_SERVICE_NAME - + " from xDS server nodeID: node-id"; + + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + EDS_SERVICE_NAME + " from xDS server"; Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); assertPicker(pickerCaptor.getValue(), expectedError, null); } diff --git a/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java b/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java new file mode 100644 index 00000000000..c8b32f376ee --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc.xds; + +import static java.util.Objects.requireNonNull; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.NoopClientCall; +import io.grpc.Status; + +/** + * An interceptor that fails all RPCs with the provided status. + */ +final class FailingClientInterceptor implements ClientInterceptor { + private final Status status; + + public FailingClientInterceptor(Status status) { + this.status = requireNonNull(status, "status"); + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new NoopClientCall() { + @Override + public void start(Listener responseListener, Metadata headers) { + responseListener.onClose(status, new Metadata()); + } + }; + } +} diff --git a/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java b/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java index 7bae7000eaf..522eb29c001 100644 --- a/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java @@ -409,6 +409,32 @@ public void testTcpListenerErrors() { testWatcher.verifyStats(0, 1); } + @Test + public void testControlPlaneError() { + Status forcedStatus = Status.NOT_FOUND + .withDescription("expected") + .withCause(new IllegalArgumentException("a random exception")); + xdsClient.shutdown(); + xdsClient = XdsTestUtils.createXdsClient( + Collections.singletonList("control-plane"), + serverInfo -> new GrpcXdsTransportFactory.GrpcXdsTransport( + InProcessChannelBuilder.forName(serverInfo.target()) + .directExecutor() + .intercept(new FailingClientInterceptor(forcedStatus)) + .build()), + fakeClock); + xdsDependencyManager = new XdsDependencyManager( + xdsClient, syncContext, serverName, serverName, nameResolverArgs); + xdsDependencyManager.start(xdsConfigWatcher); + + verify(xdsConfigWatcher).onUpdate( + argThat(StatusOrMatcher.hasStatus( + statusHasCode(Status.Code.UNAVAILABLE) + .andDescriptionContains(forcedStatus.getDescription()) + .andCause(forcedStatus.getCause())))); + testWatcher.verifyStats(0, 1); + } + @Test public void testMissingRds() { String rdsName = "badRdsName";