[Step Functions] なんとなくStates.ALLにしちゃってない?ステートマシンでのエラー取り回しを理解する!

ステートマシンでエラーを捕捉する動作について調べておいたよ。
2023.05.19

Step Functionsステートマシンでは、各ステートでエラーが発生した時に、 同じステートをリトライさせたり、 特定のエラーの場合は別の処理をさせたりなどの分岐を行うことができます。 このようなエラーの捕捉の仕方についてきちんと理解できていなかったのですが、 ある程度納得できるところまで理解できたのでまとめておきたいと思います。

さて、早速まとめをする前に、 少しわかりにくいというかミスリードだと思った部分を見てみましょう。 Workflow Studioで捕捉するエラー名を指定しようとすると、こんな感じに表示されます。

States.〜〜〜というものの中から選択する(ことしかできない)という感じに見えます。 また、ドキュメントを見ても、 エラー名としてStates.〜〜〜というものしか存在しないようにも見えてしまいます。

これだけを見ると、ステートマシンのエラー捕捉の仕組みは、 ステートレベルでのエラー情報しか利用できない (ステートマシンから呼ばれたLambdaがどんなエラーで失敗したのかによる分岐ができない) ようにも見えてしまいます。 しかし実際はそんなことはなく、 Lambdaで起きた例外の名前を捕捉してそれによって処理を分けることができます。

基本の動作

基本の基本をおさらいします。

ステートマシンのエラー名によって動的に処理を行うことができるのは

  • リトライ(エラー内容によるリトライの要否)
  • エラーキャッチ(エラー内容によって次のステートを変化させる)

の2つがあります。 基本的な考え方として、「hogehoegというエラー名がきたらリトライ/エラーキャッチする」という定義の仕方をします。 条件に合致しなかったら「リトライしない/デフォルトの次ステートに進む」という動作となります。 条件はそれぞれ複数書くことができ、いずれかの条件に合致するかというOR条件となります。 (実際に送出されるエラーの種類は1種類なので、AND条件だと絶対合致しない)

さて、ステートマシンのエラーの取り扱いにおいては、当たり前ですが、 エラーを送出する側と、 そのエラー名を受け取る側とに分けて考える必要があります。

まずはエラー名を受け取る側から見てみます。 理由としては、こっちは至って簡単だからです。

エラー名を受け取る側

原則

エラー名を受け取る側の設定の基本原則はこうです。

  • 好きな文字列を書ける
  • 完全一致した時だけ捕捉する

原則というか、これだけ覚えとけばほぼOKという感じなので、 恐ろしく時間がない人はここまで読んだらそっ閉じしてください。

ということで、これを理解すればほぼ95%理解したと言えます。 解説するまでもないことですが、少し補足すると、

  • 好きな文字列を書ける
    • 画面だといくつかの選択肢から選ぶように見えますが、任意の文字列を書けます
  • 完全一致した時だけ捕捉する
    • 一部の例外を除き、指定した文字列とエラー名が完全一致した時だけ捕捉されます

という感じです。 そのまんますぎますね。それっぽく文章を水増しするのも大変なレベルです。

認識すべき例外

エラー名を受け取る側の例外として、以下は理解しておきましょう。

  • States.ALLState.TaskFailedは特別
    • この2つは、「完全一致した時だけ捕捉する」の原則に反して、複数のものに合致する
    • States.ALL: 全てのエラー名に一致する
    • State.TaskFailed: States.Timeout以外の全てのエラー名に合致する
  • States.Runtimeのエラーについては、どう書いても捕捉できない
    • States.Runtimeはある限られた状況で発生するエラー
    • このエラーは捕捉条件との突合が行われないので、何を書いても捕捉できない(と考えるのが良さそう)

なお、動作を理解するためには例外という扱いですが、 実際はこれらの要素を考慮しなければいけない機会は多いです...(語気弱め)

原則からわかること

上記のルールが愚直に適用されますので、例えば以下のような挙動をすることがわかります。

  • 先頭のStates.は人間にわかりやすくする工夫であって、ただの文字列の一部に過ぎない
    • 接頭辞をつけなければいけないルールはない(=好きな文字列を書ける)
    • States.ALLLambda.UnknownErrorだろうがhogehogeだろうが合致する
      • States.で始まるものに合致」ではない
  • States.*Lambda.ALLのように書いて特殊な動きを期待しても、ただの文字列として処理される
    • States.ALLは、あくまでもこれ全体で特殊扱いなのであって、ALLに特別な能力はない

とにかく機械的に処理されているんだと思えば単純明快ですね。 エラー名を受け取る側の話は以上です。

エラー送出側

エラー名を受け取る側の話はもう完璧に理解できましたね。 次にエラーを送出する側について見ていきます。

ステートマシン内ではさまざまな理由によってエラーが送出されます。 Lambda関数を(同期的に)呼び出すステートを題材に、 1つのステートの開始から終了まででエラーが送出されるタイミングをいくつか見て行きましょう。

Parametersで存在しない変数の参照

Parametersでパラメータを指定しているものの、 そこで参照している変数が存在しないパターンです。

こんな感じのステートマシン定義です。 NOT_FOUND_VARに代入するべきaaaが未定義なのでエラーとなります。

Parametersで失敗するステートマシン定義

{
  "Comment": "A description of my state machine",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:123456789012:function:cm-hirano-stepfunctions-lambda-sync:$LATEST",
        "NOT_FOUND_VAR.$": "$.aaa"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "End": true
    }
  }
}
An error occurred while executing the state 'Lambda Invoke' (entered at the event id #2). The JSONPath '$.aaa' specified for the field 'NOT_FOUND_VAR.$' could not be found in the input '{
    "Comment": "Insert your JSON here"
}'

この場合はStates.Runtimeが送出されます。 絶対に捕捉されないやつです。

また、ステートマシンの実行状況を見ると、Lambdaを呼ぼうとしているステートの色は白いままです。

Parametersの作成は、Lambda関数に入力する引数を作っている段階なので、 これは「ステートが開始する前」という位置付けだと考えられます。 この辺は私が以前書いたブログも参照して頂くとより理解が深まるかと思います。

[StepFunctions]ParametersやらResultPathやら…。ステート間のパラメータ受け渡しって結局どうなってるの?を1つの図にしてみた。

これは個人的な推測ですが、 ここでの挙動は、「States.Runtimeだから捕捉されない」というよりも、 「ステート自体の処理が開始されていないから捕捉されない」と考えた方が適切なのかなと思っています。 リトライやエラーキャッチの定義はあくまでも一つのステートに対してするものなので、 このエラーはそもそもステートの本処理が開始される前に発生していて、 これから処理を開始しようとしているステートの定義とは無関係だと考えるとしっくりきます。

Lambdaの名前が間違ってる

次に、入力する引数の準備が終わってLambda関数を呼ぼうとする段階で、 LambdaのARNとして存在しないLambdaを指定したような場合です。 こんなステートマシンです。

Lambdaの名前が間違っているステートマシン定義

{
  "Comment": "A description of my state machine",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:123456789012:function:cm-hirano-stepfunctions-lambda-sync-xxx:$LATEST"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "End": true
    }
  }
}
Function not found: arn:aws:lambda:ap-northeast-1:123456789012:function:cm-hirano-stepfunctions-lambda-sync-xxx:$LATEST (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: 483b25a9-f46c-42e0-b0a8-27e0b20fbc0d; Proxy: null)

この場合はLambda.ResourceNotFoundExceptionというエラーが送出されます。 Lambda.で始まっていますが、States.ALLには合致するルールなので、この定義だとリトライが走ります。 もちろん存在しないLambdaを指しているので何度やってもエラーとなってしまいますが。

ちなみにこの時ステートの色としては赤になっています。 赤は失敗を表しているので、このステートは立派に(?)開始され、 そして立派に失敗したことを表しています。

Lambda呼び出し失敗

次に、Lambdaを呼び出そうとしたけれど呼び出しに失敗したパターンです。 呼び出しの権限不足など、これが発生する原因はいくつか考えられますが、 Lambdaの呼び出しがなぜかタイムアウトするというものも考えられます。 これはおそらく滅多に発生することはないかと思うのですが、 先日これが発生していたっぽいので、わざわざこれを取り上げようと思います(笑)

送出されたエラーは Lambda.ClientExecutionTimeoutException でした。 これはAWS基盤の稀な事象であり、単純にリトライをすることで正常に動作するような事態です。 ですので、このようなエラーはリトライで捕捉してあげたい所です。

Lambda内での例外発生

無事Lambda関数の実行が開始されたあと、 Lambda内で例外が発生した場合はその内容がそのままエラー名として上がってきます。

ここはちょっと意外にも思えるところですが、 ここまでStates.Lambda.で始まる「あらかじめ用意されたエラー名」が登場していましたが、 ここでは任意の文字列をエラー名として発生させることができます。

例えばPythonで0除算するとエラーはこんな感じになります。 この場合ZeroDivisionErrorが条件比較されるエラー名となります。

Lambda内で例外が発生した時の例

{
  "resourceType": "lambda",
  "resource": "invoke",
  "error": "ZeroDivisionError",
  "cause": {
    "errorMessage": "division by zero",
    "errorType": "ZeroDivisionError",
    "requestId": "832e641a-1f2c-4a38-a669-a0de7a64b209",
    "stackTrace": [
      "  File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n    print(1/x)\n"
    ]
  }
}

Lambdaタイムアウト

Lambdaがタイムアウトした場合です。 この場合は、Lambdaから正規ルートでのレスポンスがないと見做されるので Lambda.Unknownというエラー名になるようです。

Lambdaがタイムアウトした時の例

{
  "resourceType": "lambda",
  "resource": "invoke",
  "error": "Lambda.Unknown",
  "cause": "The cause could not be determined because Lambda did not return an error type. Returned payload: {\"errorMessage\":\"2023-05-17T09:21:30.973Z e21e95ee-82a8-472e-af81-00959ee426fe Task timed out after 15.02 seconds\"}"
}

