How to return AggregateException from async method

余生长醉 提交于 2020-05-24 04:07:09

问题


I got an async method working like an enhanced Task.WhenAll. It takes a bunch of tasks and returns when all are completed.

public async Task MyWhenAll(Task[] tasks) {
    ...
    await Something();
    ...

    // all tasks are completed
    if (someTasksFailed)
        throw ??
}

My question is how do I get the method to return a Task looking like the one returned from Task.WhenAll when one or more tasks has failed?

If I collect the exceptions and throw an AggregateException it will be wrapped in another AggregateException.

Edit: Full Example

async Task Main() {
    try {
        Task.WhenAll(Throw(1), Throw(2)).Wait();
    }
    catch (Exception ex) {
        ex.Dump();
    }

    try {
        MyWhenAll(Throw(1), Throw(2)).Wait();
    }
    catch (Exception ex) {
        ex.Dump();
    }
}

public async Task MyWhenAll(Task t1, Task t2) {
    await Task.Delay(TimeSpan.FromMilliseconds(100));
    try {
        await Task.WhenAll(t1, t2);
    }
    catch {
        throw new AggregateException(new[] { t1.Exception, t2.Exception });
    }
}
public async Task Throw(int id) {
    await Task.Delay(TimeSpan.FromMilliseconds(100));
    throw new InvalidOperationException("Inner" + id);
}

For Task.WhenAll the exception is AggregateException with 2 inner exceptions.

For MyWhenAll the exception is AggregateException with one inner AggregateException with 2 inner exceptions.

Edit: Why I am doing this

I often need to call paging API:s and want to limit number of simultaneous connections.

The actual method signatures are

public static async Task<TResult[]> AsParallelAsync<TResult>(this IEnumerable<Task<TResult>> source, int maxParallel)
public static async Task<TResult[]> AsParallelUntilAsync<TResult>(this IEnumerable<Task<TResult>> source, int maxParallel, Func<Task<TResult>, bool> predicate)

It means I can do paging like this

var pagedRecords = await Enumerable.Range(1, int.MaxValue)
                                   .Select(x => GetRecordsAsync(pageSize: 1000, pageNumber: x)
                                   .AsParallelUntilAsync(maxParallel: 5, x => x.Result.Count < 1000);
var records = pagedRecords.SelectMany(x => x).ToList();

It all works fine, the aggregate within aggregate is just a minor inconvenience.


回答1:


async methods are designed to only every set at most a single exception on the returned task, not multiple.

This leaves you with two options, you can either not use an async method to start with, instead relying on other means of performing your method:

public Task MyWhenAll(Task t1, Task t2)
{
    return Task.Delay(TimeSpan.FromMilliseconds(100))
        .ContinueWith(_ => Task.WhenAll(t1, t2))
        .Unwrap();
}

If you have a more complex method that would be harder to write without using await, then you'll need to unwrap the nested aggregate exceptions, which is tedious, although not overly complex, to do:

    public static Task UnwrapAggregateException(this Task taskToUnwrap)
    {
        var tcs = new TaskCompletionSource<bool>();

        taskToUnwrap.ContinueWith(task =>
        {
            if (task.IsCanceled)
                tcs.SetCanceled();
            else if (task.IsFaulted)
            {
                if (task.Exception is AggregateException aggregateException)
                    tcs.SetException(Flatten(aggregateException));
                else
                    tcs.SetException(task.Exception);
            }
            else //successful
                tcs.SetResult(true);
        });

        IEnumerable<Exception> Flatten(AggregateException exception)
        {
            var stack = new Stack<AggregateException>();
            stack.Push(exception);
            while (stack.Any())
            {
                var next = stack.Pop();
                foreach (Exception inner in next.InnerExceptions)
                {
                    if (inner is AggregateException innerAggregate)
                        stack.Push(innerAggregate);
                    else
                        yield return inner;
                }
            }
        }

        return tcs.Task;
    }



回答2:


Use a TaskCompletionSource.

The outermost exception is created by .Wait() or .Result - this is documented as wrapping the exception stored inside the Task inside an AggregateException (to preserve its stack trace - this was introduced before ExceptionDispatchInfo was created).

However, Task can actually contain many exceptions. When this is the case, .Wait() and .Result will throw an AggregateException which contains multiple InnerExceptions. You can access this functionality through TaskCompletionSource.SetException(IEnumerable<Exception> exceptions).

So you do not want to create your own AggregateException. Set multiple exceptions on the Task, and let .Wait() and .Result create that AggregateException for you.

So:

var tcs = new TaskCompletionSource<object>();
tcs.SetException(new[] { t1.Exception, t2.Exception });
return tcs.Task;

Of course, if you then call await MyWhenAll(..) or MyWhenAll(..).GetAwaiter().GetResult(), then it will only throw the first exception. This matches the behaviour of Task.WhenAll.

This means you need to pass tcs.Task up as your method's return value, which means your method can't be async. You end up doing ugly things like this (adjusting the sample code from your question):

public static Task MyWhenAll(Task t1, Task t2)
{
    var tcs = new TaskCompletionSource<object>();
    var _ = Impl();
    return tcs.Task;

    async Task Impl()
    {
        await Task.Delay(10);
        try
        {
            await Task.WhenAll(t1, t2);
            tcs.SetResult(null);
        }
        catch
        {
            tcs.SetException(new[] { t1.Exception, t2.Exception });
        }
    }
}

At this point, though, I'd start to query why you're trying to do this, and why you can't use the Task returned from Task.WhenAll directly.




回答3:


This answer combines ideas from Servy's and canton7's solutions. As an example is used an actually useful method named Retry, that attempts to run a task multiple times before reporting failure. In case that the maximum number of attempts has been reached, the returned task transitions to a faulted state, containing all the exceptions bundled inside an AggregateException. So this method behaves very similar to the built-in Task.WhenAll method. Here is the Retry method:

public static Task<TResult> Retry<TResult>(Func<Task<TResult>> taskFactory,
    int maxAttempts)
{
    if (taskFactory == null) throw new ArgumentNullException(nameof(taskFactory));
    if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts));
    return FlattenTopAggregateException(Implementation());

    async Task<TResult> Implementation()
    {
        var exceptions = new List<Exception>();
        while (true)
        {
            var task = taskFactory();
            try
            {
                return await task.ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
                if (exceptions.Count >= maxAttempts)
                    throw new AggregateException(exceptions);
            }
        }
    }
}

