diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java
index 99c9b1ef..074906c1 100644
--- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java
+++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java
@@ -46,6 +46,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 int maxConcurrentEntityWorkItems;
private final ExecutorService workItemExecutor;
@@ -84,6 +85,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;
int maxThreads = builder.maxWorkItemThreads > 0 ? builder.maxWorkItemThreads : DEFAULT_MAX_WORK_ITEM_THREADS;
this.workItemExecutor = new ThreadPoolExecutor(
0, maxThreads,
@@ -159,7 +161,8 @@ public void startAndBlock() {
this.maximumTimerInterval,
logger,
this.versioningOptions,
- true);
+ true,
+ this.exceptionPropertiesProvider);
TaskActivityExecutor taskActivityExecutor = new TaskActivityExecutor(
this.activityFactories,
this.dataConverter,
@@ -389,11 +392,8 @@ 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();
+ failureDetails = FailureDetails.fromException(
+ e, 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 4af2335e..8d3672bc 100644
--- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java
+++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java
@@ -24,6 +24,7 @@ public final class DurableTaskGrpcWorkerBuilder {
DataConverter dataConverter;
Duration maximumTimerInterval;
DurableTaskGrpcWorkerVersioningOptions versioningOptions;
+ ExceptionPropertiesProvider exceptionPropertiesProvider;
int maxConcurrentEntityWorkItems = 1;
int maxWorkItemThreads;
private WorkItemFilter workItemFilter;
@@ -292,6 +293,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;
+ }
+
/**
* Sets explicit work item filters for this worker. When set, only work items matching the filters
* will be dispatched to this worker by the backend.
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..93679851 100644
--- a/client/src/main/java/com/microsoft/durabletask/FailureDetails.java
+++ b/client/src/main/java/com/microsoft/durabletask/FailureDetails.java
@@ -2,11 +2,18 @@
// 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.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
/**
* Class that represents the details of a task failure.
@@ -16,33 +23,65 @@
* result in task failures, in which case there may not be any exception-specific information.
*/
public final class FailureDetails {
+ private static final Logger logger = Logger.getLogger(FailureDetails.class.getName());
+
private final String errorType;
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);
+ /**
+ * Creates a {@code FailureDetails} from a throwable, optionally using the provided
+ * {@link ExceptionPropertiesProvider} to extract custom properties.
+ *
+ * Accepts any {@link Throwable} so callers that catch {@code Throwable} (for example, activity
+ * dispatchers that need to report {@link Error} subclasses such as {@link StackOverflowError}
+ * or {@link OutOfMemoryError}) can preserve the original error type instead of having to wrap
+ * it in a {@link RuntimeException}. The {@link ExceptionPropertiesProvider}, however, is only
+ * invoked for {@link Exception} instances, since that is what its contract accepts.
+ *
+ * @param throwable the throwable that caused the failure
+ * @param provider the provider for extracting custom properties, or {@code null}
+ * @return a new {@code FailureDetails} instance
+ */
+ static FailureDetails fromException(Throwable throwable, @Nullable ExceptionPropertiesProvider provider) {
+ return fromExceptionRecursive(throwable, 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()));
}
/**
@@ -87,17 +126,54 @@ public boolean isNonRetriable() {
}
/**
- * Returns {@code true} if the task failure was provided by the specified exception type, otherwise {@code false}.
+ * 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 this failure's top-level error type matches the specified exception type, otherwise
+ * {@code false}.
*
- * This method allows checking if a task failed due to a specific exception type by attempting to load the class
- * specified in {@link #getErrorType()}. If the exception class cannot be loaded for any reason, this method will
- * return {@code false}. Base types are supported by this method, as shown in the following example:
+ * This method only inspects the error type reported by {@link #getErrorType()} on this instance; it
+ * does not traverse the inner failure chain exposed by {@link #getInnerFailure()}. This matches
+ * the behavior of {@code TaskFailureDetails.IsCausedBy} in the Durable Task .NET SDK. If you also want to test
+ * wrapped causes, walk the chain explicitly, for example:
+ *
{@code
+ * for (FailureDetails f = failureDetails; f != null; f = f.getInnerFailure()) {
+ * if (f.isCausedBy(IllegalStateException.class)) {
+ * // handle
+ * break;
+ * }
+ * }
+ * }
+ *
+ * Internally the method attempts to load the class named by {@link #getErrorType()} via reflection. If the
+ * exception class cannot be loaded for any reason (for example, it is not on the worker's classpath), this
+ * method returns {@code false}. Base types are supported, as shown below:
*
{@code
* boolean isRuntimeException = failureDetails.isCausedBy(RuntimeException.class);
* }
*
* @param exceptionClass the class representing the exception type to test
- * @return {@code true} if the task failure was provided by the specified exception type, otherwise {@code false}
+ * @return {@code true} if this failure's top-level error type is assignable to {@code exceptionClass};
+ * {@code false} otherwise
*/
public boolean isCausedBy(Class extends Exception> exceptionClass) {
String actualClassName = this.getErrorType();
@@ -112,6 +188,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 +205,131 @@ 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 providerException) {
+ // Don't let provider errors mask the original failure, but log so the issue is diagnosable.
+ logger.log(
+ Level.WARNING,
+ providerException,
+ () -> "ExceptionPropertiesProvider threw while extracting properties for "
+ + exception.getClass().getName() + "; ignoring provider output.");
+ }
+ }
+ 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 Collections.unmodifiableList(list);
+ case STRUCT_VALUE:
+ Map map = new HashMap<>();
+ for (Map.Entry entry : value.getStructValue().getFieldsMap().entrySet()) {
+ map.put(entry.getKey(), convertProtoValue(entry.getValue()));
+ }
+ return Collections.unmodifiableMap(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 b5206765..8e60080e 100644
--- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java
+++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java
@@ -37,6 +37,7 @@ final class TaskOrchestrationExecutor {
private final Logger logger;
private final Duration maximumTimerInterval;
private final DurableTaskGrpcWorkerVersioningOptions versioningOptions;
+ private final ExceptionPropertiesProvider exceptionPropertiesProvider;
private final boolean useNativeEntityActions;
public TaskOrchestrationExecutor(
@@ -45,7 +46,7 @@ public TaskOrchestrationExecutor(
Duration maximumTimerInterval,
Logger logger,
DurableTaskGrpcWorkerVersioningOptions versioningOptions) {
- this(orchestrationFactories, dataConverter, maximumTimerInterval, logger, versioningOptions, false);
+ this(orchestrationFactories, dataConverter, maximumTimerInterval, logger, versioningOptions, false, null);
}
public TaskOrchestrationExecutor(
@@ -55,12 +56,24 @@ public TaskOrchestrationExecutor(
Logger logger,
DurableTaskGrpcWorkerVersioningOptions versioningOptions,
boolean useNativeEntityActions) {
+ this(orchestrationFactories, dataConverter, maximumTimerInterval, logger, versioningOptions, useNativeEntityActions, null);
+ }
+
+ public TaskOrchestrationExecutor(
+ HashMap orchestrationFactories,
+ DataConverter dataConverter,
+ Duration maximumTimerInterval,
+ Logger logger,
+ DurableTaskGrpcWorkerVersioningOptions versioningOptions,
+ boolean useNativeEntityActions,
+ ExceptionPropertiesProvider exceptionPropertiesProvider) {
this.orchestrationFactories = orchestrationFactories;
this.dataConverter = dataConverter;
this.maximumTimerInterval = maximumTimerInterval;
this.logger = logger;
this.versioningOptions = versioningOptions;
this.useNativeEntityActions = useNativeEntityActions;
+ this.exceptionPropertiesProvider = exceptionPropertiesProvider;
}
public TaskOrchestratorResult execute(
@@ -93,7 +106,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..294e484b 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,263 @@ 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()}.
+ */
+ @Disabled("Emulator (dts-emulator) does not yet support properties in activity-level TaskFailureDetails")
+ @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("Emulator (dts-emulator) does not yet support properties in activity-level TaskFailureDetails")
+ @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..ad3279a9
--- /dev/null
+++ b/client/src/test/java/com/microsoft/durabletask/FailureDetailsTest.java
@@ -0,0 +1,279 @@
+// 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_acceptsErrorAndPreservesType() {
+ // Errors aren't Exceptions, but activity dispatchers catch Throwable. fromException should
+ // accept them so the original error type (e.g. StackOverflowError) is reported instead of
+ // being hidden behind a synthetic RuntimeException wrapper.
+ StackOverflowError error = new StackOverflowError("too deep");
+
+ FailureDetails details = FailureDetails.fromException(error, null);
+
+ assertEquals("java.lang.StackOverflowError", details.getErrorType());
+ assertEquals("too deep", details.getErrorMessage());
+ assertNull(details.getProperties());
+ }
+
+ @Test
+ void fromException_errorWithCause_preservesInnerFailure() {
+ IOException cause = new IOException("disk full");
+ OutOfMemoryError error = new OutOfMemoryError("heap exhausted");
+ error.initCause(cause);
+
+ FailureDetails details = FailureDetails.fromException(error, null);
+
+ assertEquals("java.lang.OutOfMemoryError", details.getErrorType());
+ assertNotNull(details.getInnerFailure());
+ assertEquals("java.io.IOException", details.getInnerFailure().getErrorType());
+ assertEquals("disk full", details.getInnerFailure().getErrorMessage());
+ }
+
+ @Test
+ void fromException_errorWithProvider_skipsProviderForError() {
+ // The provider contract takes Exception, not Throwable, so for an Error we shouldn't
+ // call the provider at all. The Error itself still needs to be reported faithfully.
+ ExceptionPropertiesProvider provider = exception -> {
+ Map props = new HashMap<>();
+ props.put("called", true);
+ return props;
+ };
+
+ StackOverflowError error = new StackOverflowError("too deep");
+
+ FailureDetails details = FailureDetails.fromException(error, provider);
+
+ assertEquals("java.lang.StackOverflowError", details.getErrorType());
+ 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 f7b45576..a887d9ab 100644
--- a/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java
+++ b/client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java
@@ -120,6 +120,11 @@ public TestDurableTaskWorkerBuilder useVersioning(DurableTaskGrpcWorkerVersionin
return this;
}
+ public TestDurableTaskWorkerBuilder exceptionPropertiesProvider(ExceptionPropertiesProvider provider) {
+ this.innerBuilder.exceptionPropertiesProvider(provider);
+ return this;
+ }
+
public TestDurableTaskWorkerBuilder addEntity(String name, TaskEntityFactory factory) {
this.innerBuilder.addEntity(name, factory);
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();
+ }
+}