ねののお庭。

かりかりもふもふ。

【C#】StandardResilienceHandler で HTTP リクエストの回復力を高める。

クラウドでアプリケーションを動かす上で HTTP リクエストの回復力を高める事は極めて重要です。それは何故か?

クラウドでアプリケーションを動かすという事は、ホスティング環境は動的であり、ネットワークの問題やサーバー側の問題など様々な問題によって HTTP リクエストが失敗する可能性があるという事です。 こうした失敗は一時的なものが多く、リトライなどを上手く挟み回復力を高める事によって、サービス全体として正常に動作させる事ができます。 また回復力が乏しいアプリケーションの場合、とあるリクエストの失敗が原因で、サービス全体として障害が起きてしまう可能性があります。 これを Cascading failure といったりしますが、このような障害を避けるためにも回復力は重要です。Cascading failure については下の gif を見るとイメージが付きやすいかと思います。

ちなみにこの「回復力」という語彙は resilience(名詞) / resilient(形容詞) の訳語です。「弾力性」などといった訳が当たっている事もありますが、この記事では「回復力」という訳語で統一します。 「resilient HTTP requests」「resilience strategies」「resilience pipelines」などといった形で頻繁に使われます。

さてこの回復力を高めるというは、フルスクラッチでまともに実装するのはかなり大変です。 そこでいままで C# でのアプリケーション開発においては Polly を用いる事で回復力を高めていました。 Polly は IHttpClientBuilder との統合も AddPolicyHandler で簡単に行うことが出来るため便利でした。 が、何をどの程度構成して回復力を高めよう、というのは結構悩ましかったりします。

そんな中、.NET 8 のリリースとともに Microsoft.Extensions.Http.Resilience パッケージが公開され、以下のように極めて簡単に回復力を高める標準的(Standard)な構成を行う事が出来るようになりました。この「標準的な」というのが地味にうれしい。 本記事では AddStandardResilienceHandler() で追加される StandardResilienceHandler について触れていきます。 なお、StandardResilienceHandler という型があるわけではなく、AddStandardResilienceHandler() によって追加される Standard な構成がなされている ResilienceHandler 型のインスタンスの事を StandardResilienceHandler とこの記事内では呼称しています。

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler();
});

ちなみに ConfigureHttpClientDefaults() については前回の記事 で詳しく解説しているので気になったら是非。

Polly v8

StandardResilienceHandler を理解する前に、まず Polly v8 を軽く理解しておく必要があります。

Polly v8 は 2023/09 にリリースされました。.NET 8 リリースよりちょっと前くらいの時期ですね。 Polly v7 から Polly v8 は互換は維持されているものの、コアの API にはかなり大きな変更があります。 また Polly v7 までは async のサポートや CancelationToken のサポートが完璧でなかったり、一部パフォーマンス上の問題も抱えていましたが、それら多くの問題が v8 では解決しています。万歳。

v7 から v8 への大きな変更点は以下の通り。

  • v7 まで Policy と呼ばれていたものは v8 からは Strategy と呼ばれるように
  • Resilience Pipeline の導入
    • Resilience Pipeline は一つ以上の Strategy が組み合わさったもの
    • ResiliencePipeline 及び ResiliencePipeline<T> が v8 からの基盤となる型
  • 完全な async サポート
  • 完全な CancelationToken サポート
  • Options ベースの構成
    • Microsoft.Extensions 系の API に慣れている場合、極めて親しみやすい形に
  • Telemetry のビルトイン
  • パフォーマンスの向上

Polly v7 と v8 の叩き方の違いはおおむね以下のような感じ。v8 の方が洗練されていますね...!

// Polly v7
IAsyncPolicy<HttpResponseMessage> asyncPolicyT = Policy<HttpResponseMessage>
    .HandleResult(result => !result.IsSuccessStatusCode)
    .WaitAndRetryAsync(3, _ => TimeSpan.FromSeconds(1));

await asyncPolicyT.ExecuteAsync(async token =>
{
    // このデリゲート内で HTTP リクエストを飛ばしたりする
    return await GetResponseAsync(token);
}, cancellationToken);
// Polly v8
ResiliencePipeline<HttpResponseMessage> pipelineT = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<Exception>()
            .HandleResult(static result => !result.IsSuccessStatusCode),
        Delay = TimeSpan.FromSeconds(1),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Constant
    })
    .Build();

