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:
+ *
+ * - {@code CANCELLED} → {@link CancellationException}
+ * - {@code DEADLINE_EXCEEDED} → {@link TimeoutException} (via {@link #toException})
+ * - {@code INVALID_ARGUMENT} → {@link IllegalArgumentException}
+ * - {@code FAILED_PRECONDITION} → {@link IllegalStateException}
+ * - {@code NOT_FOUND} → {@link IllegalArgumentException}
+ * - {@code UNIMPLEMENTED} → {@link UnsupportedOperationException}
+ * - All other codes → {@link RuntimeException}
+ *
+ */
+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());
+ }
+}