Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/Abstractions/DurableTaskCoreExceptionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ static class DurableTaskCoreExceptionsExtensions
/// FailureDetails; otherwise, null is returned.
/// </returns>
internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.Exceptions.TaskFailedException taskFailedException)
=> taskFailedException.FailureDetails.ToTaskFailureDetails();
=> taskFailedException.FailureDetails.ToTaskFailureDetails(taskFailedException);

/// <summary>
/// Converts <paramref name="subOrchestrationFailedException"/> to a <see cref="TaskFailureDetails"/> instance.
Expand All @@ -29,27 +29,35 @@ static class DurableTaskCoreExceptionsExtensions
/// A <see cref="TaskFailureDetails"/> instance if <paramref name="subOrchestrationFailedException"/> contains
/// FailureDetails; otherwise, null is returned.
/// </returns>
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);

/// <summary>
/// Converts <paramref name="failureDetails"/> to a <see cref="TaskFailureDetails"/> instance.
/// </summary>
/// <param name="failureDetails"><see cref="global::DurableTask.Core.FailureDetails"/> instance.</param>
/// <param name="originalException">Optional original exception that caused the failure.</param>
/// <returns>
/// A <see cref="TaskFailureDetails"/> instance if <paramref name="failureDetails"/> is not null; otherwise, null.
/// </returns>
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,
};
}
}
24 changes: 21 additions & 3 deletions src/Abstractions/TaskFailureDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ public record TaskFailureDetails(string ErrorType, string ErrorMessage, string?
{
Type? loadedExceptionType;

/// <summary>
/// Gets the original exception that caused the task failure, if available.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public Exception? OriginalException { get; init; }

/// <summary>
/// Gets a debug-friendly description of the failure information.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions test/Abstractions.Tests/TaskFailureDetailsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Xunit;

namespace Microsoft.DurableTask.Tests;

/// <summary>
/// Tests for <see cref="TaskFailureDetails"/>.
/// </summary>
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<InvalidOperationException>());
Assert.True(details.IsCausedBy<Exception>());
Assert.False(details.IsCausedBy<ArgumentException>());
}

/// <summary>
/// Custom exception to test access to specific properties.
/// </summary>
private class CustomException : Exception
{
public CustomException(int statusCode, string message)
: base(message)
{
this.StatusCode = statusCode;
}

public int StatusCode { get; }
}

/// <summary>
/// Mock SQL exception to test transient error scenarios.
/// </summary>
private class SqlException : Exception
{
public SqlException(bool isTransient)
: base("SQL error")
{
this.IsTransient = isTransient;
}

public bool IsTransient { get; }
}
}
98 changes: 98 additions & 0 deletions test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,75 @@ void ActivityImpl(TaskActivityContext ctx) =>
Assert.True(activityFailure.IsCausedBy<ArgumentOutOfRangeException>());
}

/// <summary>
/// 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.
/// </summary>
[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;
}
}
Comment on lines +891 to +900

// Fallback to type-based checking when OriginalException is not available
return taskFailureDetails.IsCausedBy<ApiException>();
},
};

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
{
Expand All @@ -887,6 +956,35 @@ protected CustomException(SerializationInfo info, StreamingContext context)
#pragma warning restore SYSLIB0051
}

/// <summary>
/// Custom API exception with status code to test the use case from the issue.
/// </summary>
[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
}

/// <summary>
/// A custom exception with diverse property types for comprehensive testing of exception properties.
/// </summary>
Expand Down
Loading