[.NET]開發人員不可缺少的重試處理利器 Polly

 

Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner.

Polly是一個.NET彈性和瞬態故障處理庫,允許開發人員以流暢和線程安全的方式表達諸如重試,斷路器,超時,隔離和回退之類的策略。

 

支援平台

Polly targets .NET Standard 1.1 (coverage: .NET Core 1.0, Mono, Xamarin, UWP, WP8.1+) and .NET Standard 2.0+ (coverage: .NET Core 2.0+, .NET Core 3.0, and later Mono, Xamarin and UWP targets). The nuget package also includes direct targets for .NET Framework 4.6.1 and 4.7.2.

引用內容出自:https://github.com/App-vNext/Polly

前面幾篇有稍微介紹 Polly 的應用 Hangfire + PollyHttpClientFactory + Polly,忽然發現 Polly 的草稿好像還沒使用。

開發環境

  • VS 2019
  • WinForm .NET 4.8
  • Polly 7.2.1

 

在沒有使用 Polly 之前呢,Retry 你可能會這樣寫,這是一個很簡單的重試

private static void Retry(Action action, int retryCount = 3, int waitSecond = 10)
{
    while (true)
    {
        try
        {
            action();
            break;
        }
        catch
        {
            if (--retryCount == 0)
            {
                throw;
            }

            var seconds = TimeSpan.FromSeconds(waitSecond);
            Thread.Sleep(seconds);
        }
    }
}

 

三個步驟

安裝以下套件 Install-Package Polly

 

Polly 提供了相當豐富的 Retry 機制,你就可以不需要自己造輪子囉,Polly 處理例外的寫法主要有三個步驟,風格是方法鏈(Fluent Interface Pattern)

1. 處理甚麼樣的例外,Handle:

Policy.Handle<Exception>()

1-1. 或者是返回條件符合失敗,OrResult(非必要):

Policy.Handle<Exception>().OrResult(r => r.StatusCode == HttpStatusCode.BadGateway)

也可以這樣寫 HandleResult:

Policy.HandleResult(r => r.StatusCode == HttpStatusCode.BadGateway)

2. 重試策略,包含重試次數,發生錯誤時回呼匿名方法:
.Retry(3, (reponse, retryCount, context) =>
        {
            //call back
            //錯誤發生後要做的事
        })
 

3. 執行內容,你要做的事,Execute:

.Execute(FakeRequest)

 

完整代碼如下:

private static void _01_標準用法()
{
    Policy

        // 1. 處理甚麼樣的例外
        .Handle<HttpRequestException>()

        //    或者返回條件(非必要)
        .OrResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)

        // 2. 重試策略,包含重試次數
        .Retry(3, (reponse, retryCount, context) =>
                  {
                      var result = reponse.Result;
                      if (result != null)
                      {
                          var errorMsg = result.Content
                                               .ReadAsStringAsync()
                                               .GetAwaiter()
                                               .GetResult();
                          Console.WriteLine($"標準用法,發生錯誤:{errorMsg},第 {retryCount} 次重試");
                      }
                      else
                      {
                          var exception = reponse.Exception;
                          Console.WriteLine($"標準用法,發生錯誤:{exception.Message},第 {retryCount} 次重試");
                      }

                      Thread.Sleep(3000);
                  })

        // 3. 執行內容
        .Execute(FailResponse);

    Console.WriteLine("標準用法,完成");
}

 

七種重試策略

Polly 可以實現重試、斷路、超時、隔離、回退和緩存策略,下面列出一些使用場景和範例

重試 (Retry)

用在短暫發生很快就會恢復的故障,這個是很常見的場景,在一開始的使用步驟就是使用 Retry

永不放棄,失敗的時,等待 5 秒再重試

