リトライ処理をまとめる

もくじ
https://tera1707.com/entry/2022/02/06/144447

やりたいこと

アプリを作る中で、

「あるメソッドを実行したときに、例外がスローされたら数回リトライしたい」
「数回リトライしてもまだ例外スローするならあきらめて処理継続したい」

というようなことが度々あったので、そのようなリトライ処理のひな型をまとめておきたい。

やったこと

下記のgithubに挙げたコードのように、サンプルコードを書いてみた。

https://github.com/tera1707/Retryer

ただ個人的感覚だが、どういうときにリトライしたいか、だったり、リトライ時にどういうことをしたいか、は、ケースによってさまざまで、「どのようなケースにも使える汎用的なリトライ処理」を作るのは難しい気がする。

→下記をベースに、その時その時にあったリトライを考えようと思う。

サンプル

今回作った例(クラス)では、下記のような仕様にした。

  • 対象の処理が例外をthrowすると、リトライを行う。
  • リトライさせる例外を指定する
    • ただし、すべての例外でリトライするようにもできる
  • 対象の処理以外に、下記を指定できる。
    • リトライの最大回数
    • リトライ間隔
    • 最大リトライ回数満了しても復帰しなかったときの処理
    • 最大リトライ回数満了したときに、その時の例外をthrowするか、握りつぶして処理計測するか

リトライ制御クラス

using System;
using System.Threading.Tasks;

namespace SharedRetryProject
{
    public class Retryer
    {
        private int invokeCnt = 0;

        /// <summary>
        /// リトライ処理
        /// </summary>
        /// <typeparam name="T">
        /// retryTarget実施時にリトライさせるべき例外(T以外の例外は、リトライせず普通にthrowする)
        /// ※「Exception」を指定すると、何の例外でもリトライする
        /// </typeparam>
        /// <param name="retryMax">リトライ最大回数(合計実行回数は「最初の1回+リトライ最大回数」になる)</param>
        /// <param name="interval">リトライ前に待つ時間</param>
        /// <param name="retryTarget">対象のアクション</param>
        /// <param name="onRetryOut">リトライ最大回数分リトライしても正常終了しなかった場合に行うアクション</param>
        /// <param name="throwWhenRetryOut">trueにすると、onRetryOut時に、例外をそのまま上にthrowする</param>
        /// <returns></returns>
        public async Task TryAndThrowWhenRetryOut<T>(int retryMax, TimeSpan interval, Func<Task> retryTarget, Func<Task> onRetryOut, bool throwWhenRetryOut = false)
            where T : Exception
        {
            while (true)
            {
                try
                {
                    await retryTarget.Invoke();
                    break;
                }
                catch (T)
                {
                    if (invokeCnt++ >= retryMax)
                    {
                        await onRetryOut.Invoke();

                        if (throwWhenRetryOut)
                            throw;
                        else
                            break;
                    }
                }
                await Task.Delay(interval);
            }
        }

        /// <summary>
        /// リトライ処理(リトライさせる例外を2つ指定できる版)
        /// </summary>
        public async Task TryAndThrowWhenRetryOut<T1, T2>(int retryMax, TimeSpan interval, Func<Task> retryTarget, Func<Task> onRetryOut, bool throwWhenRetryOut = false)
            where T1 : Exception
            where T2 : Exception
        {
            while (true)
            {
                try
                {
                    await retryTarget.Invoke();
                    break;
                }
                catch (Exception ex) when (ex is T1 || ex is T2)
                {
                    if (invokeCnt++ >= retryMax)
                    {
                        await onRetryOut.Invoke();

                        if (throwWhenRetryOut)
                            throw;
                        else
                            break;
                    }
                }
                await Task.Delay(interval);
            }
        }
    }
}

テスト用画面(wpf)

using SharedRetryProject;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;

namespace RetryWpfApp
{
    public partial class MainWindow : Window
    {
        int cnt = 0;

        public MainWindow()
        {
            InitializeComponent();
        }

