diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4a79e5..ba730b90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Unreleased +* Wrap gRPC StatusRuntimeException across all DurableTaskGrpcClient methods ([#278](https://github.com/microsoft/durabletask-java/pull/278)) * Add work item filtering support for `DurableTaskGrpcWorker` to enable worker-side filtering of orchestration and activity work items ([#275](https://github.com/microsoft/durabletask-java/pull/275)) * Add support for calls to HTTP endpoints ([#271](https://github.com/microsoft/durabletask-java/pull/271)) * Add getSuspendPostUri and getResumePostUri getters to HttpManagementPayload ([#264](https://github.com/microsoft/durabletask-java/pull/264)) diff --git a/client/build.gradle b/client/build.gradle index a40d1039..9ada6749 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation "io.opentelemetry:opentelemetry-api:${openTelemetryVersion}" implementation "io.opentelemetry:opentelemetry-context:${openTelemetryVersion}" + testImplementation "io.grpc:grpc-inprocess:${grpcVersion}" testImplementation "io.opentelemetry:opentelemetry-sdk:${openTelemetryVersion}" testImplementation "io.opentelemetry:opentelemetry-sdk-trace:${openTelemetryVersion}" testImplementation "io.opentelemetry:opentelemetry-sdk-testing:${openTelemetryVersion}" diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index 8778252a..48a60150 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -161,6 +161,8 @@ public String scheduleNewOrchestrationInstance( CreateInstanceRequest request = builder.build(); CreateInstanceResponse response = this.sidecarClient.startInstance(request); return response.getInstanceId(); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "scheduleNewOrchestrationInstance"); } finally { createScope.close(); createSpan.end(); @@ -184,7 +186,11 @@ public void raiseEvent(String instanceId, String eventName, Object eventPayload) } RaiseEventRequest request = builder.build(); - this.sidecarClient.raiseEvent(request); + try { + this.sidecarClient.raiseEvent(request); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "raiseEvent"); + } } @Override @@ -193,8 +199,12 @@ public OrchestrationMetadata getInstanceMetadata(String instanceId, boolean getI .setInstanceId(instanceId) .setGetInputsAndOutputs(getInputsAndOutputs) .build(); - GetInstanceResponse response = this.sidecarClient.getInstance(request); - return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); + try { + GetInstanceResponse response = this.sidecarClient.getInstance(request); + return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "getInstanceMetadata"); + } } @Override @@ -219,7 +229,13 @@ public OrchestrationMetadata waitForInstanceStart(String instanceId, Duration ti if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { throw new TimeoutException("Start orchestration timeout reached."); } - throw e; + Exception translated = StatusRuntimeExceptionHelper.toException(e, "waitForInstanceStart"); + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); } @@ -246,7 +262,13 @@ public OrchestrationMetadata waitForInstanceCompletion(String instanceId, Durati if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { throw new TimeoutException("Orchestration instance completion timeout reached."); } - throw e; + Exception translated = StatusRuntimeExceptionHelper.toException(e, "waitForInstanceCompletion"); + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); } @@ -263,7 +285,11 @@ public void terminate(String instanceId, @Nullable Object output) { if (serializeOutput != null){ builder.setOutput(StringValue.of(serializeOutput)); } - this.sidecarClient.terminateInstance(builder.build()); + try { + this.sidecarClient.terminateInstance(builder.build()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "terminate"); + } } @Override @@ -277,8 +303,12 @@ public OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery qu instanceQueryBuilder.setMaxInstanceCount(query.getMaxInstanceCount()); query.getRuntimeStatusList().forEach(runtimeStatus -> Optional.ofNullable(runtimeStatus).ifPresent(status -> instanceQueryBuilder.addRuntimeStatus(OrchestrationRuntimeStatus.toProtobuf(status)))); query.getTaskHubNames().forEach(taskHubName -> Optional.ofNullable(taskHubName).ifPresent(name -> instanceQueryBuilder.addTaskHubNames(StringValue.of(name)))); - QueryInstancesResponse queryInstancesResponse = this.sidecarClient.queryInstances(QueryInstancesRequest.newBuilder().setQuery(instanceQueryBuilder).build()); - return toQueryResult(queryInstancesResponse, query.isFetchInputsAndOutputs()); + try { + QueryInstancesResponse queryInstancesResponse = this.sidecarClient.queryInstances(QueryInstancesRequest.newBuilder().setQuery(instanceQueryBuilder).build()); + return toQueryResult(queryInstancesResponse, query.isFetchInputsAndOutputs()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "queryInstances"); + } } private OrchestrationStatusQueryResult toQueryResult(QueryInstancesResponse queryInstancesResponse, boolean fetchInputsAndOutputs){ @@ -291,12 +321,20 @@ private OrchestrationStatusQueryResult toQueryResult(QueryInstancesResponse quer @Override public void createTaskHub(boolean recreateIfExists) { - this.sidecarClient.createTaskHub(CreateTaskHubRequest.newBuilder().setRecreateIfExists(recreateIfExists).build()); + try { + this.sidecarClient.createTaskHub(CreateTaskHubRequest.newBuilder().setRecreateIfExists(recreateIfExists).build()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "createTaskHub"); + } } @Override public void deleteTaskHub() { - this.sidecarClient.deleteTaskHub(DeleteTaskHubRequest.newBuilder().build()); + try { + this.sidecarClient.deleteTaskHub(DeleteTaskHubRequest.newBuilder().build()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "deleteTaskHub"); + } } @Override @@ -305,8 +343,12 @@ public PurgeResult purgeInstance(String instanceId) { .setInstanceId(instanceId) .build(); - PurgeInstancesResponse response = this.sidecarClient.purgeInstances(request); - return toPurgeResult(response); + try { + PurgeInstancesResponse response = this.sidecarClient.purgeInstances(request); + return toPurgeResult(response); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "purgeInstance"); + } } @Override @@ -334,7 +376,13 @@ public PurgeResult purgeInstances(PurgeInstanceCriteria purgeInstanceCriteria) t String timeOutException = String.format("Purge instances timeout duration of %s reached.", timeout); throw new TimeoutException(timeOutException); } - throw e; + Exception translated = StatusRuntimeExceptionHelper.toException(e, "purgeInstances"); + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } } @@ -345,7 +393,11 @@ public void suspendInstance(String instanceId, @Nullable String reason) { if (reason != null) { suspendRequestBuilder.setReason(StringValue.of(reason)); } - this.sidecarClient.suspendInstance(suspendRequestBuilder.build()); + try { + this.sidecarClient.suspendInstance(suspendRequestBuilder.build()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "suspendInstance"); + } } @Override @@ -355,7 +407,11 @@ public void resumeInstance(String instanceId, @Nullable String reason) { if (reason != null) { resumeRequestBuilder.setReason(StringValue.of(reason)); } - this.sidecarClient.resumeInstance(resumeRequestBuilder.build()); + try { + this.sidecarClient.resumeInstance(resumeRequestBuilder.build()); + } catch (StatusRuntimeException e) { + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "resumeInstance"); + } } @Override @@ -377,7 +433,7 @@ public void rewindInstance(String instanceId, @Nullable String reason) { throw new IllegalStateException( "Orchestration instance '" + instanceId + "' is not in a failed state and cannot be rewound.", e); } - throw e; + throw StatusRuntimeExceptionHelper.toRuntimeException(e, "rewindInstance"); } } diff --git a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java new file mode 100644 index 00000000..98379e72 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; + +/** + * Utility class to translate gRPC {@link StatusRuntimeException} into SDK-level exceptions. + * This ensures callers do not need to depend on gRPC types directly. + * + *

Status code mappings: + *

+ */ +final class StatusRuntimeExceptionHelper { + + /** + * Translates a {@link StatusRuntimeException} into an appropriate SDK-level unchecked exception. + * + * @param e the gRPC exception to translate + * @param operationName the name of the operation that failed, used in exception messages + * @return a translated RuntimeException (never returns null) + */ + static RuntimeException toRuntimeException(StatusRuntimeException e, String operationName) { + Status.Code code = e.getStatus().getCode(); + String message = formatMessage(operationName, code, getDescriptionOrDefault(e)); + switch (code) { + case CANCELLED: + return createCancellationException(e, operationName); + case INVALID_ARGUMENT: + return new IllegalArgumentException(message, e); + case FAILED_PRECONDITION: + return new IllegalStateException(message, e); + case NOT_FOUND: + return new IllegalArgumentException(message, e); + case UNIMPLEMENTED: + return new UnsupportedOperationException(message, e); + default: + return new RuntimeException(message, e); + } + } + + /** + * Translates a {@link StatusRuntimeException} into an appropriate SDK-level checked exception + * for operations that declare {@code throws TimeoutException}. + *

+ * Note: The DEADLINE_EXCEEDED case is included for completeness and future-proofing, even + * though current call sites handle DEADLINE_EXCEEDED before falling through to this method. + * This ensures centralized translation if call sites are refactored in the future. + * + * @param e the gRPC exception to translate + * @param operationName the name of the operation that failed, used in exception messages + * @return a translated Exception (never returns null) + */ + static Exception toException(StatusRuntimeException e, String operationName) { + Status.Code code = e.getStatus().getCode(); + String message = formatMessage(operationName, code, getDescriptionOrDefault(e)); + switch (code) { + case DEADLINE_EXCEEDED: + return new TimeoutException(message); + case CANCELLED: + return createCancellationException(e, operationName); + case INVALID_ARGUMENT: + return new IllegalArgumentException(message, e); + case FAILED_PRECONDITION: + return new IllegalStateException(message, e); + case NOT_FOUND: + return new IllegalArgumentException(message, e); + case UNIMPLEMENTED: + return new UnsupportedOperationException(message, e); + default: + return new RuntimeException(message, e); + } + } + + private static CancellationException createCancellationException( + StatusRuntimeException e, String operationName) { + CancellationException ce = new CancellationException( + "The " + operationName + " operation was canceled."); + ce.initCause(e); + return ce; + } + + private static String formatMessage(String operationName, Status.Code code, String description) { + return "The " + operationName + " operation failed with a " + code + " gRPC status: " + description; + } + + private static String getDescriptionOrDefault(StatusRuntimeException e) { + String description = e.getStatus().getDescription(); + return description != null ? description : "(no description)"; + } + + // Cannot be instantiated + private StatusRuntimeExceptionHelper() { + } +} diff --git a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java new file mode 100644 index 00000000..94650508 --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java @@ -0,0 +1,596 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask; + +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.CreateInstanceRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.CreateInstanceResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.CreateTaskHubRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.CreateTaskHubResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.DeleteTaskHubRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.DeleteTaskHubResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.GetInstanceRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.GetInstanceResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.PurgeInstancesRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.PurgeInstancesResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.QueryInstancesRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.QueryInstancesResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.RaiseEventRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.RaiseEventResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.ResumeRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.ResumeResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.RewindInstanceRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.RewindInstanceResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.SuspendRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.SuspendResponse; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.TerminateRequest; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.TerminateResponse; +import com.microsoft.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc; + +import io.grpc.*; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Client-level unit tests for {@link DurableTaskGrpcClient} that verify each method catches + * {@code StatusRuntimeException} from the sidecar stub and rethrows translated SDK exceptions. + *

+ * Uses gRPC in-process transport with a fake service implementation that throws configured + * {@code StatusRuntimeException} values. + */ +public class DurableTaskGrpcClientTest { + + private Server inProcessServer; + private ManagedChannel inProcessChannel; + + @AfterEach + void tearDown() { + if (inProcessChannel != null) { + inProcessChannel.shutdownNow(); + } + if (inProcessServer != null) { + inProcessServer.shutdownNow(); + } + } + + /** + * Creates a {@link DurableTaskGrpcClient} backed by an in-process gRPC server that uses the + * provided fake service implementation. + */ + private DurableTaskClient createClientWithFakeService( + TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase serviceImpl) throws IOException { + String serverName = InProcessServerBuilder.generateName(); + inProcessServer = InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(serviceImpl) + .build() + .start(); + inProcessChannel = InProcessChannelBuilder.forName(serverName) + .directExecutor() + .build(); + return new DurableTaskGrpcClientBuilder() + .grpcChannel(inProcessChannel) + .build(); + } + + /** + * Asserts that the given exception's cause is a {@link StatusRuntimeException} with the + * expected status code. gRPC in-process transport recreates exceptions, so we check the + * status code rather than object identity. + */ + private static void assertGrpcCause(Throwable ex, Status.Code expectedCode) { + assertNotNull(ex.getCause(), "Exception should have a cause"); + assertInstanceOf(StatusRuntimeException.class, ex.getCause(), + "Cause should be StatusRuntimeException but was: " + ex.getCause().getClass().getName()); + StatusRuntimeException cause = (StatusRuntimeException) ex.getCause(); + assertEquals(expectedCode, cause.getStatus().getCode(), + "Cause status code should be " + expectedCode); + } + + // ----------------------------------------------------------------------- + // 1. Newly wrapped methods + // ----------------------------------------------------------------------- + + @Test + void scheduleNewOrchestrationInstance_invalidArgument_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void startInstance(CreateInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("bad input"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.scheduleNewOrchestrationInstance("TestOrchestrator", new NewOrchestrationInstanceOptions())); + + assertGrpcCause(ex, Status.Code.INVALID_ARGUMENT); + assertTrue(ex.getMessage().contains("scheduleNewOrchestrationInstance")); + assertTrue(ex.getMessage().contains("INVALID_ARGUMENT")); + } + + @Test + void raiseEvent_notFound_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void raiseEvent(RaiseEventRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.raiseEvent("test-instance", "testEvent")); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + assertTrue(ex.getMessage().contains("raiseEvent")); + } + + @Test + void getInstanceMetadata_notFound_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void getInstance(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.getInstanceMetadata("test-instance", false)); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + } + + @Test + void terminate_unavailable_throwsRuntimeException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void terminateInstance(TerminateRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("connection refused"))); + } + }); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> + client.terminate("test-instance", null)); + + assertGrpcCause(ex, Status.Code.UNAVAILABLE); + assertTrue(ex.getMessage().contains("terminate")); + assertTrue(ex.getMessage().contains("UNAVAILABLE")); + } + + @Test + void queryInstances_unimplemented_throwsUnsupportedOperationException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void queryInstances(QueryInstancesRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNIMPLEMENTED.withDescription("method not supported"))); + } + }); + + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, () -> + client.queryInstances(new OrchestrationStatusQuery())); + + assertGrpcCause(ex, Status.Code.UNIMPLEMENTED); + assertTrue(ex.getMessage().contains("queryInstances")); + } + + @Test + void createTaskHub_failedPrecondition_throwsIllegalStateException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void createTaskHub(CreateTaskHubRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("already exists"))); + } + }); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + client.createTaskHub(false)); + + assertGrpcCause(ex, Status.Code.FAILED_PRECONDITION); + assertTrue(ex.getMessage().contains("createTaskHub")); + } + + @Test + void deleteTaskHub_cancelled_throwsCancellationException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void deleteTaskHub(DeleteTaskHubRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.CANCELLED.withDescription("operation cancelled"))); + } + }); + + CancellationException ex = assertThrows(CancellationException.class, () -> + client.deleteTaskHub()); + + assertGrpcCause(ex, Status.Code.CANCELLED); + } + + @Test + void purgeInstance_notFound_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void purgeInstances(PurgeInstancesRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.purgeInstance("test-instance")); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + } + + @Test + void suspendInstance_invalidArgument_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void suspendInstance(SuspendRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("instanceId is required"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.suspendInstance("test-instance", null)); + + assertGrpcCause(ex, Status.Code.INVALID_ARGUMENT); + assertTrue(ex.getMessage().contains("suspendInstance")); + } + + @Test + void resumeInstance_invalidArgument_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void resumeInstance(ResumeRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("instanceId is required"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.resumeInstance("test-instance", null)); + + assertGrpcCause(ex, Status.Code.INVALID_ARGUMENT); + assertTrue(ex.getMessage().contains("resumeInstance")); + } + + // ----------------------------------------------------------------------- + // 2. Timeout-declaring methods with DEADLINE_EXCEEDED + // ----------------------------------------------------------------------- + + @Test + void waitForInstanceStart_deadlineExceeded_throwsTimeoutException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceStart(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException(Status.DEADLINE_EXCEEDED)); + } + }); + + TimeoutException ex = assertThrows(TimeoutException.class, () -> + client.waitForInstanceStart("test-instance", Duration.ofSeconds(30), false)); + + assertNotNull(ex.getMessage()); + } + + @Test + void waitForInstanceCompletion_deadlineExceeded_throwsTimeoutException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceCompletion(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException(Status.DEADLINE_EXCEEDED)); + } + }); + + TimeoutException ex = assertThrows(TimeoutException.class, () -> + client.waitForInstanceCompletion("test-instance", Duration.ofSeconds(30), false)); + + assertNotNull(ex.getMessage()); + } + + @Test + void purgeInstances_deadlineExceeded_throwsTimeoutException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void purgeInstances(PurgeInstancesRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException(Status.DEADLINE_EXCEEDED)); + } + }); + + PurgeInstanceCriteria criteria = new PurgeInstanceCriteria() + .setCreatedTimeFrom(Instant.parse("2026-01-01T00:00:00Z")); + + TimeoutException ex = assertThrows(TimeoutException.class, () -> + client.purgeInstances(criteria)); + + assertNotNull(ex.getMessage()); + } + + // ----------------------------------------------------------------------- + // 3. Timeout-declaring methods with non-timeout status passthrough + // ----------------------------------------------------------------------- + + @Test + void waitForInstanceStart_invalidArgument_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceStart(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("bad instance id"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.waitForInstanceStart("test-instance", Duration.ofSeconds(30), false)); + + assertGrpcCause(ex, Status.Code.INVALID_ARGUMENT); + assertTrue(ex.getMessage().contains("waitForInstanceStart")); + } + + @Test + void waitForInstanceCompletion_failedPrecondition_throwsIllegalStateException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceCompletion(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("not started"))); + } + }); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + client.waitForInstanceCompletion("test-instance", Duration.ofSeconds(30), false)); + + assertGrpcCause(ex, Status.Code.FAILED_PRECONDITION); + assertTrue(ex.getMessage().contains("waitForInstanceCompletion")); + } + + @Test + void purgeInstances_notFound_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void purgeInstances(PurgeInstancesRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("no instances match"))); + } + }); + + PurgeInstanceCriteria criteria = new PurgeInstanceCriteria() + .setCreatedTimeFrom(Instant.parse("2026-01-01T00:00:00Z")); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.purgeInstances(criteria)); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + assertTrue(ex.getMessage().contains("purgeInstances")); + } + + // ----------------------------------------------------------------------- + // 4. rewindInstance special-case behavior + // ----------------------------------------------------------------------- + + @Test + void rewindInstance_failedPrecondition_throwsIllegalStateExceptionWithCustomMessage() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("not in a failed state"))); + } + }); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + client.rewindInstance("test-instance", null)); + + assertGrpcCause(ex, Status.Code.FAILED_PRECONDITION); + // rewindInstance has its own custom message for FAILED_PRECONDITION + assertTrue(ex.getMessage().contains("not in a failed state")); + assertTrue(ex.getMessage().contains("test-instance")); + } + + @Test + void rewindInstance_unavailable_throwsRuntimeExceptionThroughHelper() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("connection lost"))); + } + }); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> + client.rewindInstance("test-instance", null)); + + assertGrpcCause(ex, Status.Code.UNAVAILABLE); + assertTrue(ex.getMessage().contains("rewindInstance")); + assertTrue(ex.getMessage().contains("UNAVAILABLE")); + } + + @Test + void rewindInstance_notFound_throwsIllegalArgumentExceptionWithCustomMessage() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("not found"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.rewindInstance("test-instance", null)); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + // rewindInstance has its own custom message for NOT_FOUND + assertTrue(ex.getMessage().contains("No orchestration instance with ID")); + assertTrue(ex.getMessage().contains("was found")); + } + + // ----------------------------------------------------------------------- + // 5. Contract-focused assertions: exception type, cause, message content + // ----------------------------------------------------------------------- + + @Test + void scheduleNewOrchestrationInstance_messageContainsStatusCodeAndOperationName() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void startInstance(CreateInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("server down"))); + } + }); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> + client.scheduleNewOrchestrationInstance("TestOrchestrator", new NewOrchestrationInstanceOptions())); + + // Contract: message includes operation name and status code + assertTrue(ex.getMessage().contains("scheduleNewOrchestrationInstance"), + "Message should contain operation name but was: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("UNAVAILABLE"), + "Message should contain status code but was: " + ex.getMessage()); + // Contract: original gRPC exception preserved as cause + assertGrpcCause(ex, Status.Code.UNAVAILABLE); + } + + @Test + void getInstanceMetadata_cancelledStatus_throwsCancellationExceptionWithCause() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void getInstance(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException(Status.CANCELLED)); + } + }); + + CancellationException ex = assertThrows(CancellationException.class, () -> + client.getInstanceMetadata("test-instance", false)); + + // Contract: cause is preserved for CancellationException + assertGrpcCause(ex, Status.Code.CANCELLED); + // Contract: message includes operation name + assertTrue(ex.getMessage().contains("getInstanceMetadata")); + } + + @Test + void suspendInstance_failedPrecondition_fullContractCheck() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void suspendInstance(SuspendRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("instance already suspended"))); + } + }); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + client.suspendInstance("test-instance", null)); + + // Contract: exception type + assertInstanceOf(IllegalStateException.class, ex); + // Contract: cause is original gRPC exception + assertGrpcCause(ex, Status.Code.FAILED_PRECONDITION); + // Contract: message contains operation name + assertTrue(ex.getMessage().contains("suspendInstance")); + // Contract: message includes status code + assertTrue(ex.getMessage().contains("FAILED_PRECONDITION")); + } + + @Test + void raiseEvent_cancelled_throwsCancellationExceptionWithCause() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void raiseEvent(RaiseEventRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.CANCELLED.withDescription("client cancelled"))); + } + }); + + CancellationException ex = assertThrows(CancellationException.class, () -> + client.raiseEvent("test-instance", "testEvent")); + + assertGrpcCause(ex, Status.Code.CANCELLED); + } + + @Test + void terminate_notFound_throwsIllegalArgumentException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void terminateInstance(TerminateRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found"))); + } + }); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.terminate("test-instance", null)); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + assertTrue(ex.getMessage().contains("terminate")); + assertTrue(ex.getMessage().contains("NOT_FOUND")); + } + + @Test + void deleteTaskHub_unimplemented_throwsUnsupportedOperationException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void deleteTaskHub(DeleteTaskHubRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNIMPLEMENTED.withDescription("not supported"))); + } + }); + + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, () -> + client.deleteTaskHub()); + + assertGrpcCause(ex, Status.Code.UNIMPLEMENTED); + assertTrue(ex.getMessage().contains("deleteTaskHub")); + assertTrue(ex.getMessage().contains("UNIMPLEMENTED")); + } + + @Test + void waitForInstanceStart_cancelled_throwsCancellationException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceStart(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException(Status.CANCELLED)); + } + }); + + CancellationException ex = assertThrows(CancellationException.class, () -> + client.waitForInstanceStart("test-instance", Duration.ofSeconds(30), false)); + + assertGrpcCause(ex, Status.Code.CANCELLED); + } + + @Test + void waitForInstanceCompletion_unavailable_throwsRuntimeException() throws IOException { + DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { + @Override + public void waitForInstanceCompletion(GetInstanceRequest request, StreamObserver responseObserver) { + responseObserver.onError(new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("connection refused"))); + } + }); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> + client.waitForInstanceCompletion("test-instance", Duration.ofSeconds(30), false)); + + assertGrpcCause(ex, Status.Code.UNAVAILABLE); + assertTrue(ex.getMessage().contains("waitForInstanceCompletion")); + assertTrue(ex.getMessage().contains("UNAVAILABLE")); + } +} diff --git a/client/src/test/java/com/microsoft/durabletask/GrpcStatusMappingIntegrationTests.java b/client/src/test/java/com/microsoft/durabletask/GrpcStatusMappingIntegrationTests.java new file mode 100644 index 00000000..71dd199c --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/GrpcStatusMappingIntegrationTests.java @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests that verify gRPC {@code StatusRuntimeException} codes are correctly + * translated into standard Java exceptions at the SDK boundary. + *

+ * These tests require the DTS emulator sidecar to be running on localhost:4001. + */ +@Tag("integration") +public class GrpcStatusMappingIntegrationTests extends IntegrationTestBase { + + // ----------------------------------------------------------------------- + // NOT_FOUND → IllegalArgumentException + // ----------------------------------------------------------------------- + + @Test + void raiseEvent_nonExistentInstance_throwsIllegalArgumentException() { + DurableTaskClient client = this.createClientBuilder().build(); + try (client) { + // The emulator returns NOT_FOUND when raising an event on an instance that doesn't exist. + // The SDK should translate this to IllegalArgumentException. + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.raiseEvent("definitely-missing-id", "testEvent", null)); + assertNotNull(ex.getCause(), "Should preserve original gRPC exception as cause"); + } + } + + @Test + void suspendInstance_nonExistentInstance_throwsIllegalArgumentException() { + DurableTaskClient client = this.createClientBuilder().build(); + try (client) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.suspendInstance("definitely-missing-id", "test suspend")); + assertNotNull(ex.getCause(), "Should preserve original gRPC exception as cause"); + } + } + + @Test + void terminateInstance_nonExistentInstance_throwsIllegalArgumentException() { + DurableTaskClient client = this.createClientBuilder().build(); + try (client) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + client.terminate("definitely-missing-id", "test terminate")); + assertNotNull(ex.getCause(), "Should preserve original gRPC exception as cause"); + } + } + + // ----------------------------------------------------------------------- + // NOT_FOUND: getInstanceMetadata returns isInstanceFound==false (no throw) + // ----------------------------------------------------------------------- + + @Test + void getInstanceMetadata_nonExistentInstance_returnsNotFound() { + DurableTaskClient client = this.createClientBuilder().build(); + try (client) { + // The DTS emulator returns an empty response (not NOT_FOUND gRPC status) for + // missing instances, so getInstanceMetadata does not throw — it returns metadata + // with isInstanceFound == false. + OrchestrationMetadata metadata = client.getInstanceMetadata("definitely-missing-id", false); + assertNotNull(metadata); + assertFalse(metadata.isInstanceFound()); + } + } + + // ----------------------------------------------------------------------- + // DEADLINE_EXCEEDED → TimeoutException + // ----------------------------------------------------------------------- + + @Test + void waitForInstanceCompletion_tinyTimeout_throwsTimeoutException() throws TimeoutException { + final String orchestratorName = "SlowOrchestrator"; + + // Orchestrator that waits far longer than our timeout + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ctx.createTimer(Duration.ofMinutes(10)).await(); + ctx.complete("done"); + }) + .buildAndStart(); + + DurableTaskClient client = this.createClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + // Wait for the instance to actually start before applying the tiny timeout + client.waitForInstanceStart(instanceId, defaultTimeout, false); + + Duration tinyTimeout = Duration.ofSeconds(1); + assertThrows(TimeoutException.class, () -> + client.waitForInstanceCompletion(instanceId, tinyTimeout, false)); + } + } + + // ----------------------------------------------------------------------- + // UNIMPLEMENTED → UnsupportedOperationException (rewind not supported by emulator) + // ----------------------------------------------------------------------- + + @Test + void rewindInstance_throwsUnsupportedOperationException() { + DurableTaskClient client = this.createClientBuilder().build(); + try (client) { + // The DTS emulator does not support the rewind operation and returns UNIMPLEMENTED. + // The SDK should translate this to UnsupportedOperationException. + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, () -> + client.rewindInstance("any-instance-id", "test rewind")); + assertNotNull(ex.getCause(), "Should preserve original gRPC exception as cause"); + assertTrue(ex.getMessage().contains("UNIMPLEMENTED")); + } + } +} diff --git a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java new file mode 100644 index 00000000..aa123548 --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -0,0 +1,310 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link StatusRuntimeExceptionHelper}. + */ +public class StatusRuntimeExceptionHelperTest { + + // -- toRuntimeException tests -- + + @Test + void toRuntimeException_cancelledStatus_returnsCancellationException() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.CANCELLED); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "testOperation"); + + assertInstanceOf(CancellationException.class, result); + assertTrue(result.getMessage().contains("testOperation")); + assertTrue(result.getMessage().contains("canceled")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_cancelledStatusWithDescription_returnsCancellationException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.CANCELLED.withDescription("context cancelled")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "raiseEvent"); + + assertInstanceOf(CancellationException.class, result); + assertTrue(result.getMessage().contains("raiseEvent")); + } + + @Test + void toRuntimeException_invalidArgumentStatus_returnsIllegalArgumentException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("instanceId is required")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "scheduleNewOrchestrationInstance"); + + assertInstanceOf(IllegalArgumentException.class, result); + assertTrue(result.getMessage().contains("scheduleNewOrchestrationInstance")); + assertTrue(result.getMessage().contains("INVALID_ARGUMENT")); + assertTrue(result.getMessage().contains("instanceId is required")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_failedPreconditionStatus_returnsIllegalStateException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("instance already running")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "suspendInstance"); + + assertInstanceOf(IllegalStateException.class, result); + assertTrue(result.getMessage().contains("suspendInstance")); + assertTrue(result.getMessage().contains("FAILED_PRECONDITION")); + assertTrue(result.getMessage().contains("instance already running")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_notFoundStatus_returnsIllegalArgumentException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "getInstanceMetadata"); + + assertInstanceOf(IllegalArgumentException.class, result); + assertTrue(result.getMessage().contains("getInstanceMetadata")); + assertTrue(result.getMessage().contains("NOT_FOUND")); + assertTrue(result.getMessage().contains("instance not found")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_unimplementedStatus_returnsUnsupportedOperationException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.UNIMPLEMENTED.withDescription("method not supported")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "rewindInstance"); + + assertInstanceOf(UnsupportedOperationException.class, result); + assertTrue(result.getMessage().contains("rewindInstance")); + assertTrue(result.getMessage().contains("UNIMPLEMENTED")); + assertTrue(result.getMessage().contains("method not supported")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_deadlineExceededStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.DEADLINE_EXCEEDED); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "getInstanceMetadata"); + + assertInstanceOf(RuntimeException.class, result); + assertTrue(result.getMessage().contains("getInstanceMetadata")); + assertTrue(result.getMessage().contains("DEADLINE_EXCEEDED")); + } + + @Test + void toRuntimeException_unavailableStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("Connection refused")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "terminate"); + + assertInstanceOf(RuntimeException.class, result); + assertFalse(result instanceof IllegalArgumentException); + assertFalse(result instanceof IllegalStateException); + assertFalse(result instanceof UnsupportedOperationException); + assertTrue(result.getMessage().contains("terminate")); + assertTrue(result.getMessage().contains("UNAVAILABLE")); + assertTrue(result.getMessage().contains("Connection refused")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_internalStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.INTERNAL.withDescription("Internal server error")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "suspendInstance"); + + assertInstanceOf(RuntimeException.class, result); + assertTrue(result.getMessage().contains("suspendInstance")); + assertTrue(result.getMessage().contains("INTERNAL")); + assertTrue(result.getMessage().contains("Internal server error")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_preservesOperationName() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.UNKNOWN); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "customOperationName"); + + assertTrue(result.getMessage().contains("customOperationName")); + } + + @Test + void toRuntimeException_nullDescription_usesDefaultFallback() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.INTERNAL); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "testOp"); + + assertTrue(result.getMessage().contains("(no description)"), + "Expected '(no description)' fallback but got: " + result.getMessage()); + assertFalse(result.getMessage().contains(": null"), + "Message should not contain literal ': null': " + result.getMessage()); + } + + // -- toException tests -- + + @Test + void toException_deadlineExceededStatus_returnsTimeoutException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.DEADLINE_EXCEEDED.withDescription("deadline exceeded after 10s")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "waitForInstanceStart"); + + assertInstanceOf(TimeoutException.class, result); + assertTrue(result.getMessage().contains("waitForInstanceStart")); + assertTrue(result.getMessage().contains("DEADLINE_EXCEEDED")); + } + + @Test + void toException_deadlineExceededStatus_usesConsistentMessageFormat() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.DEADLINE_EXCEEDED.withDescription("timeout after 5s")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "purgeInstances"); + + assertInstanceOf(TimeoutException.class, result); + assertTrue(result.getMessage().contains("failed with a DEADLINE_EXCEEDED gRPC status"), + "Expected consistent message format but got: " + result.getMessage()); + } + + @Test + void toException_cancelledStatus_returnsCancellationException() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.CANCELLED); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "purgeInstances"); + + assertInstanceOf(CancellationException.class, result); + assertTrue(result.getMessage().contains("purgeInstances")); + assertTrue(result.getMessage().contains("canceled")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_invalidArgumentStatus_returnsIllegalArgumentException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription("bad input")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "waitForInstanceStart"); + + assertInstanceOf(IllegalArgumentException.class, result); + assertTrue(result.getMessage().contains("waitForInstanceStart")); + assertTrue(result.getMessage().contains("INVALID_ARGUMENT")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_failedPreconditionStatus_returnsIllegalStateException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.FAILED_PRECONDITION.withDescription("not ready")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "waitForInstanceCompletion"); + + assertInstanceOf(IllegalStateException.class, result); + assertTrue(result.getMessage().contains("waitForInstanceCompletion")); + assertTrue(result.getMessage().contains("FAILED_PRECONDITION")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_notFoundStatus_returnsIllegalArgumentException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.NOT_FOUND.withDescription("not found")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "purgeInstances"); + + assertInstanceOf(IllegalArgumentException.class, result); + assertTrue(result.getMessage().contains("purgeInstances")); + assertTrue(result.getMessage().contains("NOT_FOUND")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_unimplementedStatus_returnsUnsupportedOperationException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.UNIMPLEMENTED.withDescription("not implemented")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "rewindInstance"); + + assertInstanceOf(UnsupportedOperationException.class, result); + assertTrue(result.getMessage().contains("rewindInstance")); + assertTrue(result.getMessage().contains("UNIMPLEMENTED")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_unavailableStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.UNAVAILABLE); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "waitForInstanceCompletion"); + + assertInstanceOf(RuntimeException.class, result); + assertFalse(result instanceof CancellationException); + assertTrue(result.getMessage().contains("waitForInstanceCompletion")); + assertTrue(result.getMessage().contains("UNAVAILABLE")); + } + + @Test + void toException_internalStatus_returnsRuntimeExceptionWithCause() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.INTERNAL.withDescription("server error")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "purgeInstances"); + + assertInstanceOf(RuntimeException.class, result); + assertSame(grpcException, result.getCause()); + } + + @Test + void toException_nullDescription_usesDefaultFallback() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.DEADLINE_EXCEEDED); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "testOp"); + + assertInstanceOf(TimeoutException.class, result); + assertTrue(result.getMessage().contains("(no description)"), + "Expected '(no description)' fallback but got: " + result.getMessage()); + assertFalse(result.getMessage().contains(": null"), + "Message should not contain literal ': null': " + result.getMessage()); + } +}