private static void _01_永不放棄()
{
    var retryPolicy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)
                            .RetryForever((response, retryCount, context) =>
                                          {
                                              var errorMsg = response.Result
                                                                     .Content
                                                                     .ReadAsStringAsync()
                                                                     .GetAwaiter()
                                                                     .GetResult();
                                              Console.WriteLine($"永不放棄,發生錯誤:{errorMsg},第 {retryCount} 次重試");
                                              Thread.Sleep(5000);
                                          })
        ;
    retryPolicy.Execute(FailResponse);
    Console.WriteLine("永不放棄,完成");
}

 

延遲重試

根據自訂頻率重試

private static void _02_延遲重試_固定周期()
{
    var retryPolicy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)
                            .WaitAndRetry(new[]
                                          {
                                              TimeSpan.FromSeconds(5),
                                              TimeSpan.FromSeconds(10),
                                              TimeSpan.FromSeconds(15)
                                          },
                                          (response, retryTime, context) =>
                                          {
                                              var errorMsg = response.Result
                                                                     .Content
                                                                     .ReadAsStringAsync()
                                                                     .GetAwaiter()
                                                                     .GetResult();
                                              Console.WriteLine($"延遲重試,發生錯誤:{errorMsg},延遲 {retryTime} 後重試");
                                          });
    retryPolicy.Execute(FailResponse);
    Console.WriteLine("延遲重試,完成");
}

 

根據次方計算延遲重試時間,下面的例子,6 的次方,重試 6 次

private static void _01_延遲重試_計算週期_次方()
{
    var retryPolicy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)
                            .WaitAndRetry(6,
                                          retryAttempt => TimeSpan.FromSeconds(Math.Pow(6, retryAttempt)),
                                          (response, retryTime, context) =>
                                          {
                                              var errorMsg = response.Result
                                                                     .Content
                                                                     .ReadAsStringAsync()
                                                                     .GetAwaiter()
                                                                     .GetResult();
                                              Console.WriteLine($"延遲重試,發生錯誤:{errorMsg},延遲 {retryTime} 後重試");
                                          })
        ;
    retryPolicy.Execute(FailResponse);
    Console.WriteLine("延遲重試,完成");
}

 

Jitter

/// <summary>
///     https://docs.microsoft.com/zh-tw/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly
/// </summary>
private static void _01_延遲重試_計算週期_Jitter()
{
    //抖動演算法
    var jitterer = new Random();

    var retryPolicy = Policy.Handle<Exception>()
                            .OrResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)
                            .WaitAndRetry(6,
                                          retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
                                                          + TimeSpan.FromMilliseconds(jitterer.Next(0, 100)),
                                          (response, retryTime, context) =>
                                          {
                                              WaitAndRetryAction(response, retryTime);
                                          })
        ;
    try
    {
        var httpResponse = retryPolicy.Execute(RandomFailResponseOrException);
        var content = httpResponse.Content
                                  .ReadAsStringAsync()
                                  .GetAwaiter()
                                  .GetResult()
            ;
        Console.WriteLine(content);
        Console.WriteLine("延遲重試,完成");
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
}

 

 

參考
https://docs.microsoft.com/zh-tw/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly#add-a-jitter-strategy-to-the-retry-policy
https://docs.microsoft.com/zh-tw/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly

斷路 (Circuit-breaker)

當系統遇到嚴重問題時,快速回饋失敗比讓用戶/調用者等待要好,在復原之前先關閉請求,也有助於系統恢復。

比如,當我們去調一個第三方的 API,有很長一段時間 API 都沒有響應,可能對方服務器癱瘓了。如果我們的系統還不停地重試,不僅會加重系統的負擔,還會可能導致系統其它任務受影響。所以,當系統出錯的次數超過了指定的閾值,就要中斷當前線路,等待一段時間後再繼續。

下面是一個斷路策略的使用方式,假如出現兩次異常時就停下來,等待一分鐘後再繼續;除此之外,還可以在斷路時定義中斷的回呼(OnBreak)和重啟(OnReset)的回呼

Policy.Handle()CircuitBreaker(2, TimeSpan.FromMinutes(1));

 

