From 1bb999f31eb1b57b5999fb477dd50a075e659fad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:04:43 +0000 Subject: [PATCH 01/13] Initial plan From 20baafb3e4fc1f4d5b467453014903bc4498e410 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:24:00 +0000 Subject: [PATCH 02/13] Initial plan for wrapping gRPC StatusRuntimeException Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/6c71e4a3-6ba6-4f7c-b5fb-038b059128d0 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../durabletask-protobuf/protos/orchestrator_service.proto | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/durabletask-protobuf/protos/orchestrator_service.proto b/internal/durabletask-protobuf/protos/orchestrator_service.proto index 0c34d986..3d7c8eb4 100644 --- a/internal/durabletask-protobuf/protos/orchestrator_service.proto +++ b/internal/durabletask-protobuf/protos/orchestrator_service.proto @@ -25,6 +25,7 @@ message ActivityRequest { OrchestrationInstance orchestrationInstance = 4; int32 taskId = 5; TraceContext parentTraceContext = 6; + map tags = 7; } message ActivityResponse { @@ -320,6 +321,10 @@ message SendEntityMessageAction { } } +message RewindOrchestrationAction { + repeated HistoryEvent newHistory = 1; +} + message OrchestratorAction { int32 id = 1; oneof orchestratorActionType { @@ -330,6 +335,7 @@ message OrchestratorAction { CompleteOrchestrationAction completeOrchestration = 6; TerminateOrchestrationAction terminateOrchestration = 7; SendEntityMessageAction sendEntityMessage = 8; + RewindOrchestrationAction rewindOrchestration = 9; } } @@ -517,6 +523,7 @@ message PurgeInstanceFilter { google.protobuf.Timestamp createdTimeFrom = 1; google.protobuf.Timestamp createdTimeTo = 2; repeated OrchestrationStatus runtimeStatus = 3; + google.protobuf.Duration timeout = 4; } message PurgeInstancesResponse { From bb4a5bd7cd4b87e2ad9a2956e146d7744c5ab8fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:29:39 +0000 Subject: [PATCH 03/13] Wrap gRPC StatusRuntimeException across all DurableTaskGrpcClient methods Add StatusRuntimeExceptionHelper to translate gRPC StatusRuntimeException into SDK-level exceptions: - CANCELLED -> CancellationException - DEADLINE_EXCEEDED -> TimeoutException (for checked exception variant) - Other codes -> RuntimeException with descriptive message Wrap all previously-unwrapped gRPC calls in DurableTaskGrpcClient: - scheduleNewOrchestrationInstance, raiseEvent, getInstanceMetadata - terminate, queryInstances, createTaskHub, deleteTaskHub - purgeInstance, suspendInstance, resumeInstance Update partially-wrapped methods to use helper as fallback: - waitForInstanceStart, waitForInstanceCompletion, purgeInstances, rewindInstance Add comprehensive unit tests for StatusRuntimeExceptionHelper. Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/6c71e4a3-6ba6-4f7c-b5fb-038b059128d0 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../durabletask/DurableTaskGrpcClient.java | 82 ++++++++-- .../StatusRuntimeExceptionHelper.java | 70 ++++++++ .../StatusRuntimeExceptionHelperTest.java | 151 ++++++++++++++++++ 3 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java create mode 100644 client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index 14955120..5e7da366 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -158,6 +158,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(); @@ -181,7 +183,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 @@ -190,8 +196,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 @@ -216,7 +226,11 @@ 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 RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); } @@ -243,7 +257,11 @@ 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 RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); } @@ -260,7 +278,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 @@ -274,8 +296,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){ @@ -288,12 +314,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 @@ -302,8 +336,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 @@ -331,7 +369,11 @@ 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 RuntimeException) { + throw (RuntimeException) translated; + } + throw new RuntimeException(translated); } } @@ -342,7 +384,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 @@ -352,7 +398,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 @@ -374,7 +424,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..f97e24d4 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java @@ -0,0 +1,70 @@ +// 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. + */ +final class StatusRuntimeExceptionHelper { + + /** + * Translates a {@link StatusRuntimeException} into an appropriate SDK-level 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(); + switch (code) { + case CANCELLED: + CancellationException ce = new CancellationException( + "The " + operationName + " operation was canceled."); + ce.initCause(e); + return ce; + default: + return new RuntimeException( + "The " + operationName + " operation failed with a " + code + " gRPC status: " + + e.getStatus().getDescription(), + e); + } + } + + /** + * Translates a {@link StatusRuntimeException} into an appropriate SDK-level checked exception + * for operations that declare {@code throws TimeoutException}. + * + * @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(); + switch (code) { + case DEADLINE_EXCEEDED: + return new TimeoutException( + "The " + operationName + " operation timed out: " + e.getStatus().getDescription()); + case CANCELLED: + CancellationException ce = new CancellationException( + "The " + operationName + " operation was canceled."); + ce.initCause(e); + return ce; + default: + return new RuntimeException( + "The " + operationName + " operation failed with a " + code + " gRPC status: " + + e.getStatus().getDescription(), + e); + } + } + + // Cannot be instantiated + private StatusRuntimeExceptionHelper() { + } +} 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..1cb60cff --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -0,0 +1,151 @@ +// 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 { + + // Tests for toRuntimeException + + @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_unavailableStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.UNAVAILABLE.withDescription("Connection refused")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "terminate"); + + assertInstanceOf(RuntimeException.class, result); + assertNotEquals(CancellationException.class, result.getClass()); + assertTrue(result.getMessage().contains("terminate")); + assertTrue(result.getMessage().contains("UNAVAILABLE")); + 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_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_preservesOperationName() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.UNKNOWN); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "customOperationName"); + + assertTrue(result.getMessage().contains("customOperationName")); + } + + // Tests for toException (checked exception variant) + + @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("timed out")); + } + + @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_unavailableStatus_returnsRuntimeException() { + StatusRuntimeException grpcException = new StatusRuntimeException(Status.UNAVAILABLE); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "waitForInstanceCompletion"); + + assertInstanceOf(RuntimeException.class, result); + assertNotEquals(CancellationException.class, result.getClass()); + 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()); + } +} From f7b7d1cd7f5a5015b425c1575594ba1cbb7ba9b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:33:06 +0000 Subject: [PATCH 04/13] Revert proto file to base branch state (was inadvertently changed by downloadProtoFiles) Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/6c71e4a3-6ba6-4f7c-b5fb-038b059128d0 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../durabletask-protobuf/protos/orchestrator_service.proto | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/durabletask-protobuf/protos/orchestrator_service.proto b/internal/durabletask-protobuf/protos/orchestrator_service.proto index 3d7c8eb4..0c34d986 100644 --- a/internal/durabletask-protobuf/protos/orchestrator_service.proto +++ b/internal/durabletask-protobuf/protos/orchestrator_service.proto @@ -25,7 +25,6 @@ message ActivityRequest { OrchestrationInstance orchestrationInstance = 4; int32 taskId = 5; TraceContext parentTraceContext = 6; - map tags = 7; } message ActivityResponse { @@ -321,10 +320,6 @@ message SendEntityMessageAction { } } -message RewindOrchestrationAction { - repeated HistoryEvent newHistory = 1; -} - message OrchestratorAction { int32 id = 1; oneof orchestratorActionType { @@ -335,7 +330,6 @@ message OrchestratorAction { CompleteOrchestrationAction completeOrchestration = 6; TerminateOrchestrationAction terminateOrchestration = 7; SendEntityMessageAction sendEntityMessage = 8; - RewindOrchestrationAction rewindOrchestration = 9; } } @@ -523,7 +517,6 @@ message PurgeInstanceFilter { google.protobuf.Timestamp createdTimeFrom = 1; google.protobuf.Timestamp createdTimeTo = 2; repeated OrchestrationStatus runtimeStatus = 3; - google.protobuf.Duration timeout = 4; } message PurgeInstancesResponse { From a2d7c0089d87d51d340c1f6ebd31be0339825109 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:46:30 +0000 Subject: [PATCH 05/13] Address review feedback on StatusRuntimeExceptionHelper 1. Null-safe description: getDescriptionOrDefault() returns "(no description)" instead of letting null propagate into error messages. 2. Javadoc comment on toException explaining DEADLINE_EXCEEDED is included for completeness/future-proofing even though current call sites handle it first. 3. Extracted private helpers (createCancellationException, createRuntimeException, formatMessage, getDescriptionOrDefault) to eliminate duplication between toRuntimeException and toException. 4. Consistent message format: DEADLINE_EXCEEDED now uses the same "failed with a gRPC status: " prefix as all other status codes. Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/747c79d2-a3fa-427c-8b3d-63515878b1d1 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../StatusRuntimeExceptionHelper.java | 49 ++++++++++++------- .../StatusRuntimeExceptionHelperTest.java | 43 +++++++++++++++- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java index f97e24d4..a0aa7432 100644 --- a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java +++ b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java @@ -25,21 +25,19 @@ static RuntimeException toRuntimeException(StatusRuntimeException e, String oper Status.Code code = e.getStatus().getCode(); switch (code) { case CANCELLED: - CancellationException ce = new CancellationException( - "The " + operationName + " operation was canceled."); - ce.initCause(e); - return ce; + return createCancellationException(e, operationName); default: - return new RuntimeException( - "The " + operationName + " operation failed with a " + code + " gRPC status: " - + e.getStatus().getDescription(), - e); + return createRuntimeException(e, operationName, code); } } /** * 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 @@ -50,20 +48,37 @@ static Exception toException(StatusRuntimeException e, String operationName) { switch (code) { case DEADLINE_EXCEEDED: return new TimeoutException( - "The " + operationName + " operation timed out: " + e.getStatus().getDescription()); + formatMessage(operationName, code, getDescriptionOrDefault(e))); case CANCELLED: - CancellationException ce = new CancellationException( - "The " + operationName + " operation was canceled."); - ce.initCause(e); - return ce; + return createCancellationException(e, operationName); default: - return new RuntimeException( - "The " + operationName + " operation failed with a " + code + " gRPC status: " - + e.getStatus().getDescription(), - e); + return createRuntimeException(e, operationName, code); } } + private static CancellationException createCancellationException( + StatusRuntimeException e, String operationName) { + CancellationException ce = new CancellationException( + "The " + operationName + " operation was canceled."); + ce.initCause(e); + return ce; + } + + private static RuntimeException createRuntimeException( + StatusRuntimeException e, String operationName, Status.Code code) { + return new RuntimeException( + formatMessage(operationName, code, getDescriptionOrDefault(e)), e); + } + + 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/StatusRuntimeExceptionHelperTest.java b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java index 1cb60cff..a93eff92 100644 --- a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -56,6 +56,7 @@ void toRuntimeException_unavailableStatus_returnsRuntimeException() { assertNotEquals(CancellationException.class, result.getClass()); assertTrue(result.getMessage().contains("terminate")); assertTrue(result.getMessage().contains("UNAVAILABLE")); + assertTrue(result.getMessage().contains("Connection refused")); assertSame(grpcException, result.getCause()); } @@ -96,6 +97,19 @@ void toRuntimeException_preservesOperationName() { 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()); + } + // Tests for toException (checked exception variant) @Test @@ -108,7 +122,20 @@ void toException_deadlineExceededStatus_returnsTimeoutException() { assertInstanceOf(TimeoutException.class, result); assertTrue(result.getMessage().contains("waitForInstanceStart")); - assertTrue(result.getMessage().contains("timed out")); + 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 @@ -148,4 +175,18 @@ void toException_internalStatus_returnsRuntimeExceptionWithCause() { 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()); + } } From 42765bcd2df72cfe84b73d3aa756da6efdad3c4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:06:27 +0000 Subject: [PATCH 06/13] Add Java-standard exception mappings to StatusRuntimeExceptionHelper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New gRPC status code → Java exception mappings: - INVALID_ARGUMENT → IllegalArgumentException - FAILED_PRECONDITION → IllegalStateException - NOT_FOUND → NoSuchElementException - UNIMPLEMENTED → UnsupportedOperationException All mapped exceptions include the Status.Code in the message, preserve the original StatusRuntimeException as cause, and handle null descriptions. Both toRuntimeException and toException support all new mappings. Tests added for each new mapping in both helper paths. Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/ea332218-fd02-4338-8805-e40f0009eb55 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../StatusRuntimeExceptionHelper.java | 48 +++++- .../StatusRuntimeExceptionHelperTest.java | 159 +++++++++++++++--- 2 files changed, 178 insertions(+), 29 deletions(-) diff --git a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java index a0aa7432..4d053bff 100644 --- a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java +++ b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java @@ -5,17 +5,29 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; +import java.util.NoSuchElementException; 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 NoSuchElementException}
  • + *
  • {@code UNIMPLEMENTED} → {@link UnsupportedOperationException}
  • + *
  • All other codes → {@link RuntimeException}
  • + *
