diff --git a/src/DurableTask.Core/ExceptionPropertiesProviderExtensions.cs b/src/DurableTask.Core/ExceptionPropertiesProviderExtensions.cs new file mode 100644 index 000000000..08b2d9692 --- /dev/null +++ b/src/DurableTask.Core/ExceptionPropertiesProviderExtensions.cs @@ -0,0 +1,46 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- +#nullable enable +namespace DurableTask.Core +{ + using System; + using System.Collections.Generic; + using DurableTask.Core.Exceptions; + + /// + /// Extension methods for . + /// + public static class ExceptionPropertiesProviderExtensions + { + /// + /// Extracts properties of the exception specified at provider. + /// + public static IDictionary? ExtractProperties(this IExceptionPropertiesProvider? provider, Exception exception) + { + if (exception is OrchestrationException orchestrationException && + orchestrationException.FailureDetails?.Properties != null) + { + return orchestrationException.FailureDetails.Properties; + } + + if (provider == null) + { + return null; + } + + return provider.GetExceptionProperties(exception); + } + } +} + + diff --git a/src/DurableTask.Core/FailureDetails.cs b/src/DurableTask.Core/FailureDetails.cs index 5dfd02ab9..4e2abaaaf 100644 --- a/src/DurableTask.Core/FailureDetails.cs +++ b/src/DurableTask.Core/FailureDetails.cs @@ -38,14 +38,29 @@ public class FailureDetails : IEquatable /// The exception stack trace. /// The inner cause of the failure. /// Whether the failure is non-retriable. + /// Additional properties associated with the failure. [JsonConstructor] - public FailureDetails(string errorType, string errorMessage, string? stackTrace, FailureDetails? innerFailure, bool isNonRetriable) + public FailureDetails(string errorType, string errorMessage, string? stackTrace, FailureDetails? innerFailure, bool isNonRetriable, IDictionary? properties = null) { this.ErrorType = errorType; this.ErrorMessage = errorMessage; this.StackTrace = stackTrace; this.InnerFailure = innerFailure; this.IsNonRetriable = isNonRetriable; + this.Properties = properties; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the error, which is expected to the the namespace-qualified name of the exception type. + /// The message associated with the error, which is expected to be the exception's property. + /// The exception stack trace. + /// The inner cause of the failure. + /// Whether the failure is non-retriable. + public FailureDetails(string errorType, string errorMessage, string? stackTrace, FailureDetails? innerFailure, bool isNonRetriable) + : this(errorType, errorMessage, stackTrace, innerFailure, isNonRetriable, properties:null) + { } /// @@ -54,7 +69,7 @@ public FailureDetails(string errorType, string errorMessage, string? stackTrace, /// The exception used to generate the failure details. /// The inner cause of the failure. public FailureDetails(Exception e, FailureDetails innerFailure) - : this(e.GetType().FullName, GetErrorMessage(e), e.StackTrace, innerFailure, false) + : this(e, innerFailure, properties: null) { } @@ -63,7 +78,28 @@ public FailureDetails(Exception e, FailureDetails innerFailure) /// /// The exception used to generate the failure details. public FailureDetails(Exception e) - : this(e.GetType().FullName, GetErrorMessage(e), e.StackTrace, FromException(e.InnerException), false) + : this(e, properties: null) + { + } + + /// + /// Initializes a new instance of the class from an exception object. + /// + /// The exception used to generate the failure details. + /// The exception properties to include in failure details. + public FailureDetails(Exception e, IDictionary? properties) + : this(e.GetType().FullName, GetErrorMessage(e), e.StackTrace, FromException(e.InnerException), false, properties) + { + } + + /// + /// Initializes a new instance of the class from an exception object. + /// + /// The exception used to generate the failure details. + /// The inner cause of the failure. + /// The exception properties to include in failure details. + public FailureDetails(Exception e, FailureDetails innerFailure, IDictionary? properties) + : this(e.GetType().FullName, GetErrorMessage(e), e.StackTrace, innerFailure, false, properties) { } @@ -74,6 +110,7 @@ public FailureDetails() { this.ErrorType = "None"; this.ErrorMessage = string.Empty; + this.Properties = null; } /// @@ -85,6 +122,16 @@ protected FailureDetails(SerializationInfo info, StreamingContext context) this.ErrorMessage = info.GetString(nameof(this.ErrorMessage)); this.StackTrace = info.GetString(nameof(this.StackTrace)); this.InnerFailure = (FailureDetails)info.GetValue(nameof(this.InnerFailure), typeof(FailureDetails)); + // Handle backward compatibility for Properties property - defaults to null + try + { + this.Properties = (IDictionary?)info.GetValue(nameof(this.Properties), typeof(IDictionary)); + } + catch (SerializationException) + { + // Default to null for backward compatibility + this.Properties = null; + } } /// @@ -112,6 +159,11 @@ protected FailureDetails(SerializationInfo info, StreamingContext context) /// public bool IsNonRetriable { get; } + /// + /// Gets additional properties associated with the failure. + /// + public IDictionary? Properties { get; } + /// /// Gets a debug-friendly description of the failure information. /// @@ -204,7 +256,13 @@ static string GetErrorMessage(Exception e) static FailureDetails? FromException(Exception? e) { - return e == null ? null : new FailureDetails(e); + return FromException(e, properties : null); } + + static FailureDetails? FromException(Exception? e, IDictionary? properties) + { + return e == null ? null : new FailureDetails(e, properties : properties); + } + } } diff --git a/src/DurableTask.Core/IExceptionPropertiesProvider.cs b/src/DurableTask.Core/IExceptionPropertiesProvider.cs new file mode 100644 index 000000000..e40bc1c3e --- /dev/null +++ b/src/DurableTask.Core/IExceptionPropertiesProvider.cs @@ -0,0 +1,33 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- +#nullable enable +namespace DurableTask.Core +{ + using System; + using System.Collections.Generic; + + /// + /// Interface for providing custom properties from exceptions that will be included in FailureDetails. + /// This interface is intended for implementation by the durabletask-dotnet layer, which will + /// convert customer implementations to this interface and register them with DurableTask.Core. + /// + public interface IExceptionPropertiesProvider + { + /// + /// Extracts custom properties from an exception. + /// + /// The exception to extract properties from. + /// A dictionary of custom properties to include in the FailureDetails, or null if no properties should be added. + IDictionary? GetExceptionProperties(Exception exception); + } +} diff --git a/src/DurableTask.Core/OrchestrationContext.cs b/src/DurableTask.Core/OrchestrationContext.cs index 97c6f6f6d..642fa0c4d 100644 --- a/src/DurableTask.Core/OrchestrationContext.cs +++ b/src/DurableTask.Core/OrchestrationContext.cs @@ -73,6 +73,11 @@ public abstract class OrchestrationContext /// internal ErrorPropagationMode ErrorPropagationMode { get; set; } + /// + /// Gets or sets the exception properties provider that extracts custom properties from exceptions + /// + internal IExceptionPropertiesProvider ExceptionPropertiesProvider { get;set; } + /// /// Information about backend entity support, or null if the configured backend does not support entities. /// diff --git a/src/DurableTask.Core/ReflectionBasedTaskActivity.cs b/src/DurableTask.Core/ReflectionBasedTaskActivity.cs index b935f8c1c..0ecd717b4 100644 --- a/src/DurableTask.Core/ReflectionBasedTaskActivity.cs +++ b/src/DurableTask.Core/ReflectionBasedTaskActivity.cs @@ -140,7 +140,8 @@ public override async Task RunAsync(TaskContext context, string input) } else { - failureDetails = new FailureDetails(exception); + var props = context.ExceptionPropertiesProvider.ExtractProperties(exception); + failureDetails = new FailureDetails(exception, props); } throw new TaskFailureException(exception.Message, exception, details) diff --git a/src/DurableTask.Core/TaskActivity.cs b/src/DurableTask.Core/TaskActivity.cs index b05f020eb..f872c9bda 100644 --- a/src/DurableTask.Core/TaskActivity.cs +++ b/src/DurableTask.Core/TaskActivity.cs @@ -13,12 +13,12 @@ namespace DurableTask.Core { - using System; - using System.Threading.Tasks; using DurableTask.Core.Common; using DurableTask.Core.Exceptions; using DurableTask.Core.Serializing; using Newtonsoft.Json.Linq; + using System; + using System.Threading.Tasks; /// /// Base class for TaskActivity. @@ -142,7 +142,16 @@ public override async Task RunAsync(TaskContext context, string input) } else { - failureDetails = new FailureDetails(e); + if(context != null) + { + var props = context.ExceptionPropertiesProvider.ExtractProperties(e); + failureDetails = new FailureDetails(e, props); + } + else + { + // Handle case for TaskContext is null. + failureDetails = new FailureDetails(e); + } } throw new TaskFailureException(e.Message, e, details) diff --git a/src/DurableTask.Core/TaskActivityDispatcher.cs b/src/DurableTask.Core/TaskActivityDispatcher.cs index bdafffdd5..6a0a4b45f 100644 --- a/src/DurableTask.Core/TaskActivityDispatcher.cs +++ b/src/DurableTask.Core/TaskActivityDispatcher.cs @@ -36,19 +36,31 @@ public sealed class TaskActivityDispatcher readonly DispatchMiddlewarePipeline dispatchPipeline; readonly LogHelper logHelper; readonly ErrorPropagationMode errorPropagationMode; + readonly IExceptionPropertiesProvider? exceptionPropertiesProvider; + /// + /// Initializes a new instance of the class with an exception properties provider. + /// + /// The orchestration service implementation + /// The object manager for activities + /// The dispatch middleware pipeline + /// The log helper + /// The error propagation mode + /// The exception properties provider for extracting custom properties from exceptions internal TaskActivityDispatcher( IOrchestrationService orchestrationService, INameVersionObjectManager objectManager, DispatchMiddlewarePipeline dispatchPipeline, LogHelper logHelper, - ErrorPropagationMode errorPropagationMode) + ErrorPropagationMode errorPropagationMode, + IExceptionPropertiesProvider? exceptionPropertiesProvider) { this.orchestrationService = orchestrationService ?? throw new ArgumentNullException(nameof(orchestrationService)); this.objectManager = objectManager ?? throw new ArgumentNullException(nameof(objectManager)); this.dispatchPipeline = dispatchPipeline ?? throw new ArgumentNullException(nameof(dispatchPipeline)); this.logHelper = logHelper; this.errorPropagationMode = errorPropagationMode; + this.exceptionPropertiesProvider = exceptionPropertiesProvider; this.dispatcher = new WorkItemDispatcher( "TaskActivityDispatcher", @@ -190,6 +202,7 @@ await this.dispatchPipeline.RunAsync(dispatchContext, async _ => scheduledEvent.Version, scheduledEvent.EventId); context.ErrorPropagationMode = this.errorPropagationMode; + context.ExceptionPropertiesProvider = this.exceptionPropertiesProvider; HistoryEvent? responseEvent; diff --git a/src/DurableTask.Core/TaskContext.cs b/src/DurableTask.Core/TaskContext.cs index d8152976c..4fe4322f0 100644 --- a/src/DurableTask.Core/TaskContext.cs +++ b/src/DurableTask.Core/TaskContext.cs @@ -62,5 +62,10 @@ public TaskContext(OrchestrationInstance orchestrationInstance, string name, str /// Gets or sets a value indicating how to propagate unhandled exception metadata. /// internal ErrorPropagationMode ErrorPropagationMode { get; set; } + + /// + /// Gets or sets the properties of exceptions with the provider. + /// + public IExceptionPropertiesProvider? ExceptionPropertiesProvider { get; set; } } -} \ No newline at end of file +} diff --git a/src/DurableTask.Core/TaskEntityDispatcher.cs b/src/DurableTask.Core/TaskEntityDispatcher.cs index a91ae97e2..4595aae2a 100644 --- a/src/DurableTask.Core/TaskEntityDispatcher.cs +++ b/src/DurableTask.Core/TaskEntityDispatcher.cs @@ -42,19 +42,31 @@ public class TaskEntityDispatcher readonly LogHelper logHelper; readonly ErrorPropagationMode errorPropagationMode; readonly TaskOrchestrationDispatcher.NonBlockingCountdownLock concurrentSessionLock; + readonly IExceptionPropertiesProvider exceptionPropertiesProvider; + /// + /// Initializes a new instance of the class with an exception properties provider. + /// + /// The orchestration service implementation + /// The object manager for entities + /// The dispatch middleware pipeline + /// The log helper + /// The error propagation mode + /// The exception properties provider for extracting custom properties from exceptions internal TaskEntityDispatcher( IOrchestrationService orchestrationService, INameVersionObjectManager entityObjectManager, DispatchMiddlewarePipeline entityDispatchPipeline, LogHelper logHelper, - ErrorPropagationMode errorPropagationMode) + ErrorPropagationMode errorPropagationMode, + IExceptionPropertiesProvider exceptionPropertiesProvider) { this.objectManager = entityObjectManager ?? throw new ArgumentNullException(nameof(entityObjectManager)); this.orchestrationService = orchestrationService ?? throw new ArgumentNullException(nameof(orchestrationService)); this.dispatchPipeline = entityDispatchPipeline ?? throw new ArgumentNullException(nameof(entityDispatchPipeline)); this.logHelper = logHelper ?? throw new ArgumentNullException(nameof(logHelper)); this.errorPropagationMode = errorPropagationMode; + this.exceptionPropertiesProvider = exceptionPropertiesProvider; this.entityOrchestrationService = (orchestrationService as IEntityOrchestrationService)!; this.entityBackendProperties = entityOrchestrationService.EntityBackendProperties; diff --git a/src/DurableTask.Core/TaskHubWorker.cs b/src/DurableTask.Core/TaskHubWorker.cs index 629453645..65dfbb47e 100644 --- a/src/DurableTask.Core/TaskHubWorker.cs +++ b/src/DurableTask.Core/TaskHubWorker.cs @@ -247,6 +247,12 @@ public TaskHubWorker( /// public ErrorPropagationMode ErrorPropagationMode { get; set; } + /// + /// Gets or sets the exception properties provider that extracts custom properties from exceptions + /// when creating FailureDetails objects. + /// + public IExceptionPropertiesProvider ExceptionPropertiesProvider { get; set; } + /// /// Adds a middleware delegate to the orchestration dispatch pipeline. /// @@ -296,13 +302,15 @@ public async Task StartAsync() this.orchestrationDispatchPipeline, this.logHelper, this.ErrorPropagationMode, - this.versioningSettings); + this.versioningSettings, + this.ExceptionPropertiesProvider); this.activityDispatcher = new TaskActivityDispatcher( this.orchestrationService, this.activityManager, this.activityDispatchPipeline, this.logHelper, - this.ErrorPropagationMode); + this.ErrorPropagationMode, + this.ExceptionPropertiesProvider); if (this.dispatchEntitiesSeparately) { @@ -311,7 +319,8 @@ public async Task StartAsync() this.entityManager, this.entityDispatchPipeline, this.logHelper, - this.ErrorPropagationMode); + this.ErrorPropagationMode, + this.ExceptionPropertiesProvider); } await this.orchestrationService.StartAsync(); diff --git a/src/DurableTask.Core/TaskOrchestration.cs b/src/DurableTask.Core/TaskOrchestration.cs index c198c0855..d449d3584 100644 --- a/src/DurableTask.Core/TaskOrchestration.cs +++ b/src/DurableTask.Core/TaskOrchestration.cs @@ -105,7 +105,8 @@ public override async Task Execute(OrchestrationContext context, string } else { - failureDetails = new FailureDetails(e); + var props = context.ExceptionPropertiesProvider.ExtractProperties(e); + failureDetails = new FailureDetails(e, props); } throw new OrchestrationFailureException(e.Message, details) diff --git a/src/DurableTask.Core/TaskOrchestrationContext.cs b/src/DurableTask.Core/TaskOrchestrationContext.cs index b12cb1b08..87fc435b3 100644 --- a/src/DurableTask.Core/TaskOrchestrationContext.cs +++ b/src/DurableTask.Core/TaskOrchestrationContext.cs @@ -51,7 +51,8 @@ public TaskOrchestrationContext( OrchestrationInstance orchestrationInstance, TaskScheduler taskScheduler, TaskOrchestrationEntityParameters entityParameters = null, - ErrorPropagationMode errorPropagationMode = ErrorPropagationMode.SerializeExceptions) + ErrorPropagationMode errorPropagationMode = ErrorPropagationMode.SerializeExceptions, + IExceptionPropertiesProvider exceptionPropertiesProvider = null) { Utils.UnusedParameter(taskScheduler); @@ -66,6 +67,7 @@ public TaskOrchestrationContext( ErrorPropagationMode = errorPropagationMode; this.eventsWhileSuspended = new Queue(); this.suspendedActionsMap = new SortedDictionary(); + this.ExceptionPropertiesProvider = exceptionPropertiesProvider; } public IEnumerable OrchestratorActions => this.orchestratorActionsMap.Values; diff --git a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs index 44adbbbd7..c19e5bd80 100644 --- a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs +++ b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs @@ -49,14 +49,26 @@ public class TaskOrchestrationDispatcher readonly EntityBackendProperties? entityBackendProperties; readonly TaskOrchestrationEntityParameters? entityParameters; readonly VersioningSettings? versioningSettings; + readonly IExceptionPropertiesProvider? exceptionPropertiesProvider; + /// + /// Initializes a new instance of the class with an exception properties provider. + /// + /// The orchestration service implementation + /// The object manager for orchestrations + /// The dispatch middleware pipeline + /// The log helper + /// The error propagation mode + /// The versioning settings + /// The exception properties provider for extracting custom properties from exceptions internal TaskOrchestrationDispatcher( IOrchestrationService orchestrationService, INameVersionObjectManager objectManager, DispatchMiddlewarePipeline dispatchPipeline, LogHelper logHelper, ErrorPropagationMode errorPropagationMode, - VersioningSettings versioningSettings) + VersioningSettings versioningSettings, + IExceptionPropertiesProvider? exceptionPropertiesProvider) { this.objectManager = objectManager ?? throw new ArgumentNullException(nameof(objectManager)); this.orchestrationService = orchestrationService ?? throw new ArgumentNullException(nameof(orchestrationService)); @@ -67,6 +79,7 @@ internal TaskOrchestrationDispatcher( this.entityBackendProperties = this.entityOrchestrationService?.EntityBackendProperties; this.entityParameters = TaskOrchestrationEntityParameters.FromEntityBackendProperties(this.entityBackendProperties); this.versioningSettings = versioningSettings; + this.exceptionPropertiesProvider = exceptionPropertiesProvider; this.dispatcher = new WorkItemDispatcher( "TaskOrchestrationDispatcher", @@ -757,7 +770,8 @@ await this.dispatchPipeline.RunAsync(dispatchContext, _ => taskOrchestration, this.orchestrationService.EventBehaviourForContinueAsNew, this.entityParameters, - this.errorPropagationMode); + this.errorPropagationMode, + this.exceptionPropertiesProvider); OrchestratorExecutionResult resultFromOrchestrator = executor.Execute(); dispatchContext.SetProperty(resultFromOrchestrator); diff --git a/src/DurableTask.Core/TaskOrchestrationExecutor.cs b/src/DurableTask.Core/TaskOrchestrationExecutor.cs index e3dc6fc49..540851e50 100644 --- a/src/DurableTask.Core/TaskOrchestrationExecutor.cs +++ b/src/DurableTask.Core/TaskOrchestrationExecutor.cs @@ -35,6 +35,7 @@ public class TaskOrchestrationExecutor readonly OrchestrationRuntimeState orchestrationRuntimeState; readonly TaskOrchestration taskOrchestration; readonly bool skipCarryOverEvents; + readonly IExceptionPropertiesProvider? exceptionPropertiesProvider; Task? result; /// @@ -51,16 +52,8 @@ public TaskOrchestrationExecutor( BehaviorOnContinueAsNew eventBehaviourForContinueAsNew, TaskOrchestrationEntityParameters? entityParameters, ErrorPropagationMode errorPropagationMode = ErrorPropagationMode.SerializeExceptions) + : this(orchestrationRuntimeState, taskOrchestration, eventBehaviourForContinueAsNew, entityParameters, errorPropagationMode, null) { - this.decisionScheduler = new SynchronousTaskScheduler(); - this.context = new TaskOrchestrationContext( - orchestrationRuntimeState.OrchestrationInstance, - this.decisionScheduler, - entityParameters, - errorPropagationMode); - this.orchestrationRuntimeState = orchestrationRuntimeState; - this.taskOrchestration = taskOrchestration; - this.skipCarryOverEvents = eventBehaviourForContinueAsNew == BehaviorOnContinueAsNew.Ignore; } /// @@ -76,8 +69,38 @@ public TaskOrchestrationExecutor( TaskOrchestration taskOrchestration, BehaviorOnContinueAsNew eventBehaviourForContinueAsNew, ErrorPropagationMode errorPropagationMode = ErrorPropagationMode.SerializeExceptions) - : this(orchestrationRuntimeState, taskOrchestration, eventBehaviourForContinueAsNew, entityParameters: null, errorPropagationMode) + : this(orchestrationRuntimeState, taskOrchestration, eventBehaviourForContinueAsNew, entityParameters: null, errorPropagationMode, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + /// + /// + public TaskOrchestrationExecutor( + OrchestrationRuntimeState orchestrationRuntimeState, + TaskOrchestration taskOrchestration, + BehaviorOnContinueAsNew eventBehaviourForContinueAsNew, + TaskOrchestrationEntityParameters? entityParameters, + ErrorPropagationMode errorPropagationMode, + IExceptionPropertiesProvider? exceptionPropertiesProvider) { + this.decisionScheduler = new SynchronousTaskScheduler(); + this.context = new TaskOrchestrationContext( + orchestrationRuntimeState.OrchestrationInstance, + this.decisionScheduler, + entityParameters, + errorPropagationMode, + exceptionPropertiesProvider); + this.orchestrationRuntimeState = orchestrationRuntimeState; + this.taskOrchestration = taskOrchestration; + this.skipCarryOverEvents = eventBehaviourForContinueAsNew == BehaviorOnContinueAsNew.Ignore; + this.exceptionPropertiesProvider = exceptionPropertiesProvider; } /// diff --git a/test/DurableTask.Core.Tests/ExceptionHandlingIntegrationTests.cs b/test/DurableTask.Core.Tests/ExceptionHandlingIntegrationTests.cs index 7fb2ef2f5..a480279d5 100644 --- a/test/DurableTask.Core.Tests/ExceptionHandlingIntegrationTests.cs +++ b/test/DurableTask.Core.Tests/ExceptionHandlingIntegrationTests.cs @@ -14,6 +14,7 @@ namespace DurableTask.Core.Tests { using System; + using System.Collections.Generic; using System.Diagnostics; using System.Runtime.Serialization; using System.Threading.Tasks; @@ -262,6 +263,167 @@ protected override Task ExecuteAsync(TaskContext context, string input) } } + [TestMethod] + // Test that when a provider is set, properties are extracted and stored in FailureDetails.Properties. + public async Task ExceptionPropertiesProvider_ExtractsCustomProperties() + { + // Set up a provider that extracts custom properties using the new TaskHubWorker property + this.worker.ExceptionPropertiesProvider = new TestExceptionPropertiesProvider(); + this.worker.ErrorPropagationMode = ErrorPropagationMode.UseFailureDetails; + + try + { + await this.worker + .AddTaskOrchestrations(typeof(ThrowCustomExceptionOrchestration)) + .AddTaskActivities(typeof(ThrowCustomBusinessExceptionActivity)) + .StartAsync(); + + var instance = await this.client.CreateOrchestrationInstanceAsync(typeof(ThrowCustomExceptionOrchestration), "test-input"); + var result = await this.client.WaitForOrchestrationAsync(instance, DefaultTimeout); + + // Check that custom properties were extracted + Assert.AreEqual(OrchestrationStatus.Failed, result.OrchestrationStatus); + Assert.IsNotNull(result.FailureDetails); + Assert.IsNotNull(result.FailureDetails.Properties); + + // Check the properties match the exception. + Assert.AreEqual("CustomBusinessException", result.FailureDetails.Properties["ExceptionTypeName"]); + Assert.AreEqual("user123", result.FailureDetails.Properties["UserId"]); + Assert.AreEqual("OrderProcessing", result.FailureDetails.Properties["BusinessContext"]); + Assert.IsTrue(result.FailureDetails.Properties.ContainsKey("Timestamp")); + + // Check that null values are properly handled + Assert.IsTrue(result.FailureDetails.Properties.ContainsKey("TestNullObject"), "TestNullObject key should be present"); + Assert.IsNull(result.FailureDetails.Properties["TestNullObject"], "TestNullObject should be null"); + + Assert.IsTrue(result.FailureDetails.Properties.ContainsKey("DirectNullValue"), "DirectNullValue key should be present"); + Assert.IsNull(result.FailureDetails.Properties["DirectNullValue"], "DirectNullValue should be null"); + + // Verify non-null values still work + Assert.IsTrue(result.FailureDetails.Properties.ContainsKey("EmptyString"), "EmptyString key should be present"); + Assert.AreEqual(string.Empty, result.FailureDetails.Properties["EmptyString"], "EmptyString should be empty string, not null"); + } + finally + { + await this.worker.StopAsync(); + } + } + + [TestMethod] + // Test that when no provider is provided by default, property at FailureDetails should be null. + public async Task ExceptionPropertiesProvider_NullProvider_NoProperties() + { + try + { + this.worker.ErrorPropagationMode = ErrorPropagationMode.UseFailureDetails; + await this.worker + .AddTaskOrchestrations(typeof(ThrowInvalidOperationExceptionOrchestration)) + .AddTaskActivities(typeof(ThrowInvalidOperationExceptionActivity)) + .StartAsync(); + + var instance = await this.client.CreateOrchestrationInstanceAsync(typeof(ThrowInvalidOperationExceptionOrchestration), "test-input"); + var result = await this.client.WaitForOrchestrationAsync(instance, DefaultTimeout); + + // Properties should be null when no provider + Assert.AreEqual(OrchestrationStatus.Failed, result.OrchestrationStatus); + Assert.IsNotNull(result.FailureDetails); + Assert.IsNull(result.FailureDetails.Properties); + } + finally + { + await this.worker.StopAsync(); + } + } + + class ThrowCustomExceptionOrchestration : TaskOrchestration + { + public override async Task RunTask(OrchestrationContext context, string input) + { + await context.ScheduleTask(typeof(ThrowCustomBusinessExceptionActivity), input); + return "This should never be reached"; + } + } + + class ThrowCustomBusinessExceptionActivity : TaskActivity + { + protected override string Execute(TaskContext context, string input) + { + throw new CustomBusinessException("Payment processing failed", "user123", "OrderProcessing"); + } + } + + class ThrowInvalidOperationExceptionOrchestration : TaskOrchestration + { + public override async Task RunTask(OrchestrationContext context, string input) + { + await context.ScheduleTask(typeof(ThrowInvalidOperationExceptionActivity), input); + return "This should never be reached"; + } + } + + class ThrowInvalidOperationExceptionActivity : TaskActivity + { + protected override string Execute(TaskContext context, string input) + { + throw new InvalidOperationException("This is a test exception"); + } + } + + // Test exception with custom properties + [Serializable] + class CustomBusinessException : Exception + { + public string UserId { get; } + public string BusinessContext { get; } + public string? TestNullObject { get; } + + public CustomBusinessException(string message, string userId, string businessContext) + : base(message) + { + UserId = userId; + BusinessContext = businessContext; + TestNullObject = null; // Explicitly set to null for testing + } + + protected CustomBusinessException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + UserId = info.GetString(nameof(UserId)) ?? string.Empty; + BusinessContext = info.GetString(nameof(BusinessContext)) ?? string.Empty; + TestNullObject = info.GetString(nameof(TestNullObject)); // This will be null + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + info.AddValue(nameof(UserId), UserId); + info.AddValue(nameof(BusinessContext), BusinessContext); + info.AddValue(nameof(TestNullObject), TestNullObject); + } + } + + // Test provider that includes null values in different ways + class TestExceptionPropertiesProvider : IExceptionPropertiesProvider + { + public IDictionary? GetExceptionProperties(Exception exception) + { + return exception switch + { + CustomBusinessException businessEx => new Dictionary + { + ["ExceptionTypeName"] = nameof(CustomBusinessException), + ["UserId"] = businessEx.UserId, + ["BusinessContext"] = businessEx.BusinessContext, + ["Timestamp"] = DateTime.UtcNow, + ["TestNullObject"] = businessEx.TestNullObject, // This comes from the exception property (null) + ["DirectNullValue"] = null, // This is directly set to null + ["EmptyString"] = string.Empty // Non-null value for comparison + }, + _ => null // No custom properties for other exceptions + }; + } + } + [Serializable] class CustomException : Exception { diff --git a/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs b/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs index 3e5032156..6128c0465 100644 --- a/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs +++ b/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs @@ -30,7 +30,7 @@ private TaskEntityDispatcher GetTaskEntityDispatcher() var logger = new LogHelper(loggerFactory?.CreateLogger("DurableTask.Core")); TaskEntityDispatcher dispatcher = new TaskEntityDispatcher( - service, entityManager, entityMiddleware, logger, ErrorPropagationMode.UseFailureDetails); + service, entityManager, entityMiddleware, logger, ErrorPropagationMode.UseFailureDetails, null); return dispatcher; }