斷路器的執行流程,下圖出自,https://github.com/App-vNext/Polly/wiki/Circuit-Breaker

為了演練斷路器模式,我找到了這篇文章 www.twilio.com/blog/using-polly-circuit-breakers-resilient-net-web-service-consumers,這篇詳細的介紹及演練斷路器的實現,需搭配 Microsoft.Extensions.Http.Polly,開始之前請先到 https://github.com/bryanjhogan/PollyCircuitBreakers/ 拉專案下來,再按照文章內容實作

 

或者使用我的演練代碼

定義重試策略,其中 onBreak、onRest、onHalfOpen 用來觀察斷路器模式的狀態,失敗兩次後要等 30 秒才能再執行,代碼如下:

private static AsyncCircuitBreakerPolicy<HttpResponseMessage> CreateAsyncCircuitBreakerPolicy()
{
    Action<DelegateResult<HttpResponseMessage>, CircuitState, TimeSpan, Context> onBreak =
            (response, state, retryTime, context) =>
            {
                var    ex  = response.Exception;
                string msg = null;
                if (ex != null)
                {
                    msg = $"錯誤:{ex.Message}\r\n"
                          + $"超過失敗上限了,先等等,過了 {retryTime} 再過來\r\n"
                          + $"斷路器狀態:{state}"
                        ;
                }
                else
                {
                    var content = response.Result
                                          .Content
                                          .ReadAsStringAsync()
                                          .GetAwaiter()
                                          .GetResult();
                    msg = $"錯誤:{content}\r\n"
                          + $"超過失敗上限了,先等等,過了 {retryTime} 再過來\r\n"
                          + $"斷路器狀態:{state}"
                        ;
                }

                Console.WriteLine(msg);
                Console.WriteLine();
            }
        ;
    Action<Context> onReset = context =>
                              {
                                  var state = s_asyncCircuitBreakerPolicy.CircuitState;
                                  Console.WriteLine($"Reset 重設,斷路器狀態:{state}");
                              };

    Action onHalfOpen = () =>
                        {
                            var state = s_asyncCircuitBreakerPolicy.CircuitState;
                            Console.WriteLine($"斷路器狀態:{state}");
                        };

    var policy = Policy.Handle<Exception>()
                       .OrResult<HttpResponseMessage>(p => p.IsSuccessStatusCode == false)
                       .CircuitBreakerAsync(2, TimeSpan.FromSeconds(30), onBreak, onReset, onHalfOpen);

    return policy;
}

 

初始化重試策略

private static AsyncCircuitBreakerPolicy<HttpResponseMessage> s_asyncCircuitBreakerPolicy;

public Form1()
{
    this.InitializeComponent();
    s_asyncCircuitBreakerPolicy = CreateAsyncCircuitBreakerPolicy();
}

 

模擬服務狀態,如下代碼:

private static HttpResponseMessage RandomFailResponseOrException()
{
    Console.WriteLine("請求網路資源中...");

    var random = new Random().Next(0, 10);

    if (random <= 1)
    {
        throw new HttpRequestException("請求出現未知異常~");
    }

    var response = new HttpResponseMessage();
    if (random > 2 & random <= 6)
    {
        response.StatusCode = HttpStatusCode.OK;
        response.Content    = new StringContent("對了,媽,我在這裡~!");
    }
    else if (random > 6)
    {
        response.StatusCode = HttpStatusCode.BadGateway;
        response.Content    = new StringContent("網路設備噴掉了啦!!!");
    }

    return response;
}

private static Task<HttpResponseMessage> RandomFailResponseOrExceptionAsync()
{
    var response = RandomFailResponseOrException();
    return Task.FromResult(response);
}


最後,用按鈕呼叫它