*/ final class StatusRuntimeExceptionHelper { /** - * Translates a {@link StatusRuntimeException} into an appropriate SDK-level exception. + * 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 @@ -23,11 +35,20 @@ final class StatusRuntimeExceptionHelper { */ 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 createNoSuchElementException(e, message); + case UNIMPLEMENTED: + return new UnsupportedOperationException(message, e); default: - return createRuntimeException(e, operationName, code); + return new RuntimeException(message, e); } } @@ -45,14 +66,22 @@ static RuntimeException toRuntimeException(StatusRuntimeException e, String oper */ 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( - formatMessage(operationName, code, getDescriptionOrDefault(e))); + 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 createNoSuchElementException(e, message); + case UNIMPLEMENTED: + return new UnsupportedOperationException(message, e); default: - return createRuntimeException(e, operationName, code); + return new RuntimeException(message, e); } } @@ -64,10 +93,11 @@ private static CancellationException createCancellationException( return ce; } - private static RuntimeException createRuntimeException( - StatusRuntimeException e, String operationName, Status.Code code) { - return new RuntimeException( - formatMessage(operationName, code, getDescriptionOrDefault(e)), e); + private static NoSuchElementException createNoSuchElementException( + StatusRuntimeException e, String message) { + NoSuchElementException ne = new NoSuchElementException(message); + ne.initCause(e); + return ne; } private static String formatMessage(String operationName, Status.Code code, String description) { diff --git a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java index a93eff92..ddb770d7 100644 --- a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -7,6 +7,7 @@ import io.grpc.StatusRuntimeException; import org.junit.jupiter.api.Test; +import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; import java.util.concurrent.TimeoutException; @@ -17,7 +18,7 @@ */ public class StatusRuntimeExceptionHelperTest { - // Tests for toRuntimeException + // ── toRuntimeException tests ── @Test void toRuntimeException_cancelledStatus_returnsCancellationException() { @@ -45,33 +46,62 @@ void toRuntimeException_cancelledStatusWithDescription_returnsCancellationExcept } @Test - void toRuntimeException_unavailableStatus_returnsRuntimeException() { + void toRuntimeException_invalidArgumentStatus_returnsIllegalArgumentException() { StatusRuntimeException grpcException = new StatusRuntimeException( - Status.UNAVAILABLE.withDescription("Connection refused")); + Status.INVALID_ARGUMENT.withDescription("instanceId is required")); RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( - grpcException, "terminate"); + grpcException, "scheduleNewOrchestrationInstance"); - assertInstanceOf(RuntimeException.class, result); - assertNotEquals(CancellationException.class, result.getClass()); - assertTrue(result.getMessage().contains("terminate")); - assertTrue(result.getMessage().contains("UNAVAILABLE")); - assertTrue(result.getMessage().contains("Connection refused")); + 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_internalStatus_returnsRuntimeException() { + void toRuntimeException_failedPreconditionStatus_returnsIllegalStateException() { StatusRuntimeException grpcException = new StatusRuntimeException( - Status.INTERNAL.withDescription("Internal server error")); + Status.FAILED_PRECONDITION.withDescription("instance already running")); RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( grpcException, "suspendInstance"); - assertInstanceOf(RuntimeException.class, result); + assertInstanceOf(IllegalStateException.class, result); assertTrue(result.getMessage().contains("suspendInstance")); - assertTrue(result.getMessage().contains("INTERNAL")); - assertTrue(result.getMessage().contains("Internal server error")); + assertTrue(result.getMessage().contains("FAILED_PRECONDITION")); + assertTrue(result.getMessage().contains("instance already running")); + assertSame(grpcException, result.getCause()); + } + + @Test + void toRuntimeException_notFoundStatus_returnsNoSuchElementException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.NOT_FOUND.withDescription("instance not found")); + + RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( + grpcException, "getInstanceMetadata"); + + assertInstanceOf(NoSuchElementException.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()); } @@ -87,6 +117,39 @@ void toRuntimeException_deadlineExceededStatus_returnsRuntimeException() { 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); @@ -106,11 +169,11 @@ void toRuntimeException_nullDescription_usesDefaultFallback() { 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()); + assertFalse(result.getMessage().contains(": null"), + "Message should not contain literal ': null': " + result.getMessage()); } - // Tests for toException (checked exception variant) + // ── toException tests ── @Test void toException_deadlineExceededStatus_returnsTimeoutException() { @@ -151,6 +214,62 @@ void toException_cancelledStatus_returnsCancellationException() { 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_returnsNoSuchElementException() { + StatusRuntimeException grpcException = new StatusRuntimeException( + Status.NOT_FOUND.withDescription("not found")); + + Exception result = StatusRuntimeExceptionHelper.toException( + grpcException, "purgeInstances"); + + assertInstanceOf(NoSuchElementException.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); @@ -159,7 +278,7 @@ void toException_unavailableStatus_returnsRuntimeException() { grpcException, "waitForInstanceCompletion"); assertInstanceOf(RuntimeException.class, result); - assertNotEquals(CancellationException.class, result.getClass()); + assertFalse(result instanceof CancellationException); assertTrue(result.getMessage().contains("waitForInstanceCompletion")); assertTrue(result.getMessage().contains("UNAVAILABLE")); } @@ -186,7 +305,7 @@ void toException_nullDescription_usesDefaultFallback() { 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()); + assertFalse(result.getMessage().contains(": null"), + "Message should not contain literal ': null': " + result.getMessage()); } } From a4fe62f97df08ade9f83edaa09b1f25a97995882 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:08:41 +0000 Subject: [PATCH 07/13] Replace Unicode box-drawing chars with ASCII in test comments Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/ea332218-fd02-4338-8805-e40f0009eb55 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../durabletask/StatusRuntimeExceptionHelperTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java index ddb770d7..00a96cff 100644 --- a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -18,7 +18,7 @@ */ public class StatusRuntimeExceptionHelperTest { - // ── toRuntimeException tests ── + // -- toRuntimeException tests -- @Test void toRuntimeException_cancelledStatus_returnsCancellationException() { @@ -173,7 +173,7 @@ void toRuntimeException_nullDescription_usesDefaultFallback() { "Message should not contain literal ': null': " + result.getMessage()); } - // ── toException tests ── + // -- toException tests -- @Test void toException_deadlineExceededStatus_returnsTimeoutException() { From 539916bff1a4a02512494813ed38f0749fdf6550 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:39:15 +0000 Subject: [PATCH 08/13] Add explicit TimeoutException rethrow in waitForInstanceStart, waitForInstanceCompletion, purgeInstances Update rethrow logic after toException translation to check TimeoutException first, then RuntimeException, with defensive fallback as last resort. Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/2852d301-7a07-419c-a4f5-036184610079 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../microsoft/durabletask/DurableTaskGrpcClient.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index 5e7da366..5273edbf 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -227,7 +227,9 @@ public OrchestrationMetadata waitForInstanceStart(String instanceId, Duration ti throw new TimeoutException("Start orchestration timeout reached."); } Exception translated = StatusRuntimeExceptionHelper.toException(e, "waitForInstanceStart"); - if (translated instanceof RuntimeException) { + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { throw (RuntimeException) translated; } throw new RuntimeException(translated); @@ -258,7 +260,9 @@ public OrchestrationMetadata waitForInstanceCompletion(String instanceId, Durati throw new TimeoutException("Orchestration instance completion timeout reached."); } Exception translated = StatusRuntimeExceptionHelper.toException(e, "waitForInstanceCompletion"); - if (translated instanceof RuntimeException) { + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { throw (RuntimeException) translated; } throw new RuntimeException(translated); @@ -370,7 +374,9 @@ public PurgeResult purgeInstances(PurgeInstanceCriteria purgeInstanceCriteria) t throw new TimeoutException(timeOutException); } Exception translated = StatusRuntimeExceptionHelper.toException(e, "purgeInstances"); - if (translated instanceof RuntimeException) { + if (translated instanceof TimeoutException) { + throw (TimeoutException) translated; + } else if (translated instanceof RuntimeException) { throw (RuntimeException) translated; } throw new RuntimeException(translated); From d0241fb85ec8458734c1bfb886c283d04ce1963d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:44:04 +0000 Subject: [PATCH 09/13] Add client-level unit tests for DurableTaskGrpcClient exception wrapping Uses gRPC in-process transport with fake service implementations to verify each client method catches StatusRuntimeException and rethrows the correct translated SDK exception. Tests cover: - All newly wrapped methods (scheduleNewOrchestrationInstance, raiseEvent, getInstanceMetadata, terminate, queryInstances, createTaskHub, deleteTaskHub, purgeInstance, suspendInstance, resumeInstance) - Timeout-declaring methods with DEADLINE_EXCEEDED (waitForInstanceStart, waitForInstanceCompletion, purgeInstances) - Non-timeout status passthrough for timeout-declaring methods - rewindInstance special-case behavior (custom messages for NOT_FOUND, FAILED_PRECONDITION; helper fallback for UNAVAILABLE) - Contract assertions (exception type, gRPC cause preserved, message contains operation name and status code) Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/c4d58a2b-9982-49e3-bc9d-0b8527119032 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- client/build.gradle | 1 + .../DurableTaskGrpcClientTest.java | 575 ++++++++++++++++++ 2 files changed, 576 insertions(+) create mode 100644 client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java diff --git a/client/build.gradle b/client/build.gradle index 9801af45..96cfd19a 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/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java new file mode 100644 index 00000000..abf9d000 --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java @@ -0,0 +1,575 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask; + +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.*; +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.NoSuchElementException; +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_throwsNoSuchElementException() 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"))); + } + }); + + NoSuchElementException ex = assertThrows(NoSuchElementException.class, () -> + client.raiseEvent("test-instance", "testEvent")); + + assertGrpcCause(ex, Status.Code.NOT_FOUND); + assertTrue(ex.getMessage().contains("raiseEvent")); + } + + @Test + void getInstanceMetadata_notFound_throwsNoSuchElementException() 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"))); + } + }); + + NoSuchElementException ex = assertThrows(NoSuchElementException.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_throwsNoSuchElementException() 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"))); + } + }); + + NoSuchElementException ex = assertThrows(NoSuchElementException.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.now().minus(Duration.ofDays(1))); + + 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_throwsNoSuchElementException() 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.now().minus(Duration.ofDays(1))); + + NoSuchElementException ex = assertThrows(NoSuchElementException.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("test-instance")); + } + + // ----------------------------------------------------------------------- + // 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_throwsNoSuchElementException() 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"))); + } + }); + + NoSuchElementException ex = assertThrows(NoSuchElementException.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")); + } +} From 54035e41f5c991a46b81996a36464e7cd271c3c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:47:33 +0000 Subject: [PATCH 10/13] Address code review: replace wildcard imports, use fixed timestamps in tests Agent-Logs-Url: https://github.com/microsoft/durabletask-java/sessions/c4d58a2b-9982-49e3-bc9d-0b8527119032 Co-authored-by: bachuv <15795068+bachuv@users.noreply.github.com> --- .../DurableTaskGrpcClientTest.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java index abf9d000..bb196f7b 100644 --- a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java +++ b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java @@ -3,7 +3,28 @@ package com.microsoft.durabletask; -import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.*; +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.*; @@ -296,7 +317,7 @@ public void purgeInstances(PurgeInstancesRequest request, StreamObserver client.purgeInstances(criteria)); @@ -353,7 +374,7 @@ public void purgeInstances(PurgeInstancesRequest request, StreamObserver client.purgeInstances(criteria)); From 261fec03ee37e8ebd59dc1bd171b70e8f8a0ff51 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 7 Apr 2026 16:28:42 -0700 Subject: [PATCH 11/13] added integration tests --- .../durabletask/DurableTaskGrpcClient.java | 4 - .../StatusRuntimeExceptionHelper.java | 14 +- .../DurableTaskGrpcClientTest.java | 27 ++-- .../GrpcStatusMappingIntegrationTests.java | 120 ++++++++++++++++++ .../StatusRuntimeExceptionHelperTest.java | 9 +- 5 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 client/src/test/java/com/microsoft/durabletask/GrpcStatusMappingIntegrationTests.java diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index 5273edbf..234b8218 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -422,10 +422,6 @@ public void rewindInstance(String instanceId, @Nullable String reason) { try { this.sidecarClient.rewindInstance(rewindRequestBuilder.build()); } catch (StatusRuntimeException e) { - if (e.getStatus().getCode() == Status.Code.NOT_FOUND) { - throw new IllegalArgumentException( - "No orchestration instance with ID '" + instanceId + "' was found.", e); - } if (e.getStatus().getCode() == Status.Code.FAILED_PRECONDITION) { throw new IllegalStateException( "Orchestration instance '" + instanceId + "' is not in a failed state and cannot be rewound.", e); diff --git a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java index 4d053bff..98379e72 100644 --- a/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java +++ b/client/src/main/java/com/microsoft/durabletask/StatusRuntimeExceptionHelper.java @@ -5,7 +5,6 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; -import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; import java.util.concurrent.TimeoutException; @@ -19,7 +18,7 @@ *

  • {@code DEADLINE_EXCEEDED} → {@link TimeoutException} (via {@link #toException})
  • *
  • {@code INVALID_ARGUMENT} → {@link IllegalArgumentException}
  • *
  • {@code FAILED_PRECONDITION} → {@link IllegalStateException}
  • - *
  • {@code NOT_FOUND} → {@link NoSuchElementException}
  • + *
  • {@code NOT_FOUND} → {@link IllegalArgumentException}
  • *
  • {@code UNIMPLEMENTED} → {@link UnsupportedOperationException}
  • *
  • All other codes → {@link RuntimeException}
  • * @@ -44,7 +43,7 @@ static RuntimeException toRuntimeException(StatusRuntimeException e, String oper case FAILED_PRECONDITION: return new IllegalStateException(message, e); case NOT_FOUND: - return createNoSuchElementException(e, message); + return new IllegalArgumentException(message, e); case UNIMPLEMENTED: return new UnsupportedOperationException(message, e); default: @@ -77,7 +76,7 @@ static Exception toException(StatusRuntimeException e, String operationName) { case FAILED_PRECONDITION: return new IllegalStateException(message, e); case NOT_FOUND: - return createNoSuchElementException(e, message); + return new IllegalArgumentException(message, e); case UNIMPLEMENTED: return new UnsupportedOperationException(message, e); default: @@ -93,13 +92,6 @@ private static CancellationException createCancellationException( return ce; } - private static NoSuchElementException createNoSuchElementException( - StatusRuntimeException e, String message) { - NoSuchElementException ne = new NoSuchElementException(message); - ne.initCause(e); - return ne; - } - private static String formatMessage(String operationName, Status.Code code, String description) { return "The " + operationName + " operation failed with a " + code + " gRPC status: " + description; } diff --git a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java index bb196f7b..375e3960 100644 --- a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java +++ b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java @@ -38,7 +38,6 @@ import java.io.IOException; import java.time.Duration; import java.time.Instant; -import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; import java.util.concurrent.TimeoutException; @@ -123,7 +122,7 @@ public void startInstance(CreateInstanceRequest request, StreamObserver responseObserver) { @@ -132,7 +131,7 @@ public void raiseEvent(RaiseEventRequest request, StreamObserver + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> client.raiseEvent("test-instance", "testEvent")); assertGrpcCause(ex, Status.Code.NOT_FOUND); @@ -140,7 +139,7 @@ public void raiseEvent(RaiseEventRequest request, StreamObserver responseObserver) { @@ -149,7 +148,7 @@ public void getInstance(GetInstanceRequest request, StreamObserver + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> client.getInstanceMetadata("test-instance", false)); assertGrpcCause(ex, Status.Code.NOT_FOUND); @@ -224,7 +223,7 @@ public void deleteTaskHub(DeleteTaskHubRequest request, StreamObserver responseObserver) { @@ -233,7 +232,7 @@ public void purgeInstances(PurgeInstancesRequest request, StreamObserver + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> client.purgeInstance("test-instance")); assertGrpcCause(ex, Status.Code.NOT_FOUND); @@ -364,7 +363,7 @@ public void waitForInstanceCompletion(GetInstanceRequest request, StreamObserver } @Test - void purgeInstances_notFound_throwsNoSuchElementException() throws IOException { + void purgeInstances_notFound_throwsIllegalArgumentException() throws IOException { DurableTaskClient client = createClientWithFakeService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() { @Override public void purgeInstances(PurgeInstancesRequest request, StreamObserver responseObserver) { @@ -376,7 +375,7 @@ public void purgeInstances(PurgeInstancesRequest request, StreamObserver + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> client.purgeInstances(criteria)); assertGrpcCause(ex, Status.Code.NOT_FOUND); @@ -425,7 +424,7 @@ public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { @@ -438,8 +437,8 @@ public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { @@ -535,7 +534,7 @@ public void terminateInstance(TerminateRequest request, StreamObserver + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> client.terminate("test-instance", null)); assertGrpcCause(ex, Status.Code.NOT_FOUND); 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 index 00a96cff..aa123548 100644 --- a/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java +++ b/client/src/test/java/com/microsoft/durabletask/StatusRuntimeExceptionHelperTest.java @@ -7,7 +7,6 @@ import io.grpc.StatusRuntimeException; import org.junit.jupiter.api.Test; -import java.util.NoSuchElementException; import java.util.concurrent.CancellationException; import java.util.concurrent.TimeoutException; @@ -76,14 +75,14 @@ void toRuntimeException_failedPreconditionStatus_returnsIllegalStateException() } @Test - void toRuntimeException_notFoundStatus_returnsNoSuchElementException() { + void toRuntimeException_notFoundStatus_returnsIllegalArgumentException() { StatusRuntimeException grpcException = new StatusRuntimeException( Status.NOT_FOUND.withDescription("instance not found")); RuntimeException result = StatusRuntimeExceptionHelper.toRuntimeException( grpcException, "getInstanceMetadata"); - assertInstanceOf(NoSuchElementException.class, result); + assertInstanceOf(IllegalArgumentException.class, result); assertTrue(result.getMessage().contains("getInstanceMetadata")); assertTrue(result.getMessage().contains("NOT_FOUND")); assertTrue(result.getMessage().contains("instance not found")); @@ -243,14 +242,14 @@ void toException_failedPreconditionStatus_returnsIllegalStateException() { } @Test - void toException_notFoundStatus_returnsNoSuchElementException() { + void toException_notFoundStatus_returnsIllegalArgumentException() { StatusRuntimeException grpcException = new StatusRuntimeException( Status.NOT_FOUND.withDescription("not found")); Exception result = StatusRuntimeExceptionHelper.toException( grpcException, "purgeInstances"); - assertInstanceOf(NoSuchElementException.class, result); + assertInstanceOf(IllegalArgumentException.class, result); assertTrue(result.getMessage().contains("purgeInstances")); assertTrue(result.getMessage().contains("NOT_FOUND")); assertSame(grpcException, result.getCause()); From aede5301e547e8305be17c654182b69e684df3cb Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 7 Apr 2026 16:33:33 -0700 Subject: [PATCH 12/13] updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)) From eb1ad65fa702e57797d139a1576a0b93c600ffb5 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 7 Apr 2026 16:55:34 -0700 Subject: [PATCH 13/13] fixed tests --- .../com/microsoft/durabletask/DurableTaskGrpcClient.java | 4 ++++ .../microsoft/durabletask/DurableTaskGrpcClientTest.java | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index b796fce4..48a60150 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -425,6 +425,10 @@ public void rewindInstance(String instanceId, @Nullable String reason) { try { this.sidecarClient.rewindInstance(rewindRequestBuilder.build()); } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.NOT_FOUND) { + throw new IllegalArgumentException( + "No orchestration instance with ID '" + instanceId + "' was found.", e); + } if (e.getStatus().getCode() == Status.Code.FAILED_PRECONDITION) { throw new IllegalStateException( "Orchestration instance '" + instanceId + "' is not in a failed state and cannot be rewound.", e); diff --git a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java index 375e3960..94650508 100644 --- a/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java +++ b/client/src/test/java/com/microsoft/durabletask/DurableTaskGrpcClientTest.java @@ -424,7 +424,7 @@ public void rewindInstance(RewindInstanceRequest request, StreamObserver responseObserver) { @@ -437,8 +437,9 @@ public void rewindInstance(RewindInstanceRequest request, StreamObserver