Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RetryPolicy.Handle Property to Allow for Exception Filtering on Retries #314

Merged
merged 11 commits into from
Jun 3, 2024
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.3.0 (Unreleased)

### Microsoft.DurableTask.Abstractions

- Add `RetryPolicy.Handle` property to allow for exception filtering on retries ([#314](https://github.com/microsoft/durabletask-dotnet/pull/314))

## v1.2.4

- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.1`
Expand Down
54 changes: 54 additions & 0 deletions src/Abstractions/DurableTaskCoreExceptionsExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.DurableTask.Abstractions;

/// <summary>
/// Extension methods realated to the global::DurableTask.Core namespace items.
/// </summary>
static class DurableTaskCoreExceptionsExtensions
{
/// <summary>
/// Converts <paramref name="taskFailedException"/> to a <see cref="TaskFailureDetails"/> instance.
/// If <paramref name="taskFailedException"/> does not contain FailureDetails, null shall be returned.
/// </summary>
/// <param name="taskFailedException"><see cref="global::DurableTask.Core.Exceptions.TaskFailedException"/> instance.</param>
/// <returns>
/// A <see cref="TaskFailureDetails"/> instance if <paramref name="taskFailedException"/> contains
/// FailureDetails; otherwise, null is returned.
/// </returns>
internal static TaskFailureDetails? ToTaskFailureDetails(this global::DurableTask.Core.Exceptions.TaskFailedException taskFailedException)
=> taskFailedException.FailureDetails.ToTaskFailureDetails();

/// <summary>
/// Converts <paramref name="subOrchestrationFailedException"/> to a <see cref="TaskFailureDetails"/> instance.
/// If <paramref name="subOrchestrationFailedException"/> does not contain FailureDetails, null shall be returned.
/// </summary>
/// <param name="subOrchestrationFailedException"><see cref="global::DurableTask.Core.Exceptions.SubOrchestrationFailedException"/> instance.</param>
/// <returns>
/// 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();

/// <summary>
/// Converts <paramref name="failureDetails"/> to a <see cref="TaskFailureDetails"/> instance.
/// </summary>
/// <param name="failureDetails"><see cref="global::DurableTask.Core.FailureDetails"/> instance.</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)
{
if (failureDetails is null)
{
return null;
}

return new TaskFailureDetails(
failureDetails.ErrorType,
failureDetails.ErrorMessage,
failureDetails.StackTrace,
failureDetails.InnerFailure?.ToTaskFailureDetails());
}
}
53 changes: 52 additions & 1 deletion src/Abstractions/RetryPolicy.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.ComponentModel;
using Microsoft.DurableTask.Abstractions;

namespace Microsoft.DurableTask;

/// <summary>
Expand Down Expand Up @@ -86,6 +89,7 @@ public RetryPolicy(
this.BackoffCoefficient = backoffCoefficient;
this.MaxRetryInterval = maxRetryInterval ?? TimeSpan.FromHours(1);
this.RetryTimeout = retryTimeout ?? Timeout.InfiniteTimeSpan;
this.Handle = (ex) => true;
}

/// <summary>
Expand Down Expand Up @@ -123,11 +127,58 @@ public RetryPolicy(
/// </value>
public TimeSpan RetryTimeout { get; }

/// <summary>
/// Gets a delegate to call on exception to determine if retries should proceed.
tomseida marked this conversation as resolved.
Show resolved Hide resolved
/// For internal usage, use <see cref="HandleFailure" /> for setting this delegate.
/// </summary>
/// <value>
/// Defaults delegate that always returns true (i.e., all exceptions are retried).
/// </value>
[EditorBrowsable(EditorBrowsableState.Never)]
public Func<Exception, bool> Handle { get; private init; }
jviau marked this conversation as resolved.
Show resolved Hide resolved

#pragma warning disable SA1623 // Property summary documentation should match accessors
/// <summary>
/// This functionality is not implemented. Will be removed in the future. Use TaskOptions.FromRetryHandler instead.
/// </summary>
[Obsolete("This functionality is not implemented. Will be removed in the future. Use TaskOptions.FromRetryHandler instead.")]
[Obsolete("This functionality is not implemented. Will be removed in the future. Use TaskOptions.FromRetryHandler or HandleFailure instead.")]
public Func<Exception, Task<bool>>? HandleAsync { get; set; }
#pragma warning restore SA1623 // Property summary documentation should match accessors

/// <summary>
/// Optional delegate to invoke on exceptions to determine if retries should proceed. The delegate shall receive a
/// <see cref="TaskFailureDetails"/> instance and returns bool value where true means that a retry
/// is attempted and false means no retry is attempted. Time and attempt count constraints
/// take precedence over this delegate for determining if retry attempts are performed.
/// </summary>
/// <exception cref="InvalidOperationException">
/// This represents a defect in this library in that it should always receive either
/// <see cref="global::DurableTask.Core.Exceptions.TaskFailedException"/> or
/// <see cref="global::DurableTask.Core.Exceptions.SubOrchestrationFailedException"/>.
/// </exception>
public Func<TaskFailureDetails, bool> HandleFailure
{
init
{
this.Handle = ex =>
{
TaskFailureDetails? taskFailureDetails = null;
if (ex is global::DurableTask.Core.Exceptions.TaskFailedException taskFailedException)
{
taskFailureDetails = taskFailedException.ToTaskFailureDetails();
}
else if (ex is global::DurableTask.Core.Exceptions.SubOrchestrationFailedException subOrchestrationFailedException)
{
taskFailureDetails = subOrchestrationFailedException.ToTaskFailureDetails();
}

if (taskFailureDetails is null)
{
throw new InvalidOperationException("Unable to create TaskFailureDetails since TaskFailedException nor SubOrchestrationFailedException was not received.");
}

return value.Invoke(taskFailureDetails);
};
}
}
}
1 change: 1 addition & 0 deletions src/Shared/Core/RetryPolicyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ static TimeSpan ConvertInfiniteTimeSpans(TimeSpan timeout) =>
BackoffCoefficient = retry.BackoffCoefficient,
MaxRetryInterval = ConvertInfiniteTimeSpans(retry.MaxRetryInterval),
RetryTimeout = ConvertInfiniteTimeSpans(retry.RetryTimeout),
Handle = retry.Handle,
};
}
}
134 changes: 134 additions & 0 deletions test/Grpc.IntegrationTests/OrchestrationErrorHandling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,68 @@ public async Task RetryActivityFailuresCustomLogic(int expectedNumberOfAttempts,
Assert.Equal(expectedNumberOfAttempts, actualNumberOfAttempts);
}

[Theory]
[InlineData(10, typeof(ApplicationException), false, int.MaxValue, 2, 1, OrchestrationRuntimeStatus.Failed)] // 1 attempt since retry timeout expired.
[InlineData(2, typeof(ApplicationException), false, int.MaxValue, null, 1, OrchestrationRuntimeStatus.Failed)] // 1 attempt since handler specifies no retry.
[InlineData(2, typeof(CustomException),true, int.MaxValue, null, 2, OrchestrationRuntimeStatus.Failed)] // 2 attempts, custom exception type
[InlineData(10, typeof(XunitException),true, 4, null, 5, OrchestrationRuntimeStatus.Completed)] // 10 attempts, 3rd party exception type
public async Task RetryActivityFailuresCustomLogicAndPolicy(
int maxNumberOfAttempts,
Type exceptionType,
bool retryException,
int exceptionCount,
int? retryTimeout,
int expectedNumberOfAttempts,
OrchestrationRuntimeStatus expRuntimeStatus)
{
string errorMessage = "Kah-BOOOOOM!!!"; // Use an obviously fake error message to avoid confusion when debugging

int actualNumberOfAttempts = 0;
int retryHandlerCalls = 0;
RetryPolicy retryPolicy = new(
maxNumberOfAttempts,
firstRetryInterval: TimeSpan.FromMilliseconds(1),
backoffCoefficient: 2,
retryTimeout: retryTimeout.HasValue ? TimeSpan.FromMilliseconds(retryTimeout.Value) : null)
{
HandleFailure = taskFailureDetails =>
{
retryHandlerCalls++;
return taskFailureDetails.IsCausedBy(exceptionType) && retryException;
}
};
TaskOptions taskOptions = TaskOptions.FromRetryPolicy(retryPolicy);


TaskName orchestratorName = "BustedOrchestration";
await using HostTestLifetime server = await this.StartWorkerAsync(b =>
{
b.AddTasks(tasks =>
tasks.AddOrchestratorFunc(orchestratorName, async ctx =>
{
await ctx.CallActivityAsync("Foo", options: taskOptions);
})
.AddActivityFunc("Foo", (TaskActivityContext context) =>
{
if (actualNumberOfAttempts++ < exceptionCount)
{
throw MakeException(exceptionType, 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(expRuntimeStatus, metadata.RuntimeStatus);
// More calls to retry handler than expected.
//Assert.Equal(expectedNumberOfAttempts, retryHandlerCalls);
cgillum marked this conversation as resolved.
Show resolved Hide resolved
Assert.Equal(expectedNumberOfAttempts, actualNumberOfAttempts);
}

/// <summary>
/// Tests retry policies for sub-orchestration calls.
/// </summary>
Expand Down Expand Up @@ -269,6 +331,78 @@ public async Task RetrySubOrchestrationFailures(int expectedNumberOfAttempts, Ty
Assert.True(metadata.FailureDetails.IsCausedBy<TaskFailedException>());
}

[Theory]
[InlineData(10, typeof(ApplicationException), false, int.MaxValue, 2, 1, OrchestrationRuntimeStatus.Failed)] // 1 attempt since retry timeout expired.
[InlineData(2, typeof(ApplicationException), false, int.MaxValue, null, 1, OrchestrationRuntimeStatus.Failed)] // 1 attempt since handler specifies no retry.
[InlineData(2, typeof(CustomException), true, int.MaxValue, null, 2, OrchestrationRuntimeStatus.Failed)] // 2 attempts, custom exception type
[InlineData(10, typeof(XunitException), true, 4, null, 5, OrchestrationRuntimeStatus.Completed)] // 10 attempts, 3rd party exception type
public async Task RetrySubOrchestratorFailuresCustomLogicAndPolicy(
int maxNumberOfAttempts,
Type exceptionType,
bool retryException,
int exceptionCount,
int? retryTimeout,
int expectedNumberOfAttempts,
OrchestrationRuntimeStatus expRuntimeStatus)
{
string errorMessage = "Kah-BOOOOOM!!!"; // Use an obviously fake error message to avoid confusion when debugging

int actualNumberOfAttempts = 0;
int retryHandlerCalls = 0;
RetryPolicy retryPolicy = new(
maxNumberOfAttempts,
firstRetryInterval: TimeSpan.FromMilliseconds(1),
backoffCoefficient: 2,
retryTimeout: retryTimeout.HasValue ? TimeSpan.FromMilliseconds(retryTimeout.Value) : null)
{
HandleFailure = taskFailureDetails =>
{
retryHandlerCalls++;
return taskFailureDetails.IsCausedBy(exceptionType) && retryException;
}
};
TaskOptions taskOptions = TaskOptions.FromRetryPolicy(retryPolicy);

TaskName orchestratorName = "OrchestrationWithBustedSubOrchestrator";
await using HostTestLifetime server = await this.StartWorkerAsync(b =>
{
b.AddTasks(tasks =>
tasks.AddOrchestratorFunc(orchestratorName, async ctx =>
{
await ctx.CallSubOrchestratorAsync("BustedSubOrchestrator", options: taskOptions);
})
.AddOrchestratorFunc("BustedSubOrchestrator", context =>
{
if (actualNumberOfAttempts++ < exceptionCount)
{
throw MakeException(exceptionType, 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(expRuntimeStatus, metadata.RuntimeStatus);
// More calls to retry handler than expected.
//Assert.Equal(expectedNumberOfAttempts, retryHandlerCalls);
cgillum marked this conversation as resolved.
Show resolved Hide resolved
Assert.Equal(expectedNumberOfAttempts, actualNumberOfAttempts);

// The root orchestration failed due to a failure with the sub-orchestration, resulting in a TaskFailedException
if (expRuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
Assert.NotNull(metadata.FailureDetails);
Assert.True(metadata.FailureDetails!.IsCausedBy<TaskFailedException>());
}
else
{
Assert.Null(metadata.FailureDetails);
}
}

[Theory]
[InlineData(1, typeof(ApplicationException))] // 1 attempt, built-in exception type
[InlineData(2, typeof(CustomException))] // 2 attempts, custom exception type
Expand Down
Loading