private static void _02_斷路器()
{
    var state = s_asyncCircuitBreakerPolicy.CircuitState;
    try
    {
        Console.WriteLine($"呼叫任務前的狀態:{state}");

        var response = s_asyncCircuitBreakerPolicy.ExecuteAsync(RandomFailResponseOrExceptionAsync)
                                                  .GetAwaiter()
                                                  .GetResult();
        var content = response.Content;
        if (content != null)
        {
            var result = content.ReadAsStringAsync().GetAwaiter().GetResult();
            Console.WriteLine($"取得服務內容:{result}\r\n"
                              + $"斷路器狀態:{state}");
            if (response.IsSuccessStatusCode)
            {
                Console.WriteLine("斷路器,正常完成");
            }
        }
    }
    catch (Exception e)
    {
        Console.WriteLine($"錯誤:{e.Message}\r\n"
                          + $"斷路器狀態:{state}");
    }
    finally
    {
        Console.WriteLine("");
    }
}

 

執行結果,當狀態是 Open 的時候,不允許執行任務,必須要等待 30 秒(前面設定的),斷路器變成 Close,才能再次執行任務,如下圖

 

超時 (Timeout)

當系統超過一定時間的等待,幾乎可以判斷不可能會有成功的結果。例如,平時一個網絡請求瞬間就完成了,如果有一次網絡請求超過了 30 秒還沒完成,大概就知道這次不會成功了,這時就可以使用超時策略,避免系統長時間做無所謂的等待。

兩種 TimeoutStrategy

樂觀 (Optimistic):預設為 Optimistic,支援取消 (CancellationToken),捕捉 OperationCanceledException 和 CancellationTokenSource.IsCancellationRequested 來表示超時,大多數的 .NET 類別都是使用這種方式。

悲觀 (Pessimistic):某些情況可能沒有支援取消,允許強制執行超時。
 

下面是一個超時策略的使用方式,超過 30 秒,發生錯誤會進入回呼。

Policy.Timeout(30, onTimeout: (context, timespan, task) =>
{
    // call back
});

參考:https://github.com/App-vNext/Polly/wiki/Timeout

接下來演練觸發樂觀 (Optimistic) TimeOut,先使用 HttpClient 產生超時例外。

private static string TimeoutRequest()
{
    Console.WriteLine("請求網路資源中...");
    var baseAddress = "http://localhost:9527";

    // 釋放掉 HttpClient 是不對的作法,這裡只是為了模擬超時所而產生的代碼
    using (var client = new HttpClient {BaseAddress = new Uri(baseAddress)})
    {
        client.BaseAddress = new Uri(baseAddress);
        client.Timeout     = TimeSpan.FromMilliseconds(10);
        var response = client.GetAsync("api/value")
                             .GetAwaiter()
                             .GetResult();
        if (response.IsSuccessStatusCode)
        {
            var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
            return result;
        }
    }

    return null;
}
釋放掉 HttpClient 是不對的作法,這裡只是為了模擬超時所而產生的代碼


 

最後,用按鈕呼叫它,當超時例外出現時,會進入  Policy.Timeout,我在這裡印出錯誤訊息

private static void _03_樂觀超時()
{
    var timeoutPolicy = Policy.Timeout(TimeSpan.FromMilliseconds(1),
                                       (context, timespan, task, ex) =>
                                       {
                                           var errorMsg = $"錯誤訊息:{ex.Message}"
                                                          + $"錯誤目標:{ex.TargetSite}";
                                           Console.WriteLine($"逾時時間:{timespan},錯誤:{errorMsg}");
                                       });

    try
    {
        timeoutPolicy.Execute(TimeoutRequest);
        Console.WriteLine("沒有超時,完成");
    }
    catch (Exception e)
    {
        Console.WriteLine($"超時錯誤:{e.Message}");
    }
}

 

不透過 HttpClient 也可以這樣模擬超時,手動建立 TaskCanceledException 或 OperationCanceledException。

TaskCanceledException 實作了 OperationCanceledException

