diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallable.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallable.java index 1097bf96379e..b40093057e9a 100644 --- a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallable.java +++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallable.java @@ -33,13 +33,27 @@ import io.grpc.StatusException; import io.grpc.StatusRuntimeException; import java.time.Duration; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; /** A callable to fork traffic between classic and session based operations. */ public class DivertingUnaryCallable extends UnaryCallable { + private static final Logger LOGGER = Logger.getLogger(DivertingUnaryCallable.class.getName()); + + /** Bounds the cause walk so a self-referential or pathologically deep chain can't spin. */ + private static final int MAX_CAUSE_DEPTH = 8; + + /** Gates the WARNING-level log for statusless throwables to the first occurrence. */ + private final AtomicBoolean loggedStatusless = new AtomicBoolean(); + private final ClientConfigurationManager configurationManager; private final UnaryCallable classic; @@ -121,16 +135,135 @@ ApiException translateException(Throwable e) { } } - Status.Code code = Status.Code.UNKNOWN; + Status.Code carried = findStatusCode(cause); + if (carried != null) { + return ApiExceptionFactory.createException( + cause.getMessage(), e, GrpcStatusCode.of(carried), false); + } + + // Nothing in the chain carries a gRPC status, so the code has to be inferred from the throwable + // itself. Whatever it comes out as, name the throwable in the message too. This really is the + // last chance to say what failed: CSM takes its status from VRpcResult, so a throwable that got + // here either escaped before any VRpcResult existed (no CSM record of the operation at all) or + // came from an OK one (CSM records a success). Either way it is invisible in the metrics, and + // the code and message below are the only evidence the failure happened. + Status.Code inferred = classifyStatuslessCause(cause); + reportStatusless(cause, inferred); + return ApiExceptionFactory.createException( + describeStatusless(cause, inferred), e, GrpcStatusCode.of(inferred), false); + } + + /** + * Returns the gRPC code for {@code t}, or null if nothing in its cause chain carries one. + * + *