もちろんLambda.UnknownStates.ALLという条件で捕捉できます。

ステートのタイムアウト

ステート自体のタイムアウトを設定して、それに引っかかった場合です。

ステートマシンがタイムアウトした時の例

{
  "resourceType": "lambda",
  "resource": "invoke",
  "error": "States.Timeout",
  "cause": null
}

States.Timeoutとなります。 これはStates.TaskFailedだと捕捉できない、 ちょっとだけ特別なやつです。

OutputPathで存在しない引数を指定

最後に、Lambdaでの処理が無事完了して、ステートとしての後始末をしている段階でのエラーです。 OutputPathとして存在しない変数名hogeを指定してみます。

Outputで失敗するステートマシン定義

{
  "Comment": "A description of my state machine",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:123456789012:function:cm-hirano-stepfunctions-lambda-sync:$LATEST"
      },
      "OutputPath": "$.hoge",
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "End": true
    }
  }
}
An error occurred while executing the state 'Lambda Invoke' (entered at the event id #2). Invalid path '$.hoge' : No results for path: $['hoge']

この場合はParametersの時と同じくStates.Runtimeが送出されます。 こちらはステートの色こそ赤になりますが、 Lambda関数の処理というステートの本処理が終了した後の処理でのエラーなので、 ステートで定義された条件とは突合が行われないと考えても良さそうです。

エラー送出側まとめ

以上、エラーが送出されるパターンについて見て来ました。

全てを調べることはできませんが、States.Runtimeが送出されるのは、 そのステートでの本処理の外側での処理で失敗した場合と考えて良さそうに見えます。 内部実装としてもStates.Runtimeを特別に除外する処理が書かれているというよりは、 そもそも文字列の一致を確認する処理フローに入らないのではないかとも思えます。 (あくまでも推察であって、どう解釈しても良いと思います)

リトライとエラー捕捉の関係

最後に、リトライとエラーキャッチの関係性についてです。

この両方で同じエラー文字列を捕捉するような定義を書いた場合はどうなるでしょうか? どちらが優先されるでしょう?

正解としては、

  1. まずリトライに捕捉され、
  2. リトライの上限回数を超えたらエラー側に捕捉される。

となります。

リトライには必ず上限回数が設定されますが、 その回数を超えた時は、そもそもリトライ捕捉の定義がなかった時のように振る舞うようです。 結果として、同じ捕捉文字列を書いていた場合はリトライ後にエラーキャッチされます。

おまけ: すっごく細かい話

States.Runtimeは捕捉できない

と書きました。

2023/06/20 追記

以下の記述はエラー名として Status.Runtime を送出することはできませんでした。 このコードで送出されるエラー名は「Exception」となります。

では、LambdaでこんなPythonコードを書くとどうなるでしょうか?

raise Exception("States.Runtime")

上述したように、Lambda内で発生した例外はそのままステートマシンに送られます。 よって、ステートマシンとしてはStates.Runtimeというエラー名が送られてきたことになります。

代わりに下記方法で確認を行い、やはり結果は変わらないことを確認しております。

では無理やりStates.Runtimeを送出してやったらどうなるでしょうか? ステートマシンからLambda等を非同期で呼び、コールバックを返すようにすることで、 無理やりStates.Runtimeというエラー名をステートマシンに送りつけることができます。 このやり方の詳細は、下記ブログをご参照ください。

[Step Functions + Glue] Glueジョブ終了時の出力を次のステートに流す。エラーハンドリングもできるよ!

この場合は果たしてどのような挙動になるでしょうか?

正解は、「このStates.Runtimeはただのエラー名の文字列として扱われる」です。 つまり、States.ALLなどで捕捉することができます。

よほど意地の悪い人でなければこんな実装はしないので忘れて良いと思います。 が、この挙動を見ても、 ステートの本処理外で発生したStates.RuntimeStates.ALLでも捕捉できないのは、 そもそも文字列突合の処理フローに入っていないから、 という推測が裏付けられたように思います。

なお、全く役に立たない補足情報ですが、 上記のやり方で無理やりStates.Runtimeをステートマシンに送出した場合、 そのステートマシンの実行画面をマネジメントコンソールで開こうとすると、 読み込み画面のまま先に進まなくなります。 ステートマシンの動作としてはただの文字列として扱われたのですが、 どうやらマネジメントコンソールの画面を描画する際にはこの文言が特別扱いとして使われているように思えます。 なお、AWS CLIを用いることで実行の内容情報を取得することはできますので、今回はそれで確認しています。

まとめ

ステートマシンのエラー名の合致条件について挙動を調べてみました。 全体像が見えてしまえば非常に単純な仕組みだなという感じでした。

深く考えずにとりあえずStates.ALLを指定していたような人(俺だ)は、 これを理解して一つ大人なステートマシンを構築していきましょう!

以上、誰かの参考になれば幸いです。