timeoutPolicy.Execute(() =>
                      {
                          var cancelSource = new CancellationTokenSource();
                          var task = new Task(() => { Console.WriteLine("模擬超時例外"); },
                                              cancelSource.Token);
                          cancelSource.Cancel();
                          var ex = new TaskCanceledException(task);
                          throw ex;
                      });
timeoutPolicy.Execute(() =>
                      {
                          var cancelSource = new CancellationTokenSource();
                          cancelSource.Cancel();
                          var ex = new OperationCanceledException(cancelSource.Token);
                          throw ex;
                      });

 

Polly.TimeOut 攔截 OperationCanceledException 例外且判斷是否有觸發 Cancel,最後再重新引發 TimeoutRejectedException 例外


悲觀 (Pessimistic) TimeOut ,使用時間來決定是否超時

private static void _03_悲觀超時()
{
    var timeoutPolicy = Policy.Timeout(TimeSpan.FromSeconds(3),
                                       TimeoutStrategy.Pessimistic,
                                       (context, time, task, ex) =>
                                       {
                                           var errorMsg = $"錯誤訊息:{ex.Message}"
                                                          + $"錯誤目標:{ex.TargetSite}";
                                           Console.WriteLine($"逾時時間:{time}\r\n錯誤:{errorMsg}");
                                       });
    try
    {
        timeoutPolicy.Execute(() =>
                              {
                                  Console.WriteLine("請求網路資源中...");
                                  Thread.Sleep(TimeSpan.FromSeconds(5));
                              });

        Console.WriteLine("沒有超時,完成");
    }
    catch (Exception e)
    {
        Console.WriteLine($"超時錯誤:{e.Message}");
    }
}

 

隔離 (Bulkhead Isolation)

當系統的一處出現故障時,可能引發多個錯誤,很容易耗盡主機的資源(如 CPU、RAM、執行緒),將操作限制在一個固定大小的資源池中,隔離有潛在可能相互影響的操作。

以下是隔離策略的用法,表示最多允許 12 個執行序執行,如果執行任務被拒絕,則執行回呼。

Policy.Bulkhead(12, context =>
{
    // do something
});

 

參考:https://github.com/App-vNext/Polly/wiki/Bulkhead

 

完整範例如下:

private static void _04_隔離()
{
    var bulkheadPolicy = Policy.Bulkhead(1, 1, context =>
                                               {
                                                   var msg = $"Reject:{context.PolicyKey}";
                                                   Console.WriteLine(msg);
                                               });

    Console.WriteLine("請求網路資源中...");
    
    Task.Factory
        .StartNew(() =>
                  {
                      bulkheadPolicy.Execute(() =>
                                             {
                                                 Console.WriteLine("1.Execute Task,休息一下");
                                                 Thread.Sleep(TimeSpan.FromSeconds(5));
                                             });
                  });

    Task.Factory
        .StartNew(() =>
                  {
                      bulkheadPolicy.Execute(() =>
                                             {
                                                 Console.WriteLine("2.Execute Task");
                                             });
                  });

    Task.Factory
        .StartNew(() =>
                  {
                      bulkheadPolicy.Execute(() =>
                                             {
                                                 Console.WriteLine("3.Execute Task");
                                             });
                  });

    Console.WriteLine("隔離,完成");
}

 

回退 (Fallback)

當錯誤發生時,執行備用方案。語法如下:

Policy.Handle<Whatever>()
   .Fallback<UserAvatar>(() => UserAvatar.GetRandomAvatar())

 

參考:https://github.com/App-vNext/Polly/wiki/Fallback

 

完整範例

private static void _05_回退()
{
    var policy = Policy.Handle<HttpRequestException>()
                       .Fallback(() =>
                                 {
                                     Console.WriteLine("回退策略,執行解決方案,請求另一個備援服務位置");
                                     Console.WriteLine("請求網路資源 http://localhost:9528");
                                 });
    policy.Execute(() =>
                   {
                       Console.WriteLine("請求網路資源 http://localhost:9527,出現異常...");
                       throw new HttpRequestException("機房發生火災");
                   });
    Console.WriteLine("回退策略,完成");
}


