diff --git a/src/Abstractions/DurableTaskCoreExceptionsExtensions.cs b/src/Abstractions/DurableTaskCoreExceptionsExtensions.cs index 775be485..4c68d465 100644 --- a/src/Abstractions/DurableTaskCoreExceptionsExtensions.cs +++ b/src/Abstractions/DurableTaskCoreExceptionsExtensions.cs @@ -18,7 +18,7 @@ static class DurableTaskCoreExceptionsExtensions /// FailureDetails; otherwise, null is returned. /// internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.Exceptions.TaskFailedException taskFailedException) - => taskFailedException.FailureDetails.ToTaskFailureDetails(); + => taskFailedException.FailureDetails.ToTaskFailureDetails(taskFailedException); /// /// Converts to a instance. @@ -29,27 +29,35 @@ static class DurableTaskCoreExceptionsExtensions /// A instance if contains /// FailureDetails; otherwise, null is returned. /// - internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.Exceptions.SubOrchestrationFailedException subOrchestrationFailedException) => subOrchestrationFailedException.FailureDetails.ToTaskFailureDetails(); + internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.Exceptions.SubOrchestrationFailedException subOrchestrationFailedException) => subOrchestrationFailedException.FailureDetails.ToTaskFailureDetails(subOrchestrationFailedException); /// /// Converts to a instance. /// /// instance. + /// Optional original exception that caused the failure. /// /// A instance if is not null; otherwise, null. /// - internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.FailureDetails? failureDetails) + internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.FailureDetails? failureDetails, Exception? originalException = null) { if (failureDetails is null) { return null; } + // The originalException passed in is the Core exception wrapper (TaskFailedException or SubOrchestrationFailedException) + // Its InnerException is the actual user exception that we want to expose + Exception? actualException = originalException?.InnerException; + return new TaskFailureDetails( failureDetails.ErrorType, failureDetails.ErrorMessage, failureDetails.StackTrace, - failureDetails.InnerFailure?.ToTaskFailureDetails(), - failureDetails.Properties); + failureDetails.InnerFailure?.ToTaskFailureDetails(actualException?.InnerException), + failureDetails.Properties) + { + OriginalException = actualException, + }; } } diff --git a/src/Abstractions/TaskFailureDetails.cs b/src/Abstractions/TaskFailureDetails.cs index 271226f3..21fd309e 100644 --- a/src/Abstractions/TaskFailureDetails.cs +++ b/src/Abstractions/TaskFailureDetails.cs @@ -20,6 +20,18 @@ public record TaskFailureDetails(string ErrorType, string ErrorMessage, string? { Type? loadedExceptionType; + /// + /// Gets the original exception that caused the task failure, if available. + /// + /// + /// This property provides access to the original exception object, allowing users to inspect + /// exception-specific properties and make fine-grained retry decisions. This is particularly + /// useful for scenarios like checking SQL transient errors or HTTP API status codes. + /// Note: This property may be null if the failure details were deserialized from storage or + /// received from a remote source, as exceptions are not serialized. + /// + public Exception? OriginalException { get; init; } + /// /// Gets a debug-friendly description of the failure information. /// @@ -164,16 +176,22 @@ internal CoreFailureDetails ToCoreFailureDetails() coreEx.FailureDetails?.ErrorMessage ?? "(unknown)", coreEx.FailureDetails?.StackTrace, FromCoreFailureDetailsRecursive(coreEx.FailureDetails?.InnerFailure) ?? FromExceptionRecursive(coreEx.InnerException), - coreEx.FailureDetails?.Properties); + coreEx.FailureDetails?.Properties) + { + OriginalException = exception, + }; } - // might need to udpate this later + // might need to update this later return new TaskFailureDetails( exception.GetType().ToString(), exception.Message, exception.StackTrace, FromExceptionRecursive(exception.InnerException), - null); + null) + { + OriginalException = exception, + }; } static TaskFailureDetails? FromCoreFailureDetailsRecursive(CoreFailureDetails? coreFailureDetails) diff --git a/test/Abstractions.Tests/TaskFailureDetailsTests.cs b/test/Abstractions.Tests/TaskFailureDetailsTests.cs new file mode 100644 index 00000000..25f2d357 --- /dev/null +++ b/test/Abstractions.Tests/TaskFailureDetailsTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.DurableTask.Tests; + +/// +/// Tests for . +/// +public class TaskFailureDetailsTests +{ + [Fact] + public void FromException_CapturesOriginalException() + { + Exception originalException = new InvalidOperationException("Test error"); + TaskFailureDetails details = TaskFailureDetails.FromException(originalException); + + Assert.NotNull(details); + Assert.Same(originalException, details.OriginalException); + Assert.Equal(typeof(InvalidOperationException).FullName, details.ErrorType); + Assert.Equal("Test error", details.ErrorMessage); + } + + [Fact] + public void FromException_CapturesOriginalExceptionWithInnerException() + { + Exception innerException = new ArgumentException("Inner error"); + Exception outerException = new InvalidOperationException("Outer error", innerException); + TaskFailureDetails details = TaskFailureDetails.FromException(outerException); + + Assert.NotNull(details); + Assert.Same(outerException, details.OriginalException); + Assert.Equal(typeof(InvalidOperationException).FullName, details.ErrorType); + Assert.Equal("Outer error", details.ErrorMessage); + + // Check inner failure details + Assert.NotNull(details.InnerFailure); + Assert.Same(innerException, details.InnerFailure.OriginalException); + Assert.Equal(typeof(ArgumentException).FullName, details.InnerFailure.ErrorType); + Assert.Equal("Inner error", details.InnerFailure.ErrorMessage); + } + + [Fact] + public void OriginalException_AllowsAccessToCustomExceptionProperties() + { + CustomException customException = new CustomException(statusCode: 404, message: "Not Found"); + TaskFailureDetails details = TaskFailureDetails.FromException(customException); + + Assert.NotNull(details.OriginalException); + CustomException? retrievedException = details.OriginalException as CustomException; + Assert.NotNull(retrievedException); + Assert.Equal(404, retrievedException.StatusCode); + Assert.Equal("Not Found", retrievedException.Message); + } + + [Fact] + public void OriginalException_AllowsUseWithTransientErrorDetector() + { + // Simulate a SQL transient error scenario + SqlException sqlException = new SqlException(isTransient: true); + TaskFailureDetails details = TaskFailureDetails.FromException(sqlException); + + Assert.NotNull(details.OriginalException); + SqlException? retrievedException = details.OriginalException as SqlException; + Assert.NotNull(retrievedException); + Assert.True(retrievedException.IsTransient); + } + + [Fact] + public void OriginalException_IsNullForDeserializedFailureDetails() + { + // When creating TaskFailureDetails directly (simulating deserialization scenario) + TaskFailureDetails details = new TaskFailureDetails( + ErrorType: typeof(InvalidOperationException).FullName!, + ErrorMessage: "Test error", + StackTrace: null, + InnerFailure: null, + Properties: null); + + Assert.Null(details.OriginalException); + } + + [Fact] + public void IsCausedBy_WorksWithOriginalException() + { + Exception exception = new InvalidOperationException("Test error"); + TaskFailureDetails details = TaskFailureDetails.FromException(exception); + + Assert.True(details.IsCausedBy()); + Assert.True(details.IsCausedBy()); + Assert.False(details.IsCausedBy()); + } + + /// + /// Custom exception to test access to specific properties. + /// + private class CustomException : Exception + { + public CustomException(int statusCode, string message) + : base(message) + { + this.StatusCode = statusCode; + } + + public int StatusCode { get; } + } + + /// + /// Mock SQL exception to test transient error scenarios. + /// + private class SqlException : Exception + { + public SqlException(bool isTransient) + : base("SQL error") + { + this.IsTransient = isTransient; + } + + public bool IsTransient { get; } + } +} diff --git a/test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs b/test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs index 423dbb2f..8c123bb5 100644 --- a/test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs +++ b/test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs @@ -866,6 +866,75 @@ void ActivityImpl(TaskActivityContext ctx) => Assert.True(activityFailure.IsCausedBy()); } + /// + /// Tests that OriginalException property provides access to exception details when available. + /// NOTE: Due to serialization over gRPC, OriginalException may be null in distributed scenarios. + /// In such cases, users can fall back to using TaskFailureDetails.IsCausedBy() for type checking. + /// + [Fact] + public async Task RetryWithOriginalExceptionAccessFallback() + { + string errorMessage = "API call failed"; + int actualNumberOfAttempts = 0; + bool originalExceptionWasChecked = false; + + RetryPolicy retryPolicy = new( + maxNumberOfAttempts: 3, + firstRetryInterval: TimeSpan.FromMilliseconds(1)) + { + HandleFailure = taskFailureDetails => + { + originalExceptionWasChecked = true; + + // In distributed scenarios (like gRPC), OriginalException may be null due to serialization. + // In such cases, use IsCausedBy for type-based retry decisions. + if (taskFailureDetails.OriginalException != null) + { + // When OriginalException is available (same-process scenarios), + // users can access specific exception properties + if (taskFailureDetails.OriginalException is ApiException apiException) + { + // Example: Only retry on specific HTTP status codes + return apiException.StatusCode == 404; + } + } + + // Fallback to type-based checking when OriginalException is not available + return taskFailureDetails.IsCausedBy(); + }, + }; + + TaskOptions taskOptions = TaskOptions.FromRetryPolicy(retryPolicy); + + TaskName orchestratorName = "OrchestrationWithApiException"; + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + tasks.AddOrchestratorFunc(orchestratorName, async ctx => + { + await ctx.CallActivityAsync("ApiActivity", options: taskOptions); + }) + .AddActivityFunc("ApiActivity", (TaskActivityContext context) => + { + actualNumberOfAttempts++; + if (actualNumberOfAttempts < 3) + { + throw new ApiException(404, errorMessage); + } + })); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(instanceId, metadata.InstanceId); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal(3, actualNumberOfAttempts); + Assert.True(originalExceptionWasChecked, "HandleFailure should have been called"); + } + [Serializable] class CustomException : Exception { @@ -887,6 +956,35 @@ protected CustomException(SerializationInfo info, StreamingContext context) #pragma warning restore SYSLIB0051 } + /// + /// Custom API exception with status code to test the use case from the issue. + /// + [Serializable] + class ApiException : Exception + { + public ApiException(int statusCode, string message) + : base(message) + { + this.StatusCode = statusCode; + } + + public int StatusCode { get; } + +#pragma warning disable SYSLIB0051 + protected ApiException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + this.StatusCode = info.GetInt32(nameof(this.StatusCode)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + info.AddValue(nameof(this.StatusCode), this.StatusCode); + } +#pragma warning restore SYSLIB0051 + } + /// /// A custom exception with diverse property types for comprehensive testing of exception properties. ///