The method is separated into two parts: 1) argument validation and 2) implementation. The implementation is an asynchronous local function, that throws an AggregateException. The resulting task of the local function is not well behaved because on failure it contains a nested AggregateException. To fix this problem the task is flattened before returned by the outer Retry method. It is flattened only at top level, meaning that a possibly deep AggregateException hierarchy will become shortened only by one level. The purpose of flattening is just to eliminate the top-level nesting that is caused by throwing an AggregateException from an async method. Here is the flattening method:

private static Task<TResult> FlattenTopAggregateException<TResult>(Task<TResult> task)
{
    var tcs = new TaskCompletionSource<TResult>();
    HandleTaskCompletion();
    return tcs.Task;

    async void HandleTaskCompletion()
    {
        try
        {
            var result = await task.ConfigureAwait(false);
            tcs.SetResult(result);
        }
        catch (OperationCanceledException ex) when (task.IsCanceled)
        {
            // Unfortunately the API SetCanceled(CancellationToken) is missing
            if (!tcs.TrySetCanceled(ex.CancellationToken)) tcs.SetCanceled();
        }
        catch (Exception ex)
        {
            var taskException = task.Exception;
            if (taskException == null || taskException.InnerExceptions.Count == 0)
            {
                // Handle abnormal case
                tcs.SetException(ex);
            }
            else if (taskException.InnerExceptions.Count == 1
                && taskException.InnerException is AggregateException aex
                && aex.InnerExceptions.Count > 0)
            {
                // Fix nested AggregateException
                tcs.SetException(aex.InnerExceptions);
            }
            else
            {
                // Keep it as is
                tcs.SetException(taskException.InnerExceptions);
            }
        }
    }
}

This method contains an async void local function. The reason for this is for ensuring that any bugs that may lurk in the implementation will be surfaced. In case async void methods are undesirable, it can be trivially converted to a fire-and-forget task.

Here is a usage example of the Retry method. The returned task is awaited inside a try-catch block. The Exception caught by the block is ignored, and the Exception property of the task is observed instead:

var task = Retry(async () =>
{
    Console.WriteLine("Try to do something");
    await Task.Delay(100, new CancellationToken(true));
    return "OK";
}, maxAttempts: 3);

try
{
    await task;
}
catch
{
    if (task.IsFaulted)
    {
        Console.WriteLine($"Errors: {task.Exception.InnerExceptions.Count}");
        Console.WriteLine($"{task.Exception.Message}");
    }
    else
    {
        Console.WriteLine("Not faulted");
    }
}

Output:

Try to do something
Try to do something
Try to do something
Errors: 3
One or more errors occurred. (A task was canceled.) (A task was canceled.) (A task was canceled.)



来源:https://stackoverflow.com/questions/55687698/how-to-return-aggregateexception-from-async-method

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!