わたしの無人で動かしているツールのログに、ある夜 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エージェント運用ガイド」に目的別に並べている。


コメント