しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Durable Functions のエラーハンドリングと向き合う

Durable Functions は async / await で複雑なフローを簡単に書けるので便利ですが、エラーハンドリング周りに少し癖があるので基本的な処理方法をまとめておきます。

ドキュメントはありますが、基本的すぎることしか書いてないです。

以下の内容は Durable Functions v1.8 以降を対象としているので、これ以前のバージョンでは意図したとおりに動かなかったり、インターフェースが実装されていなかったりします。

例外周りの挙動が広い範囲で直っているので v1.8 へのアップデートは割とお勧めです。v1.x 系リリースはこれで終わりで、次は v2 になるらしいです。

今回の内容を要約すると以下のような感じです。

  • Activity では普通の例外として扱える
  • Orchestrator では FunctionFailedException にラップされる
  • RetryOptions#Handle を上手く利用する
  • Orchestrator 自体の失敗は ILifeCycleNotificationHelper でキャッチする

サンプルコードではとりあえず Exception で処理してることが多いので、本番向けではもっと例外の種類によって処理を変えていこうよ、という話でもあります。

基本的なエラーハンドリング

Durable Functions の中でも Activity は普通のメソッドとして実行されるので、普通の方法で例外をハンドリングできます。catch 後は再 throw しないと Activity が成功扱いになるので注意。

[FunctionName("TestActivity")]
public static async Task TestActivity([ActivityTrigger] DurableActivityContext context, ILogger log)
{
    try
    {
        // 例外が起きるかもしれない処理
        await _httpClient.GetAsync("https://blog.azure.moe/");
    }
    catch (HttpRequestException ex)
    {
        // 普通に try~catch で特定の例外を処理できる
        log.LogError(ex.Message);

        throw;
    }
}

実行してみても、特に変わったことなく意図したとおりの挙動となります。

f:id:shiba-yan:20190306191119p:plain

ただし Orchestrator では扱いが変わってきます。v1.x の場合は Activity で投げられた例外は Orchestrator では FunctionFailedException にラップされた形で投げられるので、実際の例外を調べるには InnerException を見る必要があります。

C# 6 からは例外フィルターが使えるので、以下のようにシンプルに書くことが出来ます。

[FunctionName("TestOrchestrator")]
public static async Task TestOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context, ILogger log)
{
    try
    {
        await context.CallActivityAsync("TestActivity", null);
    }
    catch (FunctionFailedException ex) when (ex.InnerException is HttpRequestException httpRequestException)
    {
        // FunctionFailedException で wrap されているので when で InnerException まで確認する
        log.LogError(httpRequestException.Message);

        throw;
    }
}

ちゃんと例外フィルターが有効になっているのでハンドリング出来ています。

f:id:shiba-yan:20190306191134p:plain

これも例によって catch 後に再 throw しないと成功扱いになるので注意が必要です。Durable Functions の Orchestrator を失敗扱いにするためには、例外を投げるしか今のところ方法がないです。

そして Sub Orchestrator を使った場合にはまた挙動が変わってきます。Orchestrator が入れ子になっているので、例外も FunctionFailedException が入れ子になってやってきます。

[FunctionName("TestSubOrchestrator")]
public static async Task TestSubOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context, ILogger log)
{
    try
    {
        await context.CallSubOrchestratorAsync("TestOrchestrator", null);
    }
    catch (FunctionFailedException ex)
    {
        // Sub Orchestrator の場合は FunctionFailedException が入れ子になる
        var innerException = (FunctionFailedException)ex.InnerException;

        // 2 つ辿れば Activity で発生した例外になる
        var httpRequestException = (HttpRequestException)innerException.InnerException;

        log.LogError(httpRequestException.Message);

        throw;
    }
}

実行すると FunctionFailedException を 2 つ辿ることで、実際に投げられた例外を確認できています。

f:id:shiba-yan:20190307164207p:plain

少し複雑ですが、v1.x 系ではこのように処理するしかないです。v2 からは FunctionFailedException でのラップが削除される予定なので、もっとシンプルに書けるようになるはずです。

リトライとエラーハンドリング

Durable Functions は組み込みで Activity と Sub Orchestrator のリトライ機能を持っているので、専用のメソッドを使って実行すれば以下のようにシンプルな形で書くことが出来ます。

[FunctionName("TestOrchestrator")]
public static async Task TestOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context, ILogger log)
{
    try
    {
        // 5 秒間隔で 3 回までリトライする
        await context.CallActivityWithRetryAsync("TestActivity", new RetryOptions(TimeSpan.FromSeconds(5), 3), null);
    }
    catch (FunctionFailedException ex) when (ex.InnerException is HttpRequestException httpRequestException)
    {
        log.LogError(httpRequestException.Message);

        throw;
    }
}

[FunctionName("TestActivity")]
public static async Task TestActivity([ActivityTrigger] DurableActivityContext context, ILogger log)
{
    // 例外が起きるかもしれない処理
    var response = await _httpClient.GetAsync("https://httpstat.us/500");

    response.EnsureSuccessStatusCode();
}