await pipelineT.ExecuteAsync(static async token =>
{
    // このデリゲート内で HTTP リクエストを飛ばしたりする
    return await GetResponseAsync(token);
}, cancellationToken);

AddStandardResilienceHandler

冒頭でも紹介しましたが、ConfigureHttpClientDefaults()AddStandardResilienceHandler() を併用する事で、IHttpClientFactory から作成される全ての HttpClient 及び HttpMessageHandler に StandardResilienceHandler を追加でき、とても簡単に回復力を高められます。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler();
});

AddStandardResilienceHandler() では回復力を高めるために以下のような構成が行われます。

  • Rate limiter
    • 依存関係に送られる HTTP リクエストの同時実行最大数を制限
    • 既定
      • 最大同時実行数: 1000
  • Total timeout
    • リトライにかかった時間等込々のトータルのリクエスト時間のタイムアウト設定
    • 既定
      • タイムアウト: 30 sec
  • Retry
    • 既定
      • 最大リトライ数: 3
      • バックオフ: Exponential
      • ジッター: 有効
      • 遅延: 2 sec
  • Circuit breaker
    • 既定
      • エラー率: 10%
        • このエラー率を超えると circuit breaker が open になる
      • サンプリング間隔: 30 sec
      • 最低スループット: 100
        • サンプリング間隔内に最低スループット数を超えない場合、エラー率がどれだけ高かろうと circuit breaker が open になる事はない。
        • より具体的には、既定では 30 sec 以内に 100 以上のリクエスト(スループット)が存在した上でエラー率が 10% を超えて初めて circuit breaker は open になる、という事。
      • ブレイク間隔: 5 sec
        • circuit breaker が open になっている間隔
  • Attempt timeout
    • 各リクエスト毎のタイムアウト
    • 既定
      • タイムアウト: 10 sec

実装上は上記の各項目に対応する Polly の Strategy が組み込まれた ResiliencePipeline<T> を内部に持つ DelegatingHandler が最終的に用いる HttpMessageHandler に差し込まれている、という感じです。 AddStandardResilienceHandler() を叩くだけなら全く Polly は意識しないで済みますが、実際どのように回復力を高めるための構成がなされているかを理解するためには Polly を最低限は知っておく必要があります。

なお、Retry 及び Circuit breaker は特定の HTTP status code の場合にのみ発火します。

  • HTTP 500 以上 (Server errors)
  • HTTP 408 (Request timeout)
  • HTTP 429 (Too many requests)

ちなみに AddStandardResilienceHandler()以下のような実装になっています。 AddStandardResilienceHandler() 内部で叩いている AddResilienceHandler() が、回復力の高い HttpMessageHandler を構成するための基本的な API です。 なので AddStandardResilienceHandler() の構成が気に入らない場合は AddResilienceHandler() を叩いて自分たちのアプリケーションにとって適切な回復力を高めるための構成しましょう。

public static IHttpStandardResiliencePipelineBuilder AddStandardResilienceHandler(this IHttpClientBuilder builder)
{
    _ = Throw.IfNull(builder);

    var optionsName = PipelineNameHelper.GetName(builder.Name, StandardIdentifier);

    _ = builder.Services.AddOptionsWithValidateOnStart<HttpStandardResilienceOptions, HttpStandardResilienceOptionsCustomValidator>(optionsName);
    _ = builder.Services.AddOptionsWithValidateOnStart<HttpStandardResilienceOptions, HttpStandardResilienceOptionsValidator>(optionsName);

    _ = builder.AddResilienceHandler(StandardIdentifier, (builder, context) =>
    {
        context.EnableReloads<HttpStandardResilienceOptions>(optionsName);

        var monitor = context.ServiceProvider.GetRequiredService<IOptionsMonitor<HttpStandardResilienceOptions>>();
        var options = monitor.Get(optionsName);

        _ = builder
            .AddRateLimiter(options.RateLimiter)
            .AddTimeout(options.TotalRequestTimeout)
            .AddRetry(options.Retry)
            .AddCircuitBreaker(options.CircuitBreaker)
            .AddTimeout(options.AttemptTimeout);
    });

    return new HttpStandardResiliencePipelineBuilder(optionsName, builder.Services);
}

SelectPipelineBy / SelectPipelineByAuthority