快取 (Cache)

一般我們會把頻繁使用且不會怎麽變化的資源快取起來,以提高系統的響應速度,一般常見的快取邏輯是先判斷快取中有沒有這些資料,有的話就回傳;沒有的話就從資料庫讀取,放到快取,回傳結果,Polly 的快取策略也有這樣的機制

參考::https://github.com/App-vNext/Polly/wiki/Cache

 

Cache 需要依賴 Polly.Caching.Memory,也依賴 Microsoft.Extensions.Caching.Memory

 

完整範例如下:

需要安裝以下套件

Install-Package Polly.Caching.Memory
Install-Package Newtonsoft.Json
Install-Package FakeData

"datakey" 是存取快取的 key

private static void _06_緩存()
{
    var memoryCache         = new MemoryCache(new MemoryCacheOptions());
    var memoryCacheProvider = new MemoryCacheProvider(memoryCache);
    if (s_cachePolicy == null)
    {
        s_cachePolicy = Policy.Cache(memoryCacheProvider, TimeSpan.FromSeconds(5));
    }

    var result = s_cachePolicy.Execute(context =>
                                       {
                                           Console.WriteLine("快取過期,更新快取內容");
                                           var names  = new List<string>();
                                           var number = FakeData.NumberData.GetNumber(1, 10);
                                           for (var i = 0; i < number; i++)
                                           {
                                               names.Add(FakeData.NameData.GetFullName());
                                           }

                                           return names;
                                       }, new Context("datakey"));
    var json = JsonConvert.SerializeObject(result);
    Console.WriteLine($"取得資料:{json}");
    Console.WriteLine("隔離策略,完成");
}

 

策略包 (Policy Wrap)

一種操作可能會有多種不同的故障,而不同的故障處理需要不同的策略,Polly 可以將這些不同的策略包在一起,基本用法如下

var policyWrap = Policy.Wrap(fallback, cache, retry, breaker, timeout, bulkhead);
policyWrap.Execute(...);

 

完整範例如下

用起來蠻簡單的,比較值得注意的是策略的執行順序,比如 Policy.Wrap(fallbackPolicy, waitAndRetry),會先執行 waitAndRetry → fallbackPolicy

private static void _07_策略包()
{
    var fallbackPolicy = Policy.Handle<HttpRequestException>()
                               .OrResult<HttpResponseMessage>(p => p.IsSuccessStatusCode == false)
                               .Fallback(() =>
                                         {
                                             Console.WriteLine("執行回退策略,請求另一個備援服務位置");
                                             Console.WriteLine("請求網路資源 http://localhost:9528/api/values");
                                             var response = new HttpResponseMessage(HttpStatusCode.OK)
                                             {
                                                 Content = new StringContent("OK,換個位置就可以了")
                                             };
                                             return response;
                                         });

    var waitAndRetry = Policy.Handle<HttpRequestException>()
                             .OrResult<HttpResponseMessage>(r => r.IsSuccessStatusCode == false)
                             .WaitAndRetry(3,
                                           retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                                           (response, retryTime, context) =>
                                           {
                                               Console.WriteLine($"執行重試策略,重試時間 {retryTime}");
                                           });

    var policyWrap = Policy.Wrap(fallbackPolicy, waitAndRetry);
    var wrapResponse = policyWrap.Execute(() =>
                                          {
                                              Console.WriteLine("向 http://localhost:9527/api/values 請求資源");
                                              var response = new HttpResponseMessage(HttpStatusCode.BadGateway);
                                              return response;
                                          });
    var result = wrapResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();
    Console.WriteLine($"結果:{result}");
    Console.WriteLine("策略包,完成");
}

 

範例位置

https://github.com/yaochangyu/sample.dotblog/tree/master/Retry/Polly/Lab.RetryPolly

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo