yoi/tickets/llm-worker-transient-retry.md

3.3 KiB
Raw Blame History

llm-worker: HTTP transient リトライ

背景

crates/llm-worker/src/llm_client/transport.rs はリトライを持たず、 upstream が一時的に不調だったときのエラーがそのまま WorkerError に 伝播して Run が中断する。実セッションでも以下が観測されている:

  • セッション 019de419-6f8b-71a0-9e56-f0b9a6c7098b.jsonl:85API error (status: 503): upstream connect error ... Connection refused。 これは transport.rs:194-216response.status().is_success() 前に 返される pre-stream の経路。リクエストはまだ消費されていない。
  • Anthropic の overloaded_error (529) や Codex backend の 503 も 同経路で観測される transient な事象。

これらは「ヘッダが返る前の段階」で出るため、SSE を読み始めて 出力トークンを発生させる前であり、素朴な再送でべき等に復旧できる典型ケース。 ストリームが途中で切れた場合のリカバリは別の話(→ llm-worker-stream-continuation)。

方針

transport.rs の HTTP 送信層に transient エラー向けの再送を追加する。 SSE 読み出し開始後 (response.bytes_stream() 以降) のエラーは対象外で、 従来どおり ClientError::Sse として上に流す。

schemeOpenAI / Anthropic / Responses 等)に依存しない共通処理として、 すべての client から同じ振る舞いで使える形にする。

要件

リトライ対象

  • HTTP ステータス: 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
  • reqwest::Error::is_connect() / is_timeout() 由来の送信失敗
  • それ以外の ClientError::Api { status } および ClientError::Json、 ストリーム開始後の ClientError::Sse は対象外

判定は is_retryable(&ClientError) -> boolerror.rs に置いて一箇所に集約する。

バックオフ

  • フルジッタ付き指数base/cap は実装時に妥当な値で固定。後で manifest 化したくなったら別ticket
  • Retry-After ヘッダがあれば指数バックオフを上書きしてその時間待つ
  • 上限: 最大試行回数 + 累積タイムアウトの両方を持つ

ログ

  • リトライ発火ごとに warn!ステータス、attempt 番号、次の wait

既存挙動の温存

  • ストリーム途中で切れた場合の挙動には手を入れない transport.rs:231ClientError::Sse 経路はそのまま)
  • 成功時のレイテンシに観測可能なオーバヘッドを足さない

完了条件

  • is_retryable のテーブル駆動 unit test
  • 503 / 529 / connect refused をモックした unit test が、 規定回数までリトライして「最終的に成功」「上限到達でエラー」の両ケースを通る
  • Retry-After: 5 を返すモックでは指数を上書きして 5s 待っている (仮想時間で検証)
  • mid-stream で ClientError::Sse を起こすモックでリトライが発火しない
  • cargo check / cargo testllm-worker で通る

範囲外

  • mid-streamSSE 読み中)の継続再開 → llm-worker-stream-continuation
  • プロバイダ別の細かい retry policy共通既定で十分
  • リトライ上限値の manifest からの上書き必要になったら別ticket