さて、StandardResilienceHandler が実際どのような処理をするのか分かったわけですが、各 Strategy に注目するとふと疑問がわいてきます。 それは、Rate limiter や Circuit breaker についてです。 Timeout 及び Retry は各リクエスト毎に独立ですが、Rate limiter 及び Circuit breaker は各リクエスト毎に独立ではありません。 リクエストの流量制限(rate limit)であったり、スループットやエラー率を計算して Circuit breaker を open するわけですから、当然です。

ここで気になるのが「どういう単位で rate limit したり Circuit breaker を open したりするの?」という事です。 結論から言えば、これは Polly の Pipeline 単位で効いてきます。 そして次に「その Polly の Pipeline 単位 は何で決まってくるのか?」という疑問が湧いてきます。

まず、以下のように ConfigureHttpClientDefaults() 内で AddStandardResilienceHandler() を叩いている場合は、アプリケーション全体で単一の Pipeline が用いられる事になります。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler();
});

しかしこれでは、https://hoge.example.com を叩いても https://fuga.example.com を叩いても、単一な pipeline が用いられる事になります。 これが望ましいのであれば上記のままでも構わないのですが、この単位で流量制限をかけたり Circuit breaker を制御したいかといえば、まぁ殆ど No なのではないでしょうか。

ではどういう単位でそれらを制御したいかといえば、殆どの場合は authority (host) 単位なのではないでしょうか? このパターンは最もよくあるパターンという事で、ライブラリ側がその設定を入れるための拡張メソッドを用意してくれています。 それが SelectPipelineByAuthority() です。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler()
        .SelectPipelineByAuthority();
});

このようにすることで、https://hoge.example.comhttps://fuga.example.com それぞれで流量制限や Circuit breaker が制御されるようになります。殆どの場合はこれ使っておけばよいでしょう。

しかしながら、もっと細かく pipeline を制御したい!という場合もあるでしょう。 そのような場合は SelectPipelineBy() を使う事で実現できます。 SelectPipelineBy() の引数には、IServiceProvider を引数に取り Func<HttpRequestMessage, string> を返すデリゲートを渡してあげます。 このデリゲートは要するに HttpRequestMessage から Polly の pipeline を選択する key (string) を作っているのです。 この key 毎に pipipeline が分かれる事になります。

例えば path 毎に分離したい場合 (https://hoge.example.com/piyo1https://hoge.example.com/piyo2) 毎に pipeline を分けたい場合は以下のように記述します。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler()
        .SelectPipelineBy(serviceProvider =>
        {
            // Func<HttpRequestMessage, string> を返す
            // serviceProvider からいろいろ取得して HttpRequestMessage を捏ね捏ねする事も可能
            return static request =>
            {
                return request.RequestUri?.GetLeftPart(UriPartial.Path) ?? string.Empty;
            };
        });
});

これにより、望んだ単位で pipeline を制御する事ができます。

多重 ResilienceHandler

ResilienceHandler は重ねがけ出来ます。 何をいっているかというと、要するにこんなケース。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler();
});

builder.Services.AddHttpClient("MyHttpClient")
    .AddStandardResilienceHandler();

AddStandardResilienceHandler() 2 回叩くのちょっとアレなのでちょっとそれっぽく。

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddResilienceHandler("DefaultPipeline", (builder, context) =>
    {
        _ = builder
            .AddRateLimiter(...)
            .AddRetry(...)
            .AddCircuitBreaker(...)
    });
});

builder.Services.AddHttpClient("MyHttpClient")
    .AddResilienceHandler("MyPipeline", (builder, context) =>
    {
        _ = builder
            .AddTimeout(...)
    });

このようにすると、ResilienceHandler が 2 重で挟まっている事が見て取れます。

なお今のところ ConfigureHttpClientDefaults() で構成した ResilienceHandler をそれぞれの named http client / typed http client で無効にする事ができないので、それぞれの named http client / typed http client でまったく別の ResilienceHandler を設定したい場合は、ConfigureHttpClientDefaults() 内で ResilienceHandler を構成するのはやめましょう。

まとめ

StandardResilienceHandler は便利!という事で活用していきましょう。 そしておそらく殆どの場合で SelectPipelineByAuthority() を入れた方がいいので、これも使っていきましょう...!

builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler()
        .SelectPipelineByAuthority();
});

References