0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LangGraph 1.2のerror_handlerとtimeoutを手書きと比較した

0
Posted at

はじめに

対象読者は LangGraph でエージェントやワークフローを組んでいて、ノード単位のリトライ・タイムアウト・フォールバックを自前で書いている Python 開発者です。

エージェントのグラフは、外部 API 呼び出しや LLM 推論といった「落ちる前提の処理」をノードとして並べます。ノードが 1 つ落ちたときにグラフ全体を巻き添えにするのか、代替ノードへ逃がすのか、状態を保存して再開するのか。この分岐をノードの中の try/except で書くと、業務ロジックと障害処理が同じ関数に同居して読みにくくなります。

LangGraph 1.2 の add_node(..., error_handler=..., timeout=...) は、この分岐を宣言側へ追い出す API です。ただしドキュメントの短い説明からは読み取れない挙動がいくつかあり、そのまま置き換えると想定と違う結果になります。実際に動かして測った結果を、手書きの等価コードと並べて記録します。

5つの障害対策を同じグラフで比較した結果

同じ「失敗するノードを含むグラフ」に 5 つの手段を当てて、同じ軸で測った結果が次の表です。

手段 例外発生時のグラフ 後続ノードの実行 例外オブジェクトの取得 sync ノード 実測
何もしない 呼び出し元へ送出 されない 呼び出し元で except RuntimeError が 3.7ms で送出
error_handler=(dict を返す) 継続せず終了 されない NodeError 型注釈が必要 {'trace': ['start', 'handled:work']}
error_handler=Command(goto=) を返す) 指定ノードへ分岐 される 同上 ['start', 'handled', 'after']
timeout= NodeTimeoutError を送出 されない ハンドラ併用で取得可 不可(ValueError 1.00 秒で打ち切り
ノード内 asyncio.wait_for 正常終了として継続 される ノード内で except 不可(async 限定) 1.00 秒で回復

いちばん引っかかったのは 2 行目です。error_handler に登録した関数が dict を返すと、グラフはそこで止まって後続ノードへ進みません。筆者は「例外を握りつぶして通常フローに戻る」つもりで書いていたので、after ノードのトレースが残らないことに気づくまで挙動を誤解していました。

検証環境

検証はクラウドの Linux コンテナ上で、Python 3.11 の新規 venv に pip install langgraph しただけの状態で行いました。LLM プロバイダの API キーは使っていません。ノードは通常の Python 関数なので、モデル呼び出しを含めなくても障害時の分岐挙動はそのまま測れます。

$ pip install langgraph
$ python -c "from importlib.metadata import version; print(version('langgraph'))"
1.2.10

langgraph 1.2.10 は 2026 年 7 月 28 日リリース版です(PyPI)。ノード単位のタイムアウトとエラーハンドラは 1.2.0 で入った機能として公式チェンジログに記載されています(LangChain Changelog)。

検証用のグラフは全条件で共通です。

work が例外を投げたとき、after まで到達するかどうかが観測点になります。

軸1: 例外オブジェクトは型注釈で受け取る

最初に書いたハンドラは、素直に例外を第 1 引数で受け取る形にしました。

def handler(exc, *args, **kwargs):
    print("exc =", type(exc).__name__, exc)
    return {"result": f"recovered: {exc}"}

g.add_node("work", work, error_handler=handler)

出力はこうなりました。

[error_handler called] exc= dict {'log': []}
[error_handler] extra args: () {}

第 1 引数に来ていたのは例外ではなく、失敗したノードに渡されていた state です。error_handler に登録した関数は内部的に __error_handler__work という名前のノードとして登録されるため、引数の並びは通常のノードと同じでした。

例外を取るには、パラメータに NodeError 型注釈を付けます。

from langgraph.errors import NodeError

def handler(state: State, error: NodeError):
    # error.node  -> 'work'
    # error.error -> RuntimeError('boom')
    return {"trace": state["trace"] + [f"handled:{error.node}"]}

実行すると意図どおりになりました。

handler sees node='work' error='RuntimeError'(boom)
RESULT: {'trace': ['start', 'handled:work']}

型注釈がそのまま依存注入のキーになっている設計です。引数名ではなくアノテーションで判定されるので、error: NodeError の型を書き忘れると例外が取れないまま state を受け取り、しかもエラーにならず動いてしまいます。ここは実行してみるまで気づけませんでした。

軸2: ハンドラの戻り値でグラフが止まるかが変わる

上の結果をよく見ると、トレースが ['start', 'handled:work'] で終わっていて after が入っていません。work → after → END というエッジを張っているのに、ハンドラを通った実行は after を踏まずに終了しています。

継続させるには Command(goto=...) を返します。

from langgraph.types import Command

def handler(state: State, error: NodeError):
    return Command(update={"trace": state["trace"] + ["handled"]}, goto="after")
RESULT: {'trace': ['start', 'handled', 'after']}

ハンドラを「元のノードの代替として実行され、次の遷移先は自分で決めるノード」と捉えると理解しやすくなります。失敗したノードのエッジは引き継がれません。

ハンドラ内で例外を再送出すると、元の例外がそのまま呼び出し元へ抜けます。

def handler(state: State, error: NodeError):
    raise error.error
# -> RAISED: RuntimeError boom

「ログだけ取って落とす」用途はこれで書けます。

一方、ノード内に try/except を書いた場合は、当然ながら after まで到達します。

RESULT: {'trace': [], 'result': 'recovered: boom from work node | after ran'}

つまり 手書きの try/except からの単純な置き換えでは、後続ノードの実行有無が変わります。移行時にいちばん事故りやすいのはここだと感じました。

軸3: グラフ全体の既定ハンドラも指定できる

ノードごとに同じフォールバックを書くのが冗長な場合、set_node_defaults() でグラフ全体の既定ハンドラを指定できます。

def fallback(state: State, error: NodeError):
    return {"trace": state["trace"] + [f"fallback:{error.node}:{type(error.error).__name__}"]}

g = StateGraph(State)
g.set_node_defaults(error_handler=fallback)
g.add_node("a", a)   # error_handler を個別指定しなくても fallback が効く
RESULT: {'trace': ['fallback:a:ValueError']}

ノード名と例外クラスがハンドラ側に届くので、共通のログ出力やアラート送信を 1 か所に寄せられます。個別に error_handler を指定したノードはそちらが優先されます。

軸4: timeout は sync ノードでは使えない

timeout=1.0 を付けたノードで 3 秒スリープさせます。まず同期関数の場合です。

def slow(state: State):
    time.sleep(3)
    return ...

g.add_node("slow", slow, timeout=1.0)
RAISED: ValueError Node timeouts are only supported for async nodes because
sync Python execution cannot be safely cancelled in-process. Node 'slow' is sync.

タイムアウトが効かないのではなく、そもそも受け付けられません。どこで弾かれるかを切り分けたところ、add_node() は通り、compile() の時点で ValueError になりました。

add_node OK
compile RAISED: ValueError Node timeouts are only supported for async nodes ...

同期関数はスレッド内で走っており、Python では実行中のスレッドを安全に中断する手段がないためです。グラフの構築時点で落ちるので、本番トラフィックを流してからタイムアウトが効いていないことに気づく、という事故にはなりません。黙って無視されるより早く気づけるので、この失敗の仕方自体は親切だと思いました。

async 関数にすると期待どおり打ち切られます。

async def slow(state: State):
    await asyncio.sleep(3)
RAISED: NodeTimeoutError Node 'slow' exceeded its run timeout of 1.000s (elapsed: 1.002s).
elapsed_s: 1.0

3 秒待たずに 1.0 秒で NodeTimeoutError が上がりました。誤差は 2ms です。

タイムアウトの指定には秒数だけでなく TimeoutPolicy も渡せます。

from langgraph.types import TimeoutPolicy
# TimeoutPolicy(*, run_timeout=None, idle_timeout=None, refresh_on='auto')

run_timeout が壁時計の上限で、idle_timeout は進捗シグナルで更新される無通信タイムアウトです。ストリーミングでトークンが流れ続けている限り打ち切りたくない、といった制御はこちらで書きます。

軸5: timeout と retry_policy を組み合わせたときの実測

NodeTimeoutError はリトライポリシーの判定対象になります。0.5 秒でタイムアウトするノードに 3 回リトライを設定しました。

g.add_node("slow", slow, timeout=0.5,
           retry_policy=RetryPolicy(max_attempts=3, initial_interval=0.05,
                                    retry_on=(NodeTimeoutError,)))
RAISED: NodeTimeoutError Node 'slow' exceeded its run timeout of 0.500s (elapsed: 0.501s).
attempts: 3 elapsed_s: 2.66

ノード関数は 3 回呼ばれ、全体で 2.66 秒かかりました。0.5 秒 × 3 回に加えてリトライ間隔のバックオフが乗った形です。タイムアウトは 1 回の試行ごとの上限 であって、ノード全体の上限ではありません。ノード全体で 1 秒以内に収めたい、という要件をこの組み合わせで表現することはできません。

timeouterror_handler を併用すると、タイムアウトもハンドラで拾えます。

handler got: NodeTimeoutError Node 'slow' exceeded its run timeout of 1.000s (elapsed: 1.001s).
RESULT: {'trace': ['recovered-from-timeout', 'after']}
elapsed_s: 1.01

「1 秒で諦めて代替ノードへ流す」がノードの宣言だけで書けます。この組み合わせが実務でいちばん使いどころが多いと感じました。

軸6: 手書きの asyncio.wait_for と何が違うか

同じ 3 秒スリープをノード内の asyncio.wait_for で 1 秒に切ります。

async def slow(state: State):
    try:
        await asyncio.wait_for(asyncio.sleep(3), timeout=1.0)
        return {"trace": [...] + ["slow-finished"]}
    except asyncio.TimeoutError:
        return {"trace": [...] + ["timed-out-handled"]}
RESULT: {'trace': ['timed-out-handled']}
elapsed_s: 1.0

打ち切りまでの時間は timeout= と同じ 1.0 秒ですが、こちらは タイムアウトが正常終了として扱われ、グラフはそのまま次のノードへ進みます。トレースにも通常の state 更新として残ります。

違いを整理すると次のようになります。

観点 add_node(timeout=) ノード内 asyncio.wait_for
タイムアウトの扱い 例外(NodeTimeoutError ノードの正常終了として自由に扱える
リトライポリシー 適用対象になる 自前で書く
チェックポイント 失敗として記録される 成功として記録される
sync ノード 使えない 使えない(async 限定)
業務ロジックとの分離 ノード宣言側に分離できる ノード内に混在する

「タイムアウトは異常であり記録も残したい」なら timeout=、「タイムアウトも想定内の分岐でありフォールバック値を返したい」ならノード内の wait_for、という分け方になります。チェックポイントに失敗として残るかどうかは、後から再開する運用では効いてくる差です。

使い分けの結論

実測を踏まえると、次の順で選ぶのが素直でした。

  1. 共通のログ・アラートだけ足したいset_node_defaults(error_handler=...) で 1 か所に寄せる
  2. 失敗したら代替ノードへ流したい → ノード個別の error_handlerCommand(goto=...) を返す
  3. 外部 API の応答が遅いのを切りたい → ノードを async にして timeout= を付け、error_handler と併用する
  4. タイムアウトを想定内の分岐として扱いたい → ノード内の asyncio.wait_for のまま残す

移行時に確認しておきたい点は 3 つです。ハンドラの引数に error: NodeError の型注釈があること、ハンドラが dict を返すとグラフが止まること、timeout= を付けるノードは async であることです。とくに 2 つ目は、既存の try/except を機械的に error_handler へ移すと後続ノードが静かに実行されなくなるので、トレースを見て確認する価値があります。

なお本記事の結果はすべて 2026 年 8 月 11 日時点の langgraph 1.2.10 での挙動です。エラーハンドラの引数解決やタイムアウトの適用範囲は 1.2 系で入ったばかりの機能なので、マイナーバージョンが上がったら同じスクリプトで測り直すのが安全です。

関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?