sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# 再試行パターンを実装する

f:id:shikaku_sh:20210823173353p:plain:w300

DB の操作 (DML) のような 99.99...% 成功する操作は信頼性が高いため(面倒で)リトライ処理を実装しないことがあるかと思いますが、実際はごく稀に失敗することがあって、失敗時の処理を横着したことでより面倒な障害につながるケースがあります。

通化したリトライ処理を作成しておいて、なるべく安全性の高いシステムを作成しておこう……という記事です。

再試行パターン (Retry pattern)

通化したリトライ処理、言い換えるとリトライ処理のパターン化です。これは、珍しいテクニックではないです。

アプリケーションを一時的な障害に対応できるようにするパターンです。 アプリケーションまたはサービスが、(リソースに取得・接続しようとするなど)失敗した操作を透過的に再試行します。

再試行パターンのポイントは、つぎのようなところ:

  • 再試行する
    要求した処理が失敗したときに、同じ処理を成功するまで再試行します。処理に成功した場合、再試行ををそれ以上しません。
  • キャンセルする
    処理を繰り返しても成功しない場合、操作をキャンセルして例外を報告します。たとえば、間違った資格情報で認証を何度繰り返しても、成功することは無いのでキャンセルすることが適切です。
  • 時間をおいて再試行する
    要求した処理が、一時的な障害(ビジー状態など)である場合、状態が解消されるまで時間を必要とすることがあります。要求する処理は待機してから再試行します。

加えて、再試行に失敗したときは、その操作の詳細を出力できると助けになります。

補足

それほど重要ではない処理なら、何度も繰り返し再試行するとアプリケーションのスループット(単位時間あたりに処理できる量)に影響を与えるため、さっさと失敗したことを通知する、Fail Fast のほうがよい場合もあります。

たとえば、Qiita の「C#でリトライ処理を共通化してみた」という記事では、リトライ処理の間隔を Math.Pow を使って指数的に時間を伸ばす処理をしていますが、おそらくこれはバッチのような処理の場合に適切だと思います。

一方で、ユーザーが処理する場合は、「後でもう一度試してください」くらいのメッセージを表示しておくほうが、スループットを考慮すると適切かもしれないよね、ということです。(実際は、待たずに再試行を連打マンになる人も多いわけですが)

おおきな障害で再試行が必要な場合は、再試行パターンでは対応しづらいはずです。サーキットブレーカーパターンなどを参照してみてください。

実装

シンプルな実装例だとこんな感じになると思います。

繰り返し処理を共通化するのにあわせて、DB 操作の安全性を高めることができる可能性があると考えて Lock しています。(用途に応じて、なくてもいいと思います)

Task 内で発生した例外は AggregateException として返却されますが、n回繰り返した間に発生した間に発生した例外をまとめて返却することになるので、AggregateException を作り直しています。

private static readonly object _Locker = new ();

public static async Task InvokeAsync(Action action, int attempts, int sleepMilliseconds,
                        [CallerMemberName] string memberName = "", 
                        [CallerFilePath] string sourceFilePath = "", 
                        [CallerLineNumber] int sourceLineNumber = 0)
{
    var aggregates = new List<Exception>();

    while (true)
    {
        try
        {
            lock (_Locker)
            {
                var task = Task.Run(() => action());
                task.Wait();
            }

            break;
        }
        catch (Exception ex)
        {
            if (ex is AggregateException aggregate)
            {
                for (int i = 0; i < aggregate.InnerExceptions.Count; i++)
                {
                    aggregates.Add(aggregate.InnerExceptions[i]);
                }
            }
            else 
            {
                aggregates.Add(ex);
            }

            if (--attempts <= 0)
            {
                Debug.WriteLine($"could not invoke. caller: {memberName} - {sourceFilePath} ({sourceLineNumber})");
                throw new AggregateException(aggregates);
            }

            Debug.WriteLine($"{ex.GetType()} caught: retry after {sleepMilliseconds} ms (left try: {attempts})");
            await Task.Delay(sleepMilliseconds);
        }
    }
}

メソッドの呼び出し元情報を取得する Caller パラメーターを付与しておけば、再試行に失敗したときにログを出力することができます。

どのメソッドが実行されて、そのメソッドはどのファイルで何行目かまでわかります。こうしたログを強化できると共通化したメリットが大きくなると思います。

could not invoke. caller: Test_Action - C:\Users\source\repos\Samples\RetryTest\TestProject\Tests.cs (16)

テスト

TestAction メソッドを4回試行して、最後に Exception が発生することを確認できます。実行間隔は1秒なので、約3秒で実行されることも確認できます。

public class Tests
{
    [Test]
    public void Test_Action()
    {
        Assert.ThrowsAsync<AggregateException>(() => Retry.InvokeAsync(TestAction));
    }

    public void TestAction()
    {
        throw new Exception();
    }
}

もうすこし細かくテスト:

[Test]
public async Task Test_Action()
{
    try
    {
        await Retry.InvokeAsync(TestAction);
    }
    catch (AggregateException e)
    {
        Assert.AreEqual(e.Flatten().InnerExceptions.Where(p => p is Exception).Count(), 4);
    }
}

f:id:shikaku_sh:20220105171819p:plain

async/await を使わずに、Task 型の変数に渡して Task.Wait() で待機させる方法でもよいですが、AggregateException が階層的になってしまい一覧性がわるい。なので Flatten メソッドを使って平坦化してやると扱いやすくなります。

サンプル

GitHub にサンプルコードをあげています。

参考