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 extends Exception> 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();
+ }
+}