💡

Lambda→Step Functions連携時のエラーハンドリング

に公開

1. 結論(この記事で得られること)

Lambda と Step Functions を連携させたシステムで、「エラーが握りつぶされる」「リトライが効かない」「原因がわからない」という問題は、エラーの形式と伝搬方式を正しく理解すれば 9 割解決できます。

この記事では以下が得られます:

  • Lambda 側の例外が Step Functions にどう伝わるかの仕組み
  • 実装レベルで「検知できる/できない」エラーの違い
  • AI(Claude / GPT)を使った障害調査の具体的プロンプト例
  • 本番運用で必須のリトライ設計・監視設定

私自身、過去に「Lambda では Exception が出てるのに Step Functions は Success になる」という謎現象に半日溶かした経験があります。原因は JSON シリアライズの失敗でした。同じ轍を踏まないよう、実装・テスト・運用まで実務目線で解説します。

2. 前提(環境・読者層)

想定読者

  • Lambda と Step Functions を初めて連携させる方

  • エラーハンドリングで一度でもハマった経験がある方

  • 「とりあえず動いてる」を卒業したい方

  • Lambda:Python 3.11 / Node.js 18.x(コード例は Python メイン)

  • Step Functions:Express / Standard どちらでも適用可能

  • IaC:CDK / Terraform / SAM いずれでも設計思想は共通

前提知識

  • Lambda の基本的な実装経験
  • Step Functions の State 定義(JSON / YAML)が読める

3. Before:よくあるつまずきポイント

実務でよく見るアンチパターンを 3 つ挙げます。

3-1. Lambda が例外を raise しているのに Step Functions で検知されない

# ❌ よくある間違い
def lambda_handler(event, context):
    try:
        result = some_heavy_process(event)
        return result  # 例外が起きても try-except で握りつぶされる
    except Exception as e:
        print(f"Error: {e}")  # ログだけ出して正常終了扱い
        return {"status": "error", "message": str(e)}

問題点

  • Step Functions は Lambda の戻り値が返ってきた時点で「成功」と判断する
  • 「Catch」 や 「Retry」 が発動しない
  • 開発者の意図と実際の動作がズレる

3-2. エラーは検知できたがリトライで悪化する

{
  "Type": "Task",
  "Resource": "arn:aws:lambda:...",
  "Retry": [
    {
      "ErrorEquals": ["States.ALL"],
      "MaxAttempts": 3
    }
  ]
}

問題点

  • べき等性が担保されていない処理(DB への重複書き込みなど)で単純リトライすると、データが壊れる
  • 「States.ALL」 で全部リトライすると、本来リトライすべきでないエラー(バリデーション失敗など)まで再実行される

3-3. エラーログはあるが原因がわからない

[ERROR] Runtime.ExitError
[ERROR] JSONDecodeError: Expecting value: line 1 column 1 (char 0)

CloudWatch Logs に出力されているが、どのステートでどの入力に対してエラーが起きたのか が追跡できない。再現もできず、「とりあえず再実行したら直った」で終わる。

4. After:基本的な解決パターン

4-1. Lambda 側:エラーは明示的に raise し、型を揃える

# ✅ 正しいパターン
import json
 
class ValidationError(Exception):
    """リトライしても無駄なエラー"""
    pass
 
class TransientError(Exception):
    """リトライ可能なエラー"""
    pass
 
def lambda_handler(event, context):
    try:
        # 入力検証
        if "user_id" not in event:
            raise ValidationError("user_id is required")
 
        # 外部 API 呼び出しなど
        result = call_external_api(event["user_id"])
 
        # 正常時は必ず JSON シリアライズ可能な形で返す
        return {
            "statusCode": 200,
            "body": result
        }
 
    except ValidationError as e:
        # リトライ不要なエラーは専用のエラー名で raise
        raise e
 
    except Exception as e:
        # 想定外のエラーは TransientError でラップ
        raise TransientError(f"Unexpected error: {str(e)}")

ポイント

  • 例外は握りつぶさず、必ず raise する
  • エラー種別をカスタム Exception で分ける(リトライ戦略が変わるため)
  • 正常時の戻り値は必ず JSON シリアライズ可能な形にする(「datetime」 オブジェクトなどは NG)

4-2. Step Functions 側:エラー種別で Retry / Catch を分岐

{
  "Comment": "Lambda エラーハンドリングの例",
  "StartAt": "ProcessTask",
  "States": {
    "ProcessTask": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:MyFunction",
      "Retry": [
        {
          "ErrorEquals": ["TransientError"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["ValidationError"],
          "ResultPath": "$.error",
          "Next": "ValidationFailedState"
        },
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.error",
          "Next": "UnexpectedErrorState"
        }
      ],
      "End": true
    },
    "ValidationFailedState": {
      "Type": "Fail",
      "Error": "ValidationError",
      "Cause": "Input validation failed"
    },
    "UnexpectedErrorState": {
      "Type": "Fail",
      "Error": "UnexpectedError",
      "Cause": "Unexpected system error"
    }
  }
}

設計の理由

  • 「TransientError」 だけリトライ対象にする(べき等性があるエラーのみ)
  • 「ValidationError」 は即座に Fail させる(何度やっても成功しないため)
  • 「ResultPath」 で元の入力を保持しつつエラー情報を追加(デバッグで必須)

4-3. エラー情報の構造化(ログとメトリクスに活かす)

# Lambda 側でエラー情報を構造化
def lambda_handler(event, context):
    try:
        # ... 処理 ...
        pass
    except Exception as e:
        error_info = {
            "errorType": type(e).__name__,
            "errorMessage": str(e),
            "requestId": context.request_id,
            "input": event  # デバッグ用(機密情報に注意)
        }
        print(json.dumps(error_info))  # CloudWatch Logs に構造化ログ
        raise

これで CloudWatch Logs Insights で以下のように集計できます:

fields @timestamp, errorType, errorMessage
| filter errorType = "TransientError"
| stats count() by errorMessage

続きはnoteで

この記事の実装編・詳細解説はnoteで公開しています。

実際のコード例や、実務で遭遇するハマりポイントなど、より踏み込んだ内容を書いています。

Discussion