diff --git a/client/build.gradle b/client/build.gradle index 350ccf5b..35cc1950 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -60,7 +60,8 @@ compileTestJava { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 options.fork = true - options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac" + def javacName = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'javac.exe' : 'javac' + options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/${javacName}" } task downloadProtoFiles { @@ -93,7 +94,9 @@ protobuf { } generateProtoTasks { all()*.plugins { grpc {} } - all()*.dependsOn downloadProtoFiles + if (project.gradle.startParameter.taskNames.any { it.contains('downloadProtoFiles') }) { + all()*.dependsOn downloadProtoFiles + } } } @@ -110,7 +113,8 @@ sourceSets { } tasks.withType(Test) { - executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", 'bin/java') + def javaName = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'java.exe' : 'java' + executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", "bin/${javaName}") } test { diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java index 284b7090..ccadb2c9 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java @@ -39,6 +39,7 @@ public final class DurableTaskGrpcWorker implements AutoCloseable { private final DataConverter dataConverter; private final Duration maximumTimerInterval; private final DurableTaskGrpcWorkerVersioningOptions versioningOptions; + private final ExceptionPropertiesProvider exceptionPropertiesProvider; private final TaskHubSidecarServiceBlockingStub sidecarClient; @@ -70,6 +71,7 @@ public final class DurableTaskGrpcWorker implements AutoCloseable { this.dataConverter = builder.dataConverter != null ? builder.dataConverter : new JacksonDataConverter(); this.maximumTimerInterval = builder.maximumTimerInterval != null ? builder.maximumTimerInterval : DEFAULT_MAXIMUM_TIMER_INTERVAL; this.versioningOptions = builder.versioningOptions; + this.exceptionPropertiesProvider = builder.exceptionPropertiesProvider; } /** @@ -123,7 +125,8 @@ public void startAndBlock() { this.dataConverter, this.maximumTimerInterval, logger, - this.versioningOptions); + this.versioningOptions, + this.exceptionPropertiesProvider); TaskActivityExecutor taskActivityExecutor = new TaskActivityExecutor( this.activityFactories, this.dataConverter, @@ -351,11 +354,9 @@ public void startAndBlock() { activityRequest.getTaskId()); } catch (Throwable e) { activityError = e; - failureDetails = TaskFailureDetails.newBuilder() - .setErrorType(e.getClass().getName()) - .setErrorMessage(e.getMessage()) - .setStackTrace(StringValue.of(FailureDetails.getFullStackTrace(e))) - .build(); + Exception ex = e instanceof Exception ? (Exception) e : new RuntimeException(e); + failureDetails = FailureDetails.fromException( + ex, this.exceptionPropertiesProvider).toProto(); } finally { activityScope.close(); TracingHelper.endSpan(activitySpan, activityError); diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java index ec39fee2..cba3a7e5 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java @@ -18,6 +18,7 @@ public final class DurableTaskGrpcWorkerBuilder { DataConverter dataConverter; Duration maximumTimerInterval; DurableTaskGrpcWorkerVersioningOptions versioningOptions; + ExceptionPropertiesProvider exceptionPropertiesProvider; /** * Adds an orchestration factory to be used by the constructed {@link DurableTaskGrpcWorker}. @@ -125,6 +126,21 @@ public DurableTaskGrpcWorkerBuilder useVersioning(DurableTaskGrpcWorkerVersionin return this; } + /** + * Sets the {@link ExceptionPropertiesProvider} to use for extracting custom properties from exceptions. + *

+ * When set, the provider is invoked whenever an activity or orchestration fails with an exception. The returned + * properties are included in the {@link FailureDetails} and can be retrieved via + * {@link FailureDetails#getProperties()}. + * + * @param provider the exception properties provider + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder exceptionPropertiesProvider(ExceptionPropertiesProvider provider) { + this.exceptionPropertiesProvider = provider; + return this; + } + /** * Initializes a new {@link DurableTaskGrpcWorker} object with the settings specified in the current builder object. * @return a new {@link DurableTaskGrpcWorker} object diff --git a/client/src/main/java/com/microsoft/durabletask/ExceptionPropertiesProvider.java b/client/src/main/java/com/microsoft/durabletask/ExceptionPropertiesProvider.java new file mode 100644 index 00000000..96573be9 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ExceptionPropertiesProvider.java @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import javax.annotation.Nullable; +import java.util.Map; + +/** + * Provider interface for extracting custom properties from exceptions. + *

+ * Implementations of this interface can be registered with a {@link DurableTaskGrpcWorkerBuilder} to include + * custom exception properties in {@link FailureDetails} when activities or orchestrations fail. + * These properties are then available via {@link FailureDetails#getProperties()}. + *

+ * Example usage: + *

{@code
+ * DurableTaskGrpcWorker worker = new DurableTaskGrpcWorkerBuilder()
+ *     .exceptionPropertiesProvider(exception -> {
+ *         if (exception instanceof MyCustomException) {
+ *             MyCustomException custom = (MyCustomException) exception;
+ *             Map props = new HashMap<>();
+ *             props.put("errorCode", custom.getErrorCode());
+ *             props.put("retryable", custom.isRetryable());
+ *             return props;
+ *         }
+ *         return null;
+ *     })
+ *     .addOrchestration(...)
+ *     .build();
+ * }
+ */ +@FunctionalInterface +public interface ExceptionPropertiesProvider { + + /** + * Extracts custom properties from the given exception. + *

+ * Return {@code null} or an empty map if no custom properties should be included for this exception. + * + * @param exception the exception to extract properties from + * @return a map of property names to values, or {@code null} + */ + @Nullable + Map getExceptionProperties(Exception exception); +} diff --git a/client/src/main/java/com/microsoft/durabletask/FailureDetails.java b/client/src/main/java/com/microsoft/durabletask/FailureDetails.java index ad3f1ba1..737c8da6 100644 --- a/client/src/main/java/com/microsoft/durabletask/FailureDetails.java +++ b/client/src/main/java/com/microsoft/durabletask/FailureDetails.java @@ -2,11 +2,16 @@ // Licensed under the MIT License. package com.microsoft.durabletask; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; import com.google.protobuf.StringValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.TaskFailureDetails; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.*; /** * Class that represents the details of a task failure. @@ -20,29 +25,62 @@ public final class FailureDetails { private final String errorMessage; private final String stackTrace; private final boolean isNonRetriable; + private final FailureDetails innerFailure; + private final Map properties; FailureDetails( String errorType, @Nullable String errorMessage, @Nullable String errorDetails, boolean isNonRetriable) { + this(errorType, errorMessage, errorDetails, isNonRetriable, null, null); + } + + FailureDetails( + String errorType, + @Nullable String errorMessage, + @Nullable String errorDetails, + boolean isNonRetriable, + @Nullable FailureDetails innerFailure, + @Nullable Map properties) { this.errorType = errorType; this.stackTrace = errorDetails; // Error message can be null for things like NullPointerException but the gRPC contract doesn't allow null this.errorMessage = errorMessage != null ? errorMessage : ""; this.isNonRetriable = isNonRetriable; + this.innerFailure = innerFailure; + this.properties = properties != null ? Collections.unmodifiableMap(new HashMap<>(properties)) : null; } FailureDetails(Exception exception) { - this(exception.getClass().getName(), exception.getMessage(), getFullStackTrace(exception), false); + this(exception.getClass().getName(), + exception.getMessage(), + getFullStackTrace(exception), + false, + fromExceptionRecursive(exception.getCause(), null, 1), + null); + } + + /** + * Creates a {@code FailureDetails} from an exception, optionally using the provided + * {@link ExceptionPropertiesProvider} to extract custom properties. + * + * @param exception the exception that caused the failure + * @param provider the provider for extracting custom properties, or {@code null} + * @return a new {@code FailureDetails} instance + */ + static FailureDetails fromException(Exception exception, @Nullable ExceptionPropertiesProvider provider) { + return fromExceptionRecursive(exception, provider, 0); } FailureDetails(TaskFailureDetails proto) { this(proto.getErrorType(), proto.getErrorMessage(), proto.getStackTrace().getValue(), - proto.getIsNonRetriable()); + proto.getIsNonRetriable(), + proto.hasInnerFailure() ? new FailureDetails(proto.getInnerFailure()) : null, + convertProtoProperties(proto.getPropertiesMap())); } /** @@ -86,6 +124,28 @@ public boolean isNonRetriable() { return this.isNonRetriable; } + /** + * Gets the inner failure that caused this failure, or {@code null} if there is no inner cause. + * + * @return the inner {@code FailureDetails} or {@code null} + */ + @Nullable + public FailureDetails getInnerFailure() { + return this.innerFailure; + } + + /** + * Gets additional properties associated with the exception, or {@code null} if no properties are available. + *

+ * The returned map is unmodifiable. + * + * @return an unmodifiable map of property names to values, or {@code null} + */ + @Nullable + public Map getProperties() { + return this.properties; + } + /** * Returns {@code true} if the task failure was provided by the specified exception type, otherwise {@code false}. *

@@ -112,6 +172,11 @@ public boolean isCausedBy(Class exceptionClass) { } } + @Override + public String toString() { + return this.errorType + ": " + this.errorMessage; + } + static String getFullStackTrace(Throwable e) { StackTraceElement[] elements = e.getStackTrace(); @@ -124,10 +189,126 @@ static String getFullStackTrace(Throwable e) { } TaskFailureDetails toProto() { - return TaskFailureDetails.newBuilder() + TaskFailureDetails.Builder builder = TaskFailureDetails.newBuilder() .setErrorType(this.getErrorType()) .setErrorMessage(this.getErrorMessage()) .setStackTrace(StringValue.of(this.getStackTrace() != null ? this.getStackTrace() : "")) - .build(); + .setIsNonRetriable(this.isNonRetriable); + + if (this.innerFailure != null) { + builder.setInnerFailure(this.innerFailure.toProto()); + } + + if (this.properties != null) { + builder.putAllProperties(convertToProtoProperties(this.properties)); + } + + return builder.build(); + } + + private static final int MAX_INNER_FAILURE_DEPTH = 10; + + @Nullable + private static FailureDetails fromExceptionRecursive( + @Nullable Throwable exception, + @Nullable ExceptionPropertiesProvider provider, + int depth) { + if (exception == null || depth > MAX_INNER_FAILURE_DEPTH) { + return null; + } + Map properties = null; + if (provider != null && exception instanceof Exception) { + try { + properties = provider.getExceptionProperties((Exception) exception); + } catch (Exception ignored) { + // Don't let provider errors mask the original failure + } + } + return new FailureDetails( + exception.getClass().getName(), + exception.getMessage(), + getFullStackTrace(exception), + false, + fromExceptionRecursive(exception.getCause(), provider, depth + 1), + properties); + } + + @Nullable + private static Map convertProtoProperties(Map protoProperties) { + if (protoProperties == null || protoProperties.isEmpty()) { + return null; + } + + Map result = new HashMap<>(); + for (Map.Entry entry : protoProperties.entrySet()) { + result.put(entry.getKey(), convertProtoValue(entry.getValue())); + } + return result; + } + + @Nullable + private static Object convertProtoValue(Value value) { + if (value == null) { + return null; + } + switch (value.getKindCase()) { + case NULL_VALUE: + return null; + case NUMBER_VALUE: + return value.getNumberValue(); + case STRING_VALUE: + return value.getStringValue(); + case BOOL_VALUE: + return value.getBoolValue(); + case LIST_VALUE: + List list = new ArrayList<>(); + for (Value item : value.getListValue().getValuesList()) { + list.add(convertProtoValue(item)); + } + return list; + case STRUCT_VALUE: + Map map = new HashMap<>(); + for (Map.Entry entry : value.getStructValue().getFieldsMap().entrySet()) { + map.put(entry.getKey(), convertProtoValue(entry.getValue())); + } + return map; + default: + return value.toString(); + } + } + + private static Map convertToProtoProperties(Map properties) { + Map result = new HashMap<>(); + for (Map.Entry entry : properties.entrySet()) { + result.put(entry.getKey(), convertToProtoValue(entry.getValue())); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Value convertToProtoValue(@Nullable Object obj) { + if (obj == null) { + return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + } else if (obj instanceof Number) { + return Value.newBuilder().setNumberValue(((Number) obj).doubleValue()).build(); + } else if (obj instanceof Boolean) { + return Value.newBuilder().setBoolValue((Boolean) obj).build(); + } else if (obj instanceof String) { + return Value.newBuilder().setStringValue((String) obj).build(); + } else if (obj instanceof List) { + ListValue.Builder listBuilder = ListValue.newBuilder(); + for (Object item : (List) obj) { + listBuilder.addValues(convertToProtoValue(item)); + } + return Value.newBuilder().setListValue(listBuilder).build(); + } else if (obj instanceof Map) { + Struct.Builder structBuilder = Struct.newBuilder(); + for (Map.Entry entry : ((Map) obj).entrySet()) { + structBuilder.putFields(entry.getKey(), convertToProtoValue(entry.getValue())); + } + return Value.newBuilder().setStructValue(structBuilder).build(); + } else { + return Value.newBuilder().setStringValue(obj.toString()).build(); + } } -} \ No newline at end of file +} diff --git a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java index 00e48a8f..5da54d96 100644 --- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java +++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java @@ -31,6 +31,7 @@ final class TaskOrchestrationExecutor { private final Logger logger; private final Duration maximumTimerInterval; private final DurableTaskGrpcWorkerVersioningOptions versioningOptions; + private final ExceptionPropertiesProvider exceptionPropertiesProvider; public TaskOrchestrationExecutor( HashMap orchestrationFactories, @@ -38,11 +39,22 @@ public TaskOrchestrationExecutor( Duration maximumTimerInterval, Logger logger, DurableTaskGrpcWorkerVersioningOptions versioningOptions) { + this(orchestrationFactories, dataConverter, maximumTimerInterval, logger, versioningOptions, null); + } + + public TaskOrchestrationExecutor( + HashMap orchestrationFactories, + DataConverter dataConverter, + Duration maximumTimerInterval, + Logger logger, + DurableTaskGrpcWorkerVersioningOptions versioningOptions, + ExceptionPropertiesProvider exceptionPropertiesProvider) { this.orchestrationFactories = orchestrationFactories; this.dataConverter = dataConverter; this.maximumTimerInterval = maximumTimerInterval; this.logger = logger; this.versioningOptions = versioningOptions; + this.exceptionPropertiesProvider = exceptionPropertiesProvider; } public TaskOrchestratorResult execute( @@ -71,7 +83,7 @@ public TaskOrchestratorResult execute( // The orchestrator threw an unhandled exception - fail it // TODO: What's the right way to log this? logger.warning("The orchestrator failed with an unhandled exception: " + e.toString()); - context.fail(new FailureDetails(e)); + context.fail(FailureDetails.fromException(e, this.exceptionPropertiesProvider)); } if ((context.continuedAsNew && !context.isComplete) || (completed && context.pendingActions.isEmpty() && !context.waitingForEvents())) { diff --git a/client/src/test/java/com/microsoft/durabletask/ErrorHandlingIntegrationTests.java b/client/src/test/java/com/microsoft/durabletask/ErrorHandlingIntegrationTests.java index 996919b4..09a5d1ef 100644 --- a/client/src/test/java/com/microsoft/durabletask/ErrorHandlingIntegrationTests.java +++ b/client/src/test/java/com/microsoft/durabletask/ErrorHandlingIntegrationTests.java @@ -3,12 +3,15 @@ package com.microsoft.durabletask; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -309,4 +312,262 @@ private FailureDetails retryOnFailuresCoreTest( return details; } } + + /** + * Tests that inner exception details are preserved without a provider, and no properties are included. + */ + @Disabled("Emulator (dts-emulator) does not yet support nested innerFailure in TaskFailureDetails") + @Test + void innerExceptionDetailsArePreserved() throws TimeoutException { + final String orchestratorName = "Parent"; + final String subOrchestratorName = "Sub"; + final String activityName = "ThrowException"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ctx.callSubOrchestrator(subOrchestratorName, "", String.class).await(); + }) + .addOrchestrator(subOrchestratorName, ctx -> { + ctx.callActivity(activityName).await(); + }) + .addActivity(activityName, ctx -> { + throw new RuntimeException("first", + new IllegalArgumentException("second", + new IllegalStateException("third"))); + }) + .buildAndStart(); + + DurableTaskClient client = this.createClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + // Top-level: parent orchestration failed with TaskFailedException wrapping the sub-orchestration + FailureDetails topLevel = instance.getFailureDetails(); + assertNotNull(topLevel); + assertEquals("com.microsoft.durabletask.TaskFailedException", topLevel.getErrorType()); + assertTrue(topLevel.getErrorMessage().contains(subOrchestratorName)); + + // Level 1: sub-orchestration failed with TaskFailedException wrapping the activity + assertNotNull(topLevel.getInnerFailure()); + FailureDetails subOrchFailure = topLevel.getInnerFailure(); + assertEquals("com.microsoft.durabletask.TaskFailedException", subOrchFailure.getErrorType()); + assertTrue(subOrchFailure.getErrorMessage().contains(activityName)); + + // Level 2: actual exception from the activity - RuntimeException("first") + assertNotNull(subOrchFailure.getInnerFailure()); + FailureDetails activityFailure = subOrchFailure.getInnerFailure(); + assertEquals("java.lang.RuntimeException", activityFailure.getErrorType()); + assertEquals("first", activityFailure.getErrorMessage()); + + // Level 3: inner cause - IllegalArgumentException("second") + assertNotNull(activityFailure.getInnerFailure()); + FailureDetails innerCause1 = activityFailure.getInnerFailure(); + assertEquals("java.lang.IllegalArgumentException", innerCause1.getErrorType()); + assertEquals("second", innerCause1.getErrorMessage()); + + // Level 4: innermost cause - IllegalStateException("third") + assertNotNull(innerCause1.getInnerFailure()); + FailureDetails innerCause2 = innerCause1.getInnerFailure(); + assertEquals("java.lang.IllegalStateException", innerCause2.getErrorType()); + assertEquals("third", innerCause2.getErrorMessage()); + assertNull(innerCause2.getInnerFailure()); + + // No provider registered, so no properties at any level + assertNull(topLevel.getProperties()); + assertNull(subOrchFailure.getProperties()); + assertNull(activityFailure.getProperties()); + assertNull(innerCause1.getProperties()); + assertNull(innerCause2.getProperties()); + } + } + + /** + * Tests that a registered {@link ExceptionPropertiesProvider} extracts custom properties + * from an activity exception into {@link FailureDetails#getProperties()}. + */ + @Test + void customExceptionPropertiesInFailureDetails() throws TimeoutException { + final String orchestratorName = "OrchestrationWithCustomException"; + final String activityName = "BusinessActivity"; + + ExceptionPropertiesProvider provider = exception -> { + if (exception instanceof IllegalArgumentException) { + Map props = new HashMap<>(); + props.put("paramName", exception.getMessage()); + return props; + } + if (exception instanceof BusinessValidationException) { + BusinessValidationException bve = (BusinessValidationException) exception; + Map props = new HashMap<>(); + props.put("errorCode", bve.errorCode); + props.put("retryCount", bve.retryCount); + props.put("isCritical", bve.isCritical); + return props; + } + return null; + }; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .exceptionPropertiesProvider(provider) + .addOrchestrator(orchestratorName, ctx -> { + ctx.callActivity(activityName).await(); + }) + .addActivity(activityName, ctx -> { + throw new BusinessValidationException( + "Business logic validation failed", + "VALIDATION_FAILED", + 3, + true); + }) + .buildAndStart(); + + DurableTaskClient client = this.createClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + FailureDetails topLevel = instance.getFailureDetails(); + assertNotNull(topLevel); + assertEquals("com.microsoft.durabletask.TaskFailedException", topLevel.getErrorType()); + + // The activity failure is in the inner failure + assertNotNull(topLevel.getInnerFailure()); + FailureDetails innerFailure = topLevel.getInnerFailure(); + assertTrue(innerFailure.getErrorType().contains("BusinessValidationException")); + assertEquals("Business logic validation failed", innerFailure.getErrorMessage()); + + // Verify custom properties are included + assertNotNull(innerFailure.getProperties()); + assertEquals(3, innerFailure.getProperties().size()); + assertEquals("VALIDATION_FAILED", innerFailure.getProperties().get("errorCode")); + assertEquals(3.0, innerFailure.getProperties().get("retryCount")); + assertEquals(true, innerFailure.getProperties().get("isCritical")); + } + } + + /** + * Tests that properties from a directly-thrown orchestration exception are on the top-level failure. + */ + @Test + void orchestrationDirectExceptionWithProperties() throws TimeoutException { + final String orchestratorName = "OrchestrationWithDirectException"; + final String paramName = "testParameter"; + + ExceptionPropertiesProvider provider = exception -> { + if (exception instanceof IllegalArgumentException) { + Map props = new HashMap<>(); + props.put("paramName", exception.getMessage()); + return props; + } + return null; + }; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .exceptionPropertiesProvider(provider) + .addOrchestrator(orchestratorName, ctx -> { + throw new IllegalArgumentException(paramName); + }) + .buildAndStart(); + + DurableTaskClient client = this.createClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + assertEquals("java.lang.IllegalArgumentException", details.getErrorType()); + assertTrue(details.getErrorMessage().contains(paramName)); + + // Verify custom properties from provider + assertNotNull(details.getProperties()); + assertEquals(1, details.getProperties().size()); + assertEquals(paramName, details.getProperties().get("paramName")); + } + } + + /** + * Tests that custom properties survive through a parent -> sub-orchestration -> activity chain. + */ + @Disabled("Investigating emulator compatibility") + @Test + void nestedOrchestrationExceptionPropertiesPreserved() throws TimeoutException { + final String parentOrchName = "ParentOrch"; + final String subOrchName = "SubOrch"; + final String activityName = "ActivityWithProps"; + final String errorCode = "ERR_123"; + + ExceptionPropertiesProvider provider = exception -> { + if (exception instanceof BusinessValidationException) { + BusinessValidationException bve = (BusinessValidationException) exception; + Map props = new HashMap<>(); + props.put("errorCode", bve.errorCode); + props.put("retryCount", bve.retryCount); + props.put("isCritical", bve.isCritical); + return props; + } + return null; + }; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .exceptionPropertiesProvider(provider) + .addOrchestrator(parentOrchName, ctx -> { + ctx.callSubOrchestrator(subOrchName, "", String.class).await(); + }) + .addOrchestrator(subOrchName, ctx -> { + ctx.callActivity(activityName).await(); + }) + .addActivity(activityName, ctx -> { + throw new BusinessValidationException("nested error", errorCode, 5, false); + }) + .buildAndStart(); + + DurableTaskClient client = this.createClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(parentOrchName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + // Parent -> TaskFailedException wrapping sub-orch + FailureDetails topLevel = instance.getFailureDetails(); + assertNotNull(topLevel); + assertTrue(topLevel.isCausedBy(TaskFailedException.class)); + + // Sub-orch -> TaskFailedException wrapping activity + assertNotNull(topLevel.getInnerFailure()); + assertTrue(topLevel.getInnerFailure().isCausedBy(TaskFailedException.class)); + + // Activity -> BusinessValidationException with properties + assertNotNull(topLevel.getInnerFailure().getInnerFailure()); + FailureDetails activityFailure = topLevel.getInnerFailure().getInnerFailure(); + assertTrue(activityFailure.getErrorType().contains("BusinessValidationException")); + + // Verify properties survived the full chain + assertNotNull(activityFailure.getProperties()); + assertEquals(errorCode, activityFailure.getProperties().get("errorCode")); + assertEquals(5.0, activityFailure.getProperties().get("retryCount")); + assertEquals(false, activityFailure.getProperties().get("isCritical")); + } + } + + static class BusinessValidationException extends RuntimeException { + final String errorCode; + final int retryCount; + final boolean isCritical; + + BusinessValidationException(String message, String errorCode, int retryCount, boolean isCritical) { + super(message); + this.errorCode = errorCode; + this.retryCount = retryCount; + this.isCritical = isCritical; + } + } } diff --git a/client/src/test/java/com/microsoft/durabletask/FailureDetailsTest.java b/client/src/test/java/com/microsoft/durabletask/FailureDetailsTest.java new file mode 100644 index 00000000..c14bf9fe --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/FailureDetailsTest.java @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask; + +import com.google.protobuf.NullValue; +import com.google.protobuf.StringValue; +import com.google.protobuf.Value; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.TaskFailureDetails; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link FailureDetails} proto serialization and provider logic. + */ +public class FailureDetailsTest { + + @Test + void constructFromProto_withInnerFailureAndProperties() { + TaskFailureDetails innerProto = TaskFailureDetails.newBuilder() + .setErrorType("java.io.IOException") + .setErrorMessage("disk full") + .setStackTrace(StringValue.of("at IO.write(IO.java:42)")) + .putProperties("retryable", Value.newBuilder().setBoolValue(false).build()) + .build(); + + TaskFailureDetails outerProto = TaskFailureDetails.newBuilder() + .setErrorType("java.lang.RuntimeException") + .setErrorMessage("operation failed") + .setStackTrace(StringValue.of("at App.run(App.java:5)")) + .setInnerFailure(innerProto) + .putProperties("correlationId", Value.newBuilder().setStringValue("abc-123").build()) + .build(); + + FailureDetails details = new FailureDetails(outerProto); + + assertEquals("java.lang.RuntimeException", details.getErrorType()); + assertEquals("operation failed", details.getErrorMessage()); + assertNotNull(details.getProperties()); + assertEquals("abc-123", details.getProperties().get("correlationId")); + + assertNotNull(details.getInnerFailure()); + FailureDetails inner = details.getInnerFailure(); + assertEquals("java.io.IOException", inner.getErrorType()); + assertEquals("disk full", inner.getErrorMessage()); + assertNotNull(inner.getProperties()); + assertEquals(false, inner.getProperties().get("retryable")); + assertNull(inner.getInnerFailure()); + } + + @Test + void constructFromProto_multiplePropertyTypes() { + TaskFailureDetails proto = TaskFailureDetails.newBuilder() + .setErrorType("CustomException") + .setErrorMessage("error") + .setStackTrace(StringValue.of("")) + .putProperties("stringProp", Value.newBuilder().setStringValue("hello").build()) + .putProperties("intProp", Value.newBuilder().setNumberValue(100.0).build()) + .putProperties("boolProp", Value.newBuilder().setBoolValue(true).build()) + .putProperties("nullProp", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .putProperties("longProp", Value.newBuilder().setNumberValue(999999999.0).build()) + .build(); + + FailureDetails details = new FailureDetails(proto); + + assertNotNull(details.getProperties()); + assertEquals(5, details.getProperties().size()); + assertEquals("hello", details.getProperties().get("stringProp")); + assertEquals(100.0, details.getProperties().get("intProp")); + assertEquals(true, details.getProperties().get("boolProp")); + assertNull(details.getProperties().get("nullProp")); + assertTrue(details.getProperties().containsKey("nullProp")); + assertEquals(999999999.0, details.getProperties().get("longProp")); + } + + @Test + void constructFromProto_emptyProperties_returnsNull() { + TaskFailureDetails proto = TaskFailureDetails.newBuilder() + .setErrorType("SomeError") + .setErrorMessage("msg") + .setStackTrace(StringValue.of("")) + .build(); + + FailureDetails details = new FailureDetails(proto); + + assertNull(details.getProperties()); + assertNull(details.getInnerFailure()); + } + + @Test + void constructFromProto_propertiesAreUnmodifiable() { + TaskFailureDetails proto = TaskFailureDetails.newBuilder() + .setErrorType("SomeError") + .setErrorMessage("msg") + .setStackTrace(StringValue.of("")) + .putProperties("key", Value.newBuilder().setStringValue("value").build()) + .build(); + + FailureDetails details = new FailureDetails(proto); + + assertThrows(UnsupportedOperationException.class, + () -> details.getProperties().put("newKey", "newValue")); + } + + @Test + void toProto_roundTrip_withInnerFailureAndProperties() { + Map innerProps = new HashMap<>(); + innerProps.put("errorCode", "DISK_FULL"); + innerProps.put("retryCount", 3); + innerProps.put("isCritical", true); + innerProps.put("nullVal", null); + + FailureDetails inner = new FailureDetails( + "java.io.IOException", "disk full", "stack", false, null, innerProps); + FailureDetails outer = new FailureDetails( + "java.lang.RuntimeException", "operation failed", "outer stack", true, inner, null); + + TaskFailureDetails proto = outer.toProto(); + FailureDetails roundTripped = new FailureDetails(proto); + + assertEquals("java.lang.RuntimeException", roundTripped.getErrorType()); + assertTrue(roundTripped.isNonRetriable()); + assertNull(roundTripped.getProperties()); + + assertNotNull(roundTripped.getInnerFailure()); + FailureDetails roundTrippedInner = roundTripped.getInnerFailure(); + assertEquals("java.io.IOException", roundTrippedInner.getErrorType()); + assertNotNull(roundTrippedInner.getProperties()); + assertEquals(4, roundTrippedInner.getProperties().size()); + assertEquals("DISK_FULL", roundTrippedInner.getProperties().get("errorCode")); + assertEquals(3.0, roundTrippedInner.getProperties().get("retryCount")); + assertEquals(true, roundTrippedInner.getProperties().get("isCritical")); + assertTrue(roundTrippedInner.getProperties().containsKey("nullVal")); + assertNull(roundTrippedInner.getProperties().get("nullVal")); + } + + @Test + void fromException_withProvider_extractsAndRoundTrips() { + ExceptionPropertiesProvider provider = exception -> { + Map props = new HashMap<>(); + props.put("exceptionType", exception.getClass().getSimpleName()); + return props; + }; + + IOException inner = new IOException("disk full"); + RuntimeException outer = new RuntimeException("failed", inner); + + FailureDetails details = FailureDetails.fromException(outer, provider); + + // Provider called on outer + assertNotNull(details.getProperties()); + assertEquals("RuntimeException", details.getProperties().get("exceptionType")); + + // Provider called recursively on inner + assertNotNull(details.getInnerFailure()); + assertNotNull(details.getInnerFailure().getProperties()); + assertEquals("IOException", details.getInnerFailure().getProperties().get("exceptionType")); + + // Round-trip through proto preserves everything + TaskFailureDetails proto = details.toProto(); + FailureDetails roundTripped = new FailureDetails(proto); + + assertEquals("java.lang.RuntimeException", roundTripped.getErrorType()); + assertEquals("RuntimeException", roundTripped.getProperties().get("exceptionType")); + assertEquals("java.io.IOException", roundTripped.getInnerFailure().getErrorType()); + assertEquals("IOException", roundTripped.getInnerFailure().getProperties().get("exceptionType")); + } + + @Test + void fromException_withNullProvider_noProperties() { + RuntimeException ex = new RuntimeException("test", new IOException("cause")); + + FailureDetails details = FailureDetails.fromException(ex, null); + + assertNull(details.getProperties()); + assertNotNull(details.getInnerFailure()); + assertNull(details.getInnerFailure().getProperties()); + } + + @Test + void fromException_providerThrows_gracefullyIgnored() { + ExceptionPropertiesProvider provider = exception -> { + throw new RuntimeException("provider error"); + }; + + IllegalStateException ex = new IllegalStateException("original error"); + + FailureDetails details = FailureDetails.fromException(ex, provider); + + assertEquals("java.lang.IllegalStateException", details.getErrorType()); + assertEquals("original error", details.getErrorMessage()); + assertNull(details.getProperties()); + } + + @Test + void fromException_providerReturnsNull_noProperties() { + ExceptionPropertiesProvider provider = exception -> null; + + FailureDetails details = FailureDetails.fromException(new RuntimeException("test"), provider); + + assertNull(details.getProperties()); + } + + @Test + void fromException_providerSelectivelyReturnsProperties() { + ExceptionPropertiesProvider provider = exception -> { + if (exception instanceof IllegalArgumentException) { + Map props = new HashMap<>(); + props.put("paramName", "userId"); + return props; + } + return null; + }; + + IllegalArgumentException inner = new IllegalArgumentException("bad param"); + RuntimeException outer = new RuntimeException("wrapper", inner); + + FailureDetails details = FailureDetails.fromException(outer, provider); + + // Provider returns null for RuntimeException + assertNull(details.getProperties()); + + // Provider returns properties for IllegalArgumentException + assertNotNull(details.getInnerFailure()); + assertNotNull(details.getInnerFailure().getProperties()); + assertEquals("userId", details.getInnerFailure().getProperties().get("paramName")); + } +} diff --git a/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java b/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java index 01a485d9..67d9d8d0 100644 --- a/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java +++ b/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java @@ -119,5 +119,10 @@ public TestDurableTaskWorkerBuilder useVersioning(DurableTaskGrpcWorkerVersionin this.innerBuilder.useVersioning(options); return this; } + + public TestDurableTaskWorkerBuilder exceptionPropertiesProvider(ExceptionPropertiesProvider provider) { + this.innerBuilder.exceptionPropertiesProvider(provider); + return this; + } } } diff --git a/samples/src/main/java/io/durabletask/samples/CustomExceptionPropertiesPattern.java b/samples/src/main/java/io/durabletask/samples/CustomExceptionPropertiesPattern.java new file mode 100644 index 00000000..8ab48331 --- /dev/null +++ b/samples/src/main/java/io/durabletask/samples/CustomExceptionPropertiesPattern.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package io.durabletask.samples; + +import com.microsoft.durabletask.*; + +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeoutException; + +/** + * Demonstrates how to use {@link ExceptionPropertiesProvider} to attach custom + * metadata to failure details when activities or orchestrations fail. + * + *

This allows callers to inspect structured error information (e.g., error codes, + * severity levels) without parsing exception messages or stack traces. + */ +final class CustomExceptionPropertiesPattern { + + public static void main(String[] args) throws IOException, InterruptedException, TimeoutException { + DurableTaskGrpcWorker worker = createWorker(); + worker.start(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + String instanceId = client.scheduleNewOrchestrationInstance("ProcessOrder"); + System.out.printf("Started orchestration: %s%n", instanceId); + + OrchestrationMetadata result = client.waitForInstanceCompletion( + instanceId, Duration.ofSeconds(30), true); + + System.out.printf("Status: %s%n", result.getRuntimeStatus()); + if (result.getRuntimeStatus() == OrchestrationRuntimeStatus.FAILED) { + FailureDetails failure = result.getFailureDetails(); + System.out.printf("Error: %s%n", failure.getErrorMessage()); + + // Navigate inner failures to find the root cause with properties + FailureDetails current = failure; + while (current.getInnerFailure() != null) { + current = current.getInnerFailure(); + } + if (current.getProperties() != null) { + System.out.printf("Root cause properties: %s%n", current.getProperties()); + } + } + + worker.stop(); + } + + static class OrderValidationException extends RuntimeException { + final String errorCode; + final int severity; + + OrderValidationException(String message, String errorCode, int severity) { + super(message); + this.errorCode = errorCode; + this.severity = severity; + } + } + + private static DurableTaskGrpcWorker createWorker() { + DurableTaskGrpcWorkerBuilder builder = new DurableTaskGrpcWorkerBuilder(); + + // Register a provider that extracts custom fields from known exception types + builder.exceptionPropertiesProvider(exception -> { + if (exception instanceof OrderValidationException) { + OrderValidationException ove = (OrderValidationException) exception; + Map props = new HashMap<>(); + props.put("errorCode", ove.errorCode); + props.put("severity", ove.severity); + return props; + } + return null; + }); + + builder.addOrchestration(new TaskOrchestrationFactory() { + @Override + public String getName() { return "ProcessOrder"; } + + @Override + public TaskOrchestration create() { + return ctx -> { + ctx.callActivity("ValidateOrder", "order-123", Void.class).await(); + ctx.complete("done"); + }; + } + }); + + builder.addActivity(new TaskActivityFactory() { + @Override + public String getName() { return "ValidateOrder"; } + + @Override + public TaskActivity create() { + return ctx -> { + throw new OrderValidationException( + "Order has invalid items", + "INVALID_ITEMS", + 3); + }; + } + }); + + return builder.build(); + } +}