【C#】複数のExceptionがまとめられたAggregateExceptionを正しくハンドリングする

C#で複数のExceptionがまとめられたAggregateExceptionを正しくハンドリングする方法についてまとめました。

AggregateExceptionとは?

たとえばTask.WhenAllで複数のTaskの完了を待機するときには、複数の例外がスローされる可能性があります。
WhenAllをawaitするとスローされた例外のうちのいずれかだけしかキャッチできませんが、以下のようにAggregateException型のTask.Exceptionを参照することで、全ての例外の情報を取得することができます。

public sealed class Tests
{
    [Test]
    public async Task Test()
    {
        var whenAllTask = Task.WhenAll(
            Task.FromException(new Exception("Foo Exception")),
            Task.FromException(new Exception("Bar Exception"))
        );
        try
        {
            await whenAllTask;
        }
        catch
        {
            // テストのため例外を握りつぶす
        }

        // 発生した例外がwhenAllTask.Exception(AggregateException型)にまとめられていることを確認
        Assert.That(whenAllTask.Exception, Is.Not.Null);
        // InnnerExceptionsで個々の例外を取得できる
        Assert.That(whenAllTask.Exception!.InnerExceptions.Count, Is.EqualTo(2));
        Assert.That(whenAllTask.Exception!.InnerExceptions.Any(e => e.Message == "Foo Exception"));
        Assert.That(whenAllTask.Exception!.InnerExceptions.Any(e => e.Message == "Bar Exception"));
    }
}

Flatten

またより複雑な例として、以下のページに載っているような親子関係のあるTaskを作る場合には、AggregateException自体が入れ子になる可能性もあります。

learn.microsoft.com

このようなケースでは以下のように、AggregateException.Flatten()を呼ぶことで複数のAggregateExceptionを一つにまとめることができます。

public sealed class Tests
{
    [Test]
    public async Task Test()
    {
        var task = Task.Factory.StartNew(() =>
        {
            Task.Factory.StartNew(() =>
                {
                    Task.Factory.StartNew(() => throw new Exception("Attached child2 faulted."),
                        TaskCreationOptions.AttachedToParent);
                    throw new Exception("Attached child1 faulted.");
                },
                TaskCreationOptions.AttachedToParent);
        });
        try
        {
            await task;
        }
        catch
        {
            // テストのため例外を握りつぶす
        }

        // AggregateExceptionが入れ子になっていることを確認
        Assert.That(task.Exception, Is.TypeOf<AggregateException>());
        Assert.That(task.Exception!.InnerException, Is.TypeOf<AggregateException>());
        Assert.That(task.Exception!.InnerException!.InnerException, Is.TypeOf<Exception>());
        
        // Flattenして全ての例外が取得できることを確認
        var exceptions = task.Exception!.Flatten().InnerExceptions;
        Assert.That(exceptions.Count, Is.EqualTo(2));
        Assert.That(exceptions.Any(e => e.Message == "Attached child1 faulted."));
        Assert.That(exceptions.Any(e => e.Message == "Attached child2 faulted."));
    }
}

Handle

さらに、Catch句内でAggregateException.Handleを呼ぶと特定の例外のみ処理済みとしてマークすることができます。
未処理のままにした例外については、AggregateExceptionInnerExceptionsに設定されて再びスローされます。

public sealed class Tests
{
    [Test]
    public async Task Test()
    {
        var task = Task.Factory.StartNew(() =>
        {
            Task.Factory.StartNew(() =>
                {
                    Task.Factory.StartNew(() => throw new Exception("Attached child2 faulted."),
                        TaskCreationOptions.AttachedToParent);
                    throw new Exception("Attached child1 faulted.");
                },
                TaskCreationOptions.AttachedToParent);
        });
        AggregateException? exception = null;
        try
        {
            try
            {
                await task;
            }
            catch (AggregateException ae)
            {
                ae.Flatten()
                    .Handle(ex =>
                    {
                        // 特定の例外だけ処理済みとする
                        if (ex.Message == "Attached child1 faulted.") return true;

                        // 他は未処理例外としてAggregateExceptionのInnerExceptionとして再スローされる
                        return false;
                    });
            }
        }
        catch (AggregateException ae)
        {
            // Handleされた後のAggregateException
            exception = ae;
        }

        // AggregateExceptionのInnerExceptionにはHandleされなかった例外が含まれることを確認
        Assert.That(exception, Is.TypeOf<AggregateException>());
        Assert.That(exception!.InnerExceptions.Count, Is.EqualTo(1));
        Assert.That(exception!.InnerException!.Message == "Attached child2 faulted.");
    }
}

参考

learn.microsoft.com