上の例では Activity が失敗すると 5 秒開けて 3 回までリトライを行います。リトライに失敗すると、最後に発生した例外が自動的に投げられるので try~catch で簡単に処理できるのは同じです。

リトライを行うか制御する

何回か実行して成功する可能性のあるネットワーク系などの一時的なエラーとかであれば一律のリトライで問題ないですが、中にはリトライしても絶対に成功しない系のエラーもあります。

Durable Functions は RetryOptions#Handle を使うことで、特定の例外の場合のみリトライを行うように指定できますが、v1.8 より前のバージョンでは InnerException が常に Exception 型になってしまう問題があったので、簡単に書けませんでした。

今回は 50x 系の HTTP ステータスコードが返ってきたときはリトライを行いつつ、40x 系の場合はそもそもリクエストがおかしいとして即失敗にする例を用意しました。

[FunctionName("TestOrchestrator")]
public static async Task TestOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context, ILogger log)
{
    try
    {
        await context.CallActivityWithRetryAsync("TestActivity", new RetryOptions(TimeSpan.FromSeconds(5), 3)
        {
            // 特定の例外の場合だけリトライして、それ以外は即失敗にする
            Handle = ex => ex.InnerException is RetriableException
        }, null);
    }
    catch (FunctionFailedException ex) when (ex.InnerException is RetriableException)
    {
        log.LogError("リトライしたけどダメだった");

        throw;
    }
    catch (FunctionFailedException)
    {
        log.LogError("リトライ不可能なエラーだった");

        throw;
    }
}

[FunctionName("TestActivity")]
public static async Task TestActivity([ActivityTrigger] DurableActivityContext context, ILogger log)
{
    // 例外が起きるかもしれない処理(今回は常に 500 を返すように)
    var response = await _httpClient.GetAsync("https://httpstat.us/500");

    if (!response.IsSuccessStatusCode)
    {
        // 5xx の場合は一時的な問題としてリトライしたい
        if ((int)response.StatusCode >= 500)
        {
            throw new RetriableException();
        }

        // それ以外の場合はリトライせず失敗にしたい
        response.EnsureSuccessStatusCode();
    }
}

リトライをするべきか伝える例外を用意して判別に使います。こういう時のテストには httpstat.us を使うと任意のステータスコードを返すことが出来るので便利です。

500 エラーを返すようにして実行すると、指定した回数リトライされた後に例外が投げられて Orchestrator が失敗します。テストなので成功することはないので、リトライが行われていれば確認としては OK です。

f:id:shiba-yan:20190306203531p:plain

次に 400 エラーを返すようにして再実行すると、リトライが行われることなく即座に Orchestrator が失敗します。無駄なリトライを行うことなくすぐに失敗するので待たされることはありません。

f:id:shiba-yan:20190306203557p:plain

今回は専用の例外を用意しましたが、例外の型や例外が持つプロパティで判別できる場合はそれで行えば良いです。Cosmos DB や ARM API などではスロットリング発生時のみリトライするなどが書けるはずです。

オーケストレーターの失敗を検知する

前にオーケストレーターの失敗を検知するための方法をいくつか考えて紹介しましたが、v1.8 からは LifeCycle Event 周りを拡張して独自の通知処理を組み込めるようになりました。

なので ETW Listener や Event Grid を用意しなくても使えます。

利用方法はシンプルに ILifeCycleNotificationHelper を実装したクラスを用意して、host.json に追加するだけです。適当に以下のような実装を用意しておきました。

public class InProcLifeCycleNotificationHelper : ILifeCycleNotificationHelper
{
    public Task OrchestratorStartingAsync(string hubName, string functionName, string instanceId, bool isReplay)
    {
        return Task.CompletedTask;
    }

    public Task OrchestratorCompletedAsync(string hubName, string functionName, string instanceId, bool continuedAsNew, bool isReplay)
    {
        return Task.CompletedTask;
    }

    public Task OrchestratorFailedAsync(string hubName, string functionName, string instanceId, string reason, bool isReplay)
    {
        return Task.CompletedTask;
    }

    public Task OrchestratorTerminatedAsync(string hubName, string functionName, string instanceId, string reason)
    {
        return Task.CompletedTask;
    }
}

ちなみに OrchestratorTerminatedAsync は不具合で今は発火しないようです。*1

host.json には CustomLifeCycleNotificationHelperType に用意した型とアセンブリ名を指定します。この辺りは v2 で大きく書き方が変わってくると思いますが、v1 ではこんな感じです。

{
  "version": "2.0",
  "extensions": {
    "durableTask": {
      "CustomLifeCycleNotificationHelperType": "FunctionApp47.InProcLifeCycleNotificationHelper, FunctionApp47"
    }
  }
}

実行して適当に Orchestrator を起動すると該当するメソッドが呼び出されます。以下は失敗した場合です。

f:id:shiba-yan:20190307165056p:plain

これで Orchestrator が失敗した場合に Webhook などで通知できるようになります。失敗した理由も取れるので、情報としてはまあまあ足りると思います。

これでようやく Let's Encrypt 証明書の発行失敗時に通知する機能が実装出来ます。

*1:多分未実装な気がする