Unlike a plain {@code instanceof} on the top-level throwable, this walks the whole chain: a + * perfectly good {@link StatusRuntimeException} wrapped in any type other than Completion/ + * ExecutionException would otherwise lose its code and fall through to {@link + * #classifyStatuslessCause}, which cannot recover it. + * + *

{@link CancellationException} is treated as CANCELLED so that the two paths a caller can be + * routed down agree: the classic path reports it that way via {@code + * csm.attributes.Util#extractStatus}, and the same failure should not change code just because + * sessionLoad diverted the request. It is checked at every level rather than only the top, unlike + * {@code extractStatus}, because a wrapped cancellation losing its code is the same defect the + * chain walk exists to fix. + */ + @Nullable + private static Status.Code findStatusCode(@Nullable Throwable t) { + Throwable current = t; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof StatusRuntimeException) { + return ((StatusRuntimeException) current).getStatus().getCode(); + } + if (current instanceof StatusException) { + return ((StatusException) current).getStatus().getCode(); + } + if (current instanceof CancellationException) { + return Status.Code.CANCELLED; + } + Throwable next = current.getCause(); + if (next == current) { + break; // self-referential chain + } + current = next; + } + return null; + } + + /** + * Infers a code for a throwable whose chain carries no gRPC status, falling back to UNKNOWN. + * + *

UNKNOWN is the honest answer only when the type says nothing. For the two types this path + * actually sees it says plenty, so reporting UNKNOWN throws away a classification the caller can + * act on: + * + *

    + *
  • {@link IllegalStateException} means a client-side invariant was violated -- {@code + * SessionList}'s close/drain checks, and {@code UnaryResponseFuture}'s OK-without-message + * branch. That is a bug in the client, which is what INTERNAL means. + *
  • {@link RejectedExecutionException} means an executor refused the work, so the client is + * out of a resource it needs: RESOURCE_EXHAUSTED. + *
+ * + *

Walks the chain outermost-first, like {@link #findStatusCode}, but with lower precedence: a + * real status anywhere in the chain still wins over a type inferred here. + */ + private static Status.Code classifyStatuslessCause(@Nullable Throwable cause) { + Throwable current = cause; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof RejectedExecutionException) { + return Status.Code.RESOURCE_EXHAUSTED; + } + if (current instanceof IllegalStateException) { + return Status.Code.INTERNAL; + } + Throwable next = current.getCause(); + if (next == current) { + break; // self-referential chain + } + current = next; + } + return Status.Code.UNKNOWN; + } - if (cause instanceof StatusRuntimeException) { - code = ((StatusRuntimeException) cause).getStatus().getCode(); + /** Renders the cause chain as class names, so the message identifies the failure by itself. */ + private static String describeStatusless(@Nullable Throwable cause, Status.Code reported) { + // No caller reaches here with null today, but this is the diagnostic path: an NPE thrown while + // building the error message would destroy exactly the information the message exists to carry. + if (cause == null) { + return "Session operation failed with a null error; reporting " + reported + "."; } - if (cause instanceof StatusException) { - code = ((StatusException) cause).getStatus().getCode(); + StringBuilder chain = new StringBuilder(); + Throwable current = cause; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (chain.length() > 0) { + chain.append(" <- "); + } + chain.append(current.getClass().getName()); + Throwable next = current.getCause(); + if (next == current) { + break; + } + current = next; } + String message = cause.getMessage(); + return "Session operation failed with an error that carries no gRPC status; reporting " + + reported + + ". Cause chain: " + + chain + + (message != null ? ". Message: " + message : ""); + } - return ApiExceptionFactory.createException( - cause.getMessage(), e, GrpcStatusCode.of(code), false); + /** + * Logs the first statusless throwable per callable at WARNING with a full stack, and the rest at + * FINE. A storm is exactly when this fires most, so an unconditional WARNING would flood the log + * at the moment the operator can least afford it. + */ + private void reportStatusless(@Nullable Throwable cause, Status.Code reported) { + if (loggedStatusless.compareAndSet(false, true)) { + LOGGER.log(Level.WARNING, describeStatusless(cause, reported), cause); + } else if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.log(Level.FINE, describeStatusless(cause, reported), cause); + } } } diff --git a/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/api/SessionPathErrorEscapeTest.java b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/api/SessionPathErrorEscapeTest.java new file mode 100644 index 000000000000..5e0bd54433b9 --- /dev/null +++ b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/api/SessionPathErrorEscapeTest.java @@ -0,0 +1,232 @@ +/* + * Copyright 2026 Google LLC + * + * 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 + * + * https://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 com.google.cloud.bigtable.data.v2.internal.api; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.bigtable.v2.CloseSessionRequest; +import com.google.bigtable.v2.SessionReadRowRequest; +import com.google.bigtable.v2.SessionReadRowResponse; +import com.google.bigtable.v2.VirtualRpcResponse; +import com.google.cloud.bigtable.data.v2.internal.csm.NoopMetrics; +import com.google.cloud.bigtable.data.v2.internal.csm.attributes.ClientInfo; +import com.google.cloud.bigtable.data.v2.internal.csm.tracers.VRpcTracer; +import com.google.cloud.bigtable.data.v2.internal.middleware.VRpc; +import com.google.cloud.bigtable.data.v2.internal.middleware.VRpc.VRpcResult; +import com.google.cloud.bigtable.data.v2.internal.session.BigtableTimer; +import com.google.cloud.bigtable.data.v2.internal.session.SessionPool; +import com.google.cloud.bigtable.data.v2.internal.session.SessionPoolInfo; +import com.google.cloud.bigtable.data.v2.internal.session.VRpcDescriptor; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.Message; +import io.grpc.Deadline; +import io.grpc.Metadata; +import io.grpc.Status; +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.Mockito; + +/** + * Fault injection at the seam between {@code SessionPoolMap.apply} and the session machinery. + * + *

An application-visible UNKNOWN requires a throwable with no grpc Status to reach {@code + * DivertingUnaryCallable.translateException}. That can only happen if the throw escapes {@link + * TableBase#readRow} synchronously, because everything inside the op chain is converted to a Status + * first. These tests establish which throw sites actually escape. + */ +@Timeout(30) +public class SessionPathErrorEscapeTest { + + private static final ClientInfo CLIENT_INFO = + ClientInfo.builder() + .setInstanceName( + InstanceName.builder() + .setProjectId("fake-project") + .setInstanceId("fake-instance") + .build()) + .setAppProfileId("default") + .build(); + + private final BigtableTimer mockTimer = Mockito.mock(BigtableTimer.class); + private final Deadline deadline = Deadline.after(1, TimeUnit.MINUTES); + + @Test + public void sessionPoolNewCallThrow_isConvertedToCancelled() { + // Verifies that a throw out of SessionPool.newCall does not escape as a bare throwable: it is + // caught by RetryingVRpc.start and turned into CANCELLED, both for the caller and in CSM. + // + // The throw is injected directly rather than raced for -- FakeSessionPool.newCall throws the + // IllegalStateException that SessionList would raise on a real close/drain race ("NEW session + // was closed", "double close"). The test is about what RetryingVRpc does with such a throw, not + // about reproducing the interleaving that produces it, so injecting it keeps the test + // deterministic. It also means the SessionList race cannot be a source of an application + // UNKNOWN: whatever wins the race, the throw surfaces as CANCELLED in both places. + CountingMetrics metrics = new CountingMetrics(); + FakeSessionPool pool = new FakeSessionPool(); + pool.throwOnNewCall = new IllegalStateException("double close"); + TableBase table = newTable(pool, metrics); + UnaryResponseFuture listener = new UnaryResponseFuture<>(); + + table.readRow(SessionReadRowRequest.getDefaultInstance(), listener, deadline); + + assertThat(listener.isCompletedExceptionally()).isTrue(); + ExecutionException ee = + assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(ee).hasCauseThat().isInstanceOf(VRpcException.class); + VRpcException vrpc = (VRpcException) ee.getCause(); + assertThat(vrpc.getStatus().getCode()).isEqualTo(Status.Code.CANCELLED); + // The original throw survives as the cause, and CSM records the same CANCELLED the caller saw. + assertThat(Status.fromThrowable(vrpc).getCause()).isInstanceOf(IllegalStateException.class); + assertThat(metrics.operationsFinished.get()).isEqualTo(1); + assertThat(metrics.lastOperationStatus.get()).isEqualTo(Status.Code.CANCELLED); + } + + @Test + public void okResultWithoutMessage_failsCallerButRecordsOkInCsm() { + // Verifies the quietest failure mode: an application error that CSM records as a success. + // + // UnaryResponseFuture.onClose completes the caller exceptionally with a bare + // IllegalStateException when the vRPC closes OK but no message arrived. The VRpcResult status + // is OK, so the tracer records OK and the server saw a success -- yet the application gets an + // exception, and translateException has no Status to read from it. + // + // A row that does not exist does NOT take this path: the server still sends one + // SessionReadRowResponse, with `row` unset, and ReadRowShim#buildRow turns that into a null + // row. So reaching here means the server closed OK without sending the message at all, which + // is a protocol violation rather than a normal not-found -- throwing is right, and the code + // below pins what the caller sees when it happens. + UnaryResponseFuture listener = new UnaryResponseFuture<>(); + VRpcResult okResult = VRpcResult.createServerOk(VirtualRpcResponse.getDefaultInstance()); + assertThat(okResult.getStatus().isOk()).isTrue(); + + listener.onClose(okResult); + + assertThat(listener.isCompletedExceptionally()).isTrue(); + ExecutionException ee = + assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(ee).hasCauseThat().isInstanceOf(IllegalStateException.class); + assertThat(ee).hasCauseThat().hasMessageThat().contains("missing result"); + // No grpc Status anywhere on it -- this is exactly the input translateException has to classify + // without help. See DivertingUnaryCallableTest#translateException_illegalStateBecomesInternal. + assertThat(ee.getCause()).isNotInstanceOf(io.grpc.StatusRuntimeException.class); + } + + @Test + public void okResultWithMessage_completesNormally() { + // Verifies the control for the test above: the same OK result with a message delivered first + // completes the caller normally. + UnaryResponseFuture listener = new UnaryResponseFuture<>(); + SessionReadRowResponse response = SessionReadRowResponse.getDefaultInstance(); + + listener.onMessage(response); + listener.onClose(VRpcResult.createServerOk(VirtualRpcResponse.getDefaultInstance())); + + assertThat(listener.isCompletedExceptionally()).isFalse(); + } + + private TableBase newTable(FakeSessionPool pool, CountingMetrics metrics) { + return new TableBase( + pool, + VRpcDescriptor.READ_ROW, + VRpcDescriptor.MUTATE_ROW, + metrics, + mockTimer, + MoreExecutors.directExecutor()); + } + + /** NoopMetrics that records what the operation-level tracer was told at completion. */ + private static final class CountingMetrics extends NoopMetrics { + final AtomicInteger operationsFinished = new AtomicInteger(); + final AtomicReference lastOperationStatus = new AtomicReference<>(); + + @Override + public VRpcTracer newTableTracer( + SessionPoolInfo poolInfo, VRpcDescriptor descriptor, Deadline deadline) { + return new NoopVrpcTracer() { + @Override + public void onOperationFinish(VRpcResult result) { + operationsFinished.incrementAndGet(); + lastOperationStatus.set(result.getStatus().getCode()); + } + }; + } + } + + /** SessionPool whose newCall can be told to throw, simulating a close/drain race. */ + private static final class FakeSessionPool + implements SessionPool { + @Nullable RuntimeException throwOnNewCall; + + @Override + public void start(com.google.bigtable.v2.OpenTableRequest openReq, Metadata md) {} + + @Override + public void close(CloseSessionRequest req) {} + + @Override + public boolean awaitTerminated(Duration timeout) { + return true; + } + + @Override + public SessionPoolInfo getInfo() { + return SessionPoolInfo.create(CLIENT_INFO, VRpcDescriptor.TABLE_SESSION, "fake-pool"); + } + + @Override + public VRpc newCall( + VRpcDescriptor desc) { + if (throwOnNewCall != null) { + throw throwOnNewCall; + } + return new NeverCompletingVRpc<>(); + } + + @Override + public int getConsecutiveUnimplementedFailures() { + return 0; + } + + @Override + public boolean hasSession() { + return true; + } + } + + private static final class NeverCompletingVRpc implements VRpc { + @Override + public void start(Object req, VRpcCallContext ctx, VRpcListener listener) {} + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) {} + + @Override + public boolean isDone() { + return false; + } + + @Override + public void requestNext() {} + } +} diff --git a/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallableTest.java b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallableTest.java new file mode 100644 index 000000000000..197b3ef3d95b --- /dev/null +++ b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/data/v2/internal/compat/ops/DivertingUnaryCallableTest.java @@ -0,0 +1,378 @@ +/* + * Copyright 2026 Google LLC + * + * 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 + * + * https://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 com.google.cloud.bigtable.data.v2.internal.compat.ops; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.core.ApiFuture; +import com.google.api.gax.grpc.GrpcCallContext; +import com.google.api.gax.grpc.GrpcStatusCode; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.bigtable.v2.ClientConfiguration; +import com.google.cloud.bigtable.data.v2.internal.util.ClientConfigurationManager; +import io.grpc.Deadline; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import java.io.Closeable; +import java.time.Duration; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Pins the status mapping the session path presents to the application. + * + *

{@link DivertingUnaryCallable#translateException} is the only place the session path converts + * a failure into the caller's exception. A throwable that carries a {@link StatusException}/{@link + * StatusRuntimeException} keeps its code; anything else has to be classified from its type, and + * only what cannot be classified is reported as UNKNOWN. These tests pin that mapping. + */ +class DivertingUnaryCallableTest { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10); + + /** + * translateException reads no instance state, so a field-less instance is enough to exercise it + * directly. The end-to-end tests below build a fully wired callable instead. + */ + private final DivertingUnaryCallable bare = + new DivertingUnaryCallable<>(null, null, null, DEFAULT_TIMEOUT); + + private static Status.Code codeOf(ApiException e) { + return ((GrpcStatusCode) e.getStatusCode()).getTransportCode(); + } + + @Test + void translateException_illegalStateBecomesInternal() { + // Verifies that a violated client-side invariant is reported as INTERNAL rather than UNKNOWN. + // IllegalStateException is the shape thrown by SessionList ("NEW session was closed", "double + // close"), DebugTagTracer, and UnaryResponseFuture's OK-without-message branch. None of them + // carry a grpc Status, but all of them mean the same thing: a bug on the client side. + ApiException translated = bare.translateException(new IllegalStateException("double close")); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.INTERNAL); + assertThat(translated).hasMessageThat().contains("double close"); + } + + @Test + void translateException_rejectedExecutionBecomesResourceExhausted() { + // Verifies that an executor refusing work is reported as RESOURCE_EXHAUSTED. A saturated or + // shutting-down executor is the other realistic non-Status throwable on this path; + // SessionPoolMap's javadoc calls it out explicitly. + ApiException translated = + bare.translateException(new RejectedExecutionException("executor saturated")); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.RESOURCE_EXHAUSTED); + } + + @Test + void translateException_unclassifiableThrowableBecomesUnknown() { + // Verifies that UNKNOWN is now reserved for types that genuinely say nothing about the failure. + ApiException translated = bare.translateException(new RuntimeException("something else")); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.UNKNOWN); + } + + @Test + void translateException_classifiesOutermostRecognizedTypeFirst() { + // Verifies the precedence between two classifiable types in one chain: outermost wins, matching + // how a carried Status is found. + ApiException rejectedOutside = + bare.translateException( + new RejectedExecutionException( + "executor saturated", new IllegalStateException("double close"))); + ApiException illegalStateOutside = + bare.translateException( + new IllegalStateException( + "double close", new RejectedExecutionException("executor saturated"))); + + assertThat(codeOf(rejectedOutside)).isEqualTo(Status.Code.RESOURCE_EXHAUSTED); + assertThat(codeOf(illegalStateOutside)).isEqualTo(Status.Code.INTERNAL); + } + + @Test + void translateException_retainsOriginalThrowableAsCause() { + // Verifies the original throwable is preserved in the exception chain, not just described in + // the message. + IllegalStateException original = new IllegalStateException("double close"); + + ApiException translated = bare.translateException(original); + + assertThat(translated).hasCauseThat().isSameInstanceAs(original); + } + + @Test + void translateException_statusRuntimeExceptionKeepsItsCode() { + // Verifies the normal error path is untouched: a carried Status keeps its code. + ApiException translated = + bare.translateException( + Status.DEADLINE_EXCEEDED.withDescription("too slow").asRuntimeException()); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.DEADLINE_EXCEEDED); + } + + @Test + void translateException_statusExceptionKeepsItsCode() { + // Verifies the checked variant of the same, which arrives from a different grpc entry point. + ApiException translated = + bare.translateException(Status.UNAVAILABLE.withDescription("no session").asException()); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.UNAVAILABLE); + } + + @Test + void translateException_unwrapsCompletionAndExecutionException() { + // Verifies the two wrappers the async plumbing adds are both seen through, including nested. + ApiException viaCompletion = + bare.translateException(new CompletionException(Status.NOT_FOUND.asRuntimeException())); + ApiException viaExecution = + bare.translateException(new ExecutionException(Status.NOT_FOUND.asRuntimeException())); + ApiException viaNested = + bare.translateException( + new CompletionException(new ExecutionException(Status.NOT_FOUND.asRuntimeException()))); + + assertThat(codeOf(viaCompletion)).isEqualTo(Status.Code.NOT_FOUND); + assertThat(codeOf(viaExecution)).isEqualTo(Status.Code.NOT_FOUND); + assertThat(codeOf(viaNested)).isEqualTo(Status.Code.NOT_FOUND); + } + + @Test + void translateException_findsStatusDeepInCauseChain() { + // Verifies a Status is found at any depth. Unwrapping used to stop at Completion/ + // ExecutionException, so a StatusRuntimeException wrapped in anything else lost its code. + ApiException oneDeep = + bare.translateException( + new RuntimeException("wrapper", Status.DEADLINE_EXCEEDED.asRuntimeException())); + ApiException threeDeep = + bare.translateException( + new IllegalStateException( + "outer", + new RuntimeException( + "middle", + new IllegalArgumentException("inner", Status.ABORTED.asException())))); + + assertThat(codeOf(oneDeep)).isEqualTo(Status.Code.DEADLINE_EXCEEDED); + assertThat(codeOf(threeDeep)).isEqualTo(Status.Code.ABORTED); + } + + @Test + void translateException_cancellationExceptionBecomesCancelled() { + // Verifies CancellationException maps to CANCELLED, the same code the classic path reports for + // it via csm.attributes.Util#extractStatus. The two used to disagree, so the same failure got a + // different code depending on whether sessionLoad happened to divert the request. + ApiException translated = bare.translateException(new CancellationException("caller gave up")); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.CANCELLED); + } + + @Test + void translateException_findsCancellationDeepInCauseChain() { + // Verifies a wrapped cancellation is still CANCELLED. This is stricter than + // csm.attributes.Util#extractStatus, which only checks the top level, so a nested cancellation + // is CANCELLED here and UNKNOWN on the classic path. + ApiException translated = + bare.translateException( + new IllegalStateException("wrapper", new CancellationException("caller gave up"))); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.CANCELLED); + } + + @Test + void translateException_statusOutranksCancellationAtTheSameDepth() { + // Verifies the walk order between the two: outermost wins, whichever it is. + CancellationException outer = new CancellationException("caller gave up"); + outer.initCause(Status.DEADLINE_EXCEEDED.asRuntimeException()); + + ApiException cancellationOutside = bare.translateException(outer); + ApiException statusOutside = + bare.translateException( + Status.DEADLINE_EXCEEDED + .withCause(new CancellationException("caller gave up")) + .asRuntimeException()); + + assertThat(codeOf(cancellationOutside)).isEqualTo(Status.Code.CANCELLED); + assertThat(codeOf(statusOutside)).isEqualTo(Status.Code.DEADLINE_EXCEEDED); + } + + @Test + void translateException_toleratesSelfReferentialCauseChain() { + // Verifies a throwable that is its own cause does not spin the walk. + SelfCausedException looping = new SelfCausedException(); + + ApiException translated = bare.translateException(looping); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.UNKNOWN); + } + + @Test + void translateException_messageNamesTheCauseChain() { + // Verifies the message identifies the failure by itself. Without it the caller gets a code and + // nothing else, which is all that reaches CSM, their error counters, or a support case. + ApiException translated = + bare.translateException( + new IllegalStateException( + "Unary rpc completed OK but missing result", + new RejectedExecutionException("executor saturated"))); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.INTERNAL); + assertThat(translated).hasMessageThat().contains("INTERNAL"); + assertThat(translated).hasMessageThat().contains("java.lang.IllegalStateException"); + assertThat(translated) + .hasMessageThat() + .contains("java.util.concurrent.RejectedExecutionException"); + assertThat(translated).hasMessageThat().contains("Unary rpc completed OK but missing result"); + } + + @Test + void translateException_messageSurvivesNullCauseMessage() { + // Verifies the chain still identifies the throwable when it has no message of its own, as a + // NullPointerException usually does not. + ApiException translated = bare.translateException(new NullPointerException()); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.UNKNOWN); + assertThat(translated).hasMessageThat().contains("java.lang.NullPointerException"); + } + + @Test + void translateException_nullThrowableStillProducesUnknown() { + // Verifies the diagnostic path itself cannot throw. CompletableFuture#handle never hands us a + // null, but an NPE raised while *building* the error message would replace the very failure + // the message exists to report. + ApiException translated = bare.translateException(null); + + assertThat(codeOf(translated)).isEqualTo(Status.Code.UNKNOWN); + assertThat(translated).hasMessageThat().contains("null error"); + } + + @Test + void futureCall_shimFailureWithNonStatusThrowableSurfacesAsInternal() { + // Verifies the mapping end to end: a shim future that fails with a statusless throwable reaches + // the application through the real futureCall plumbing, not just translateException. + DivertingUnaryCallable callable = + newCallable( + (request, deadline) -> { + CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new IllegalStateException("double close")); + return f; + }); + + ApiException surfaced = failureOf(callable.futureCall("req", GrpcCallContext.createDefault())); + + assertThat(codeOf(surfaced)).isEqualTo(Status.Code.INTERNAL); + } + + @Test + void futureCall_sessionPoolMapSyncThrowSurfacesAsInternal() { + // Verifies the full seam: TableBase.readRow throws synchronously on the caller thread -> + // SessionPoolMap.apply's `catch (Throwable)` converts it to a failed future -> + // translateException + // classifies it. No grpc Status is involved at any point, so the code comes entirely from the + // throwable's type. + SessionPoolMap poolMap = new SessionPoolMap<>(key -> new NoopHandle()); + DivertingUnaryCallable callable = + newCallable( + (request, deadline) -> + poolMap.apply( + "table", + handle -> { + throw new IllegalStateException("NEW session was closed"); + })); + + ApiException surfaced = failureOf(callable.futureCall("req", GrpcCallContext.createDefault())); + + assertThat(codeOf(surfaced)).isEqualTo(Status.Code.INTERNAL); + assertThat(surfaced).hasCauseThat().isNotNull(); + } + + @Test + void futureCall_sessionPoolMapStatusThrowKeepsItsCode() { + // Verifies the same seam is not itself lossy: a throw that already carries a Status keeps it. + SessionPoolMap poolMap = new SessionPoolMap<>(key -> new NoopHandle()); + DivertingUnaryCallable callable = + newCallable( + (request, deadline) -> + poolMap.apply( + "table", + handle -> { + throw Status.UNAVAILABLE.withDescription("pool wedged").asRuntimeException(); + })); + + ApiException surfaced = failureOf(callable.futureCall("req", GrpcCallContext.createDefault())); + + assertThat(codeOf(surfaced)).isEqualTo(Status.Code.UNAVAILABLE); + } + + private static DivertingUnaryCallable newCallable(ShimFn shim) { + ClientConfiguration.Builder config = ClientConfiguration.newBuilder(); + config.getSessionConfigurationBuilder().setSessionLoad(1.0f); + + ClientConfigurationManager configManager = Mockito.mock(ClientConfigurationManager.class); + Mockito.when(configManager.getClientConfiguration()).thenReturn(config.build()); + + return new DivertingUnaryCallable<>(configManager, new FailingClassic(), shim, DEFAULT_TIMEOUT); + } + + /** Extracts the ApiException the application would observe from {@code future.get()}. */ + private static ApiException failureOf(ApiFuture future) { + ExecutionException ee = + assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS)); + assertThat(ee).hasCauseThat().isInstanceOf(ApiException.class); + return (ApiException) ee.getCause(); + } + + /** sessionLoad is pinned to 1.0 in these tests, so the classic path must never be taken. */ + private static final class FailingClassic extends UnaryCallable { + @Override + public ApiFuture futureCall(String request, ApiCallContext context) { + throw new AssertionError("classic path taken despite sessionLoad=1.0"); + } + } + + private static final class NoopHandle implements Closeable { + @Override + public void close() {} + } + + /** Its own cause — exercises the cycle guard in the cause walk. */ + private static final class SelfCausedException extends RuntimeException { + @Override + public synchronized Throwable getCause() { + return this; + } + } + + /** + * UnaryShim extends Closeable, so it has two abstract methods and cannot be a lambda target on + * its own. Defaulting close() away makes call() the single abstract method. + */ + @FunctionalInterface + private interface ShimFn extends UnaryShim { + @Override + CompletableFuture call(String request, Deadline deadline); + + @Override + default void close() {} + } +}