        /// <summary>
        /// リトライ(満了時も処理を継続)
        /// </summary>
        private async void Button_Click(object sender, RoutedEventArgs e)
        {
            cnt = 0;
            var retryer = new Retryer();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(5, TimeSpan.FromSeconds(1), 
                async () =>
                {
                    await ErrorFunc();
                },
                async () =>
                {
                    Debug.WriteLine("リトライ回数超えました。");
                });
        }

        /// <summary>
        /// リトライ(満了時は例外を挙げる)
        /// </summary>
        private async void Button_Click2(object sender, RoutedEventArgs e)
        {
            cnt = 0;
            var retryer = new Retryer();

            try
            {
                await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(5, TimeSpan.FromSeconds(1),
                    async () =>
                    {
                        await ErrorFunc();
                    },
                    async () =>
                    {
                        Debug.WriteLine("リトライ回数超えました。");
                    },
                    true);
            }
            catch (Exception ex)
            {
                Debug.WriteLine("画面側のtrycatch");
            }
        }

        private async Task ErrorFunc()
        {
            Debug.WriteLine($" 処理 {cnt++} 回目");

            if (cnt < 10)
            {
                throw new InvalidOperationException();
            }
        }
    }
}

ユニットテスト

using SharedRetryProject;
using System.Diagnostics;

namespace TestProject1
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        [Description("最大回数までリトライする場合")]
        public async Task TestMethod1()
        {
            int invokeCtr = 0;
            int retryMax = 5;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(retryMax, TimeSpan.FromSeconds(1),
                async () =>
                {
                    invokeCtr++;
                    throw new InvalidOperationException();
                },
                async () =>
                {
                    executedOnRetryOut = true;
                });

            Assert.AreEqual(retryMax + 1, invokeCtr);
            Assert.AreEqual(true, executedOnRetryOut);
        }

        [TestMethod]
        [Description("リトライがない場合")]
        public async Task TestMethod2()
        {
            int invokeCtr = 0;
            int retryMax = 5;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(retryMax, TimeSpan.FromSeconds(1),
                async () =>
                {
                    invokeCtr++;
                },
                async () =>
                {
                    executedOnRetryOut = true;
                });

            Assert.AreEqual(1, invokeCtr);
            Assert.AreEqual(false, executedOnRetryOut);
        }

        [TestMethod]
        [Description("リトライ最大回数になる前に正常終了する場合")]
        public async Task TestMethod3()
        {
            int invokeCtr = 0;
            int fixCtr = 3;//3回目で復活
            int retryMax = 5;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(retryMax, TimeSpan.FromSeconds(1),
                async () =>
                {
                    invokeCtr++;

                    if (invokeCtr < fixCtr)
                        throw new InvalidOperationException();
                },
                async () =>
                {
                    executedOnRetryOut = true;
                });

            Assert.AreEqual(fixCtr, invokeCtr);
            Assert.AreEqual(false, executedOnRetryOut);
        }

        [TestMethod]
        [Description("リトライ秒数の確認")]
        public async Task TestMethod4()
        {
            int invokeCtr = 0;
            int retryMax = 3;

            var retryer = new Retryer();

            // 実行開始前にストップウォッチをスタート
            Stopwatch sw = new Stopwatch();
            sw.Start();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(retryMax, TimeSpan.FromSeconds(10),
                async () =>
                {
                    invokeCtr++;
                    throw new InvalidOperationException();
                },
                async () =>
                {
                    // リトライがすべて終わったらストップウォッチ停止
                    sw.Stop();
                });

            // リトライが3回だと、処理実行4回、間に10秒待ちが3回なので、30秒+処理時間かかるはず→30秒±数秒、で終わるはずと想定
            Debug.WriteLine(sw.Elapsed.ToString());
            Assert.IsTrue(TimeSpan.FromSeconds(30 - 2) < sw.Elapsed && sw.Elapsed < TimeSpan.FromSeconds(30 + 2));
        }

        [TestMethod]
        [Description("指定以外の例外ではリトライせず、例外をスローすることの確認")]
        public async Task TestMethod5()
        {
            int invokeCtr = 0;
            int retryMax = 5;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await Assert.ThrowsExceptionAsync<ArgumentNullException>(async () =>
            {
                await retryer.TryAndThrowWhenRetryOut<InvalidOperationException>(retryMax, TimeSpan.FromSeconds(1),
                    async () =>
                    {
                        invokeCtr++;
                        throw new ArgumentNullException();
                    },
                    async () =>
                    {
                        executedOnRetryOut = true;
                    });
            });

            Assert.AreEqual(1, invokeCtr);
            Assert.AreEqual(false, executedOnRetryOut);
        }

        [TestMethod]
        [Description("リトライ最大回数になる前に正常終了する場合(2つの例外を受けられるほうのメソッド)")]
        public async Task TestMethod11()
        {
            int invokeCtr = 0;
            int fixCtr = 6;//3回目で復活
            int retryMax = 10;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await retryer.TryAndThrowWhenRetryOut<InvalidOperationException, ArgumentNullException>(retryMax, TimeSpan.FromSeconds(1),
                async () =>
                {
                    invokeCtr++;

                    if (invokeCtr < fixCtr)
                    {
                        if (invokeCtr % 2 == 0)
                            throw new InvalidOperationException();
                        else
                            throw new ArgumentNullException();
                    }
                },
                async () =>
                {
                    executedOnRetryOut = true;
                });

            Assert.AreEqual(fixCtr, invokeCtr);
            Assert.AreEqual(false, executedOnRetryOut);
        }


        [TestMethod]
        [Description("指定以外の例外ではリトライせず、例外をスローすることの確認(2つの例外を受けられるほうのメソッド)")]
        public async Task TestMethod12()
        {
            int invokeCtr = 0;
            int fixCtr = 6;//3回目で復活
            int retryMax = 10;
            bool executedOnRetryOut = false;

            var retryer = new Retryer();

            await Assert.ThrowsExceptionAsync<DivideByZeroException>(async () =>
            {
                await retryer.TryAndThrowWhenRetryOut<InvalidOperationException, ArgumentNullException>(retryMax, TimeSpan.FromSeconds(1),
                    async () =>
                    {
                        invokeCtr++;

                        throw new DivideByZeroException();
                    },
                    async () =>
                    {
                        executedOnRetryOut = true;
                    });
            });

            Assert.AreEqual(1, invokeCtr);
            Assert.AreEqual(false, executedOnRetryOut);
        }
    }
}

リトライ処理のキャンセルについて

まだ実際に試してはいないのだが、 リトライには時間がかかるということで、おそらく「リトライ途中でキャンセルしたい」ということが出てくると想像する。

その場合は、下記の記事で調べた「CancellationTokenを使用した非同期処理のキャンセル」が使えると思われる。

tera1707.com

またそういうことをしたくなったら試してみる。

備考

上のサンプルでもWPFの画面でテストをしているが、 「リトライ」は例えば、

  • ボタンを押して何かの処理をして、それがうまくいかずに例外がおきたときにリトライする

という用途が自分の身近なところではありそうだが、その場合、上のWPFのコードだと、

  • ボタンを連打すると、ボタンを連打しただけ処理が並行して走って、リトライも押した数だけ並行して行われてしまう

ということが起きてしまうが、それはリトライ処理の問題ではなくボタンを押したときの排他処理の問題だと思うので、このリトライ制御用のクラスでどうこうするところではないかなと思った。
(そもそもボタンを連打できなくすべきだから、そこを「リトライ制御」クラスがやるべきじゃないなということ)

参考

MS公式の「Retry pattern」

https://learn.microsoft.com/en-us/azure/architecture/patterns/retry

結構、似たことを記事にしている方がおられた。

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

https://shikaku-sh.hatenablog.com/entry/c-sharp-implements-retry-pattern

C#リトライ制御

https://qiita.com/naka123/items/0ee1a0aecc492ead14d0

C#でリトライ処理を共通化してみた

https://qiita.com/t_takahari/items/6367434c3484b29cf14d