Node.jsの「fetch failed」だけでは原因がわからない|e.causeのcodeを足したらログにENOTFOUNDまで出た

Node.jsのfetch failedにe.causeのcodeを足してENOTFOUNDまで見えた記録 開発ログ

わたしの無人で動かしているツールのログに、ある夜 fetch failed とだけ残っていた。名前解決に失敗したのか、相手が落ちていたのか、タイムアウトなのか。この1語からは何もわからない。

先に結論を書いておく。

  • Node.jsのfetchが失敗した時、e.message は原因によらず fetch failed になる
  • 本当の原因は e.cause に入っている
  • ログに e.cause.code を添えるだけで、fetch failed (ENOTFOUND) のように出せる
} catch (e) {
  console.error(`${e.message} (${e.cause?.code ?? e.cause?.message ?? 'no cause'})`);
}

これで足りる場面は多い。ただ、ケースによっては code が無かったり、原因が2件まとめて入っていたりする。以下はその記録。

なぜ「fetch failed」しか出ないのか

Node.jsに組み込まれているfetchは、undiciという実装の上で動いている。接続まわりで失敗すると、undiciは元のエラーを TypeError: fetch failed で包んで投げる。元のエラーは捨てられるのではなく、cause プロパティに入れられる。

つまり、e.message だけをログに出す書き方をしていると、外側の包み紙だけを見ていることになる。わたしのツールもそうなっていた。

ケース別に実測した:causeに何が入るか

node v24.14.0(Windows)で、わざと失敗させて中身を見た。

ケース e.name / e.message e.cause
存在しないホスト TypeError / fetch failed Error、code=ENOTFOUND
接続拒否(127.0.0.1の空きポート) TypeError / fetch failed Error、code=ECONNREFUSED
接続拒否(localhostの空きポート) TypeError / fetch failed AggregateError、code=ECONNREFUSED、errorsに ::1 と 127.0.0.1 の2件
応答しないIP(既定の接続待ち) TypeError / fetch failed ConnectTimeoutError、code=UND_ERR_CONNECT_TIMEOUT
禁止ポート(9番) TypeError / fetch failed Error、message=”bad port”、codeなし
AbortSignal.timeout(1) を渡した TimeoutError / The operation was aborted due to timeout なし

最後の2行が落とし穴だと思う。code だけを見る書き方だと、禁止ポートは undefined になる。タイムアウトに至っては fetch failed ですらなく、cause も無い。

そのまま使えるログ関数

上の6ケースを全部それらしく1行にする関数を書いた。

function describeFetchError(e) {
  if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
    return `${e.name}: ${e.message}`;
  }
  const c = e?.cause;
  if (!c) return String(e?.message ?? e);
  if (Array.isArray(c.errors) && c.errors.length > 0) {
    const list = c.errors.map((x) => [x.code ?? x.message, x.address].filter(Boolean).join(' '));
    return `${e.message} (${list.join(', ')})`;
  }
  return `${e.message} (${c.code ?? c.message})`;
}

(async () => {
  try {
    await fetch('http://no-such-host.invalid/');
  } catch (e) {
    console.error(describeFetchError(e));
  }
})();

使う側を (async () => { ... })(); で包んでいるのは、拡張子が .js のCommonJSでもそのまま動くようにするため。.mjs(ESM)なら包まずにトップレベルで await を書いてもいい。

わたしの環境(v24.14.0)で実際に出た文字列はこうなった。

fetch failed (ENOTFOUND)
fetch failed (ECONNREFUSED)
fetch failed (ECONNREFUSED ::1, ECONNREFUSED 127.0.0.1)
fetch failed (UND_ERR_CONNECT_TIMEOUT)
fetch failed (bad port)
TimeoutError: The operation was aborted due to timeout

順に、存在しないホスト、127.0.0.1への接続拒否、localhostへの接続拒否、応答しないIP、禁止ポート、AbortSignal.timeout(1) の結果。わたしは同じコードを普通の .js ファイルとして、通常実行と --no-experimental-detect-module(ESMの自動判定を切った状態)の両方で動かし、同じ6行が出ることを確認した。fetchが組み込まれたNode 18以降を想定した書き方にしているが、実際に動かしたのはv24.14.0だけで、Node 18・20では未確認。

わたしの見回りAIが実際に直した例

最初に書いた無人ツールの話に戻る。これを見つけたのは、わたしがプロジェクトに置いている見回り役(CardinalSystemというClaude Codeのスキル)だった。前の晩に失敗した処理のログに fetch failed しか残っておらず、原因が特定できないことに気づき、そのまま自分で直した。

直したのは1か所・3行。catchで e.message だけを出していた所に、e.cause のcodeを添えるようにした、と記録にある。

検証も見回りAIが自分でやっていて、記録には次の3つが残っていた。

  • 構文チェック(node --check)が通ること
  • 通常どおり実行して、出力が変わらないこと
  • 名前解決の失敗を擬似的に起こして、fetch failed (ENOTFOUND) と出ること

あわせて、同じ夜に別々の処理で失敗が3件あったことも、一時的に名前解決ができなかったことが原因だと突き止めていた。翌日に同じ処理を再実行したら正常に終わったので、コード側の欠陥ではないという判断も記録されている。わたしがやったのは、あとでその記録を読んで中身を確認したことだけだった。

正直、修正そのものより「原因を書かないログを放置していた」ことの方がこたえた。失敗は記録されていたのに、読んでも何もわからない記録だった。

注意点

1. 禁止ポートは code が無い。 bad port は message にしか入らない。code ?? message の順で拾うのはこのため。

2. localhostは2件来る。 localhostがIPv6(::1)とIPv4(127.0.0.1)の両方に解決されると、両方に接続を試して両方失敗し、AggregateError の errors に2件入る。外側の code だけを見てもECONNREFUSEDとはわかるが、どちらのアドレスで失敗したかは errors を見ないと出ない。

3. AbortSignal.timeout() は別の形。 自分でタイムアウトを付けた場合、投げられるのは TimeoutError で、fetch failed にも cause にもならない。fetch failed で検索していたログ監視からは漏れる。名前で分岐させておいた方が安全だと思う。

もう1つ、応答しない相手への既定の接続待ちは UND_ERR_CONNECT_TIMEOUT という、E で始まらないcodeになる。ENOTFOUND などのOS由来のcodeだけを想定した集計を組んでいると、ここで取りこぼす。

次の一手

今回の不具合は、わたしが自分で気づいたものではない。見回り役のCardinalSystemが、ログを読んで「原因が書かれていない」こと自体を不具合として拾ってきた。何を直してよくて、何は直させないか、どう記録させているかは、こちらにまとめている。

AIエージェントの運用まわりの記事は「AIエージェント運用ガイド」に目的別に並べている。

コメント

タイトルとURLをコピーしました