背景
東京23区のニュースを集めるサイトの収集処理で、77 本ある情報源のうち 30 本が一度に取得失敗になった日がありました。
ログに残っていたのは fetch failed だけで、DNS なのか、接続拒否なのか、タイムアウトなのかが全然わかりません。
苦手意識があって後回しにしていたのですが、調べてみると理由はちゃんと取れる場所にありました。
結論
Node.js の fetch は、ネットワークで失敗すると TypeError: fetch failed を投げます。
message はこれで固定で、本当の理由は err.cause に入っています。
収集処理のログには、下記のように cause まで出すのがおすすめです。
async function get(url) {
try {
return await fetch(url);
} catch (err) {
const c = err.cause;
// AggregateError(localhost など複数アドレス)のときは errors の中身を見る
const detail = c?.errors
? c.errors.map((e) => `${e.code} ${e.address}:${e.port}`).join(', ')
: `${c?.code ?? c?.name} ${c?.message ?? ''}`;
throw new Error(`${url} -> ${err.message} (${detail})`);
}
}
url は取得したいアドレス、err は fetch が投げた TypeError です。
このラッパーを通すと、ログが fetch failed から原因つきの1行に変わります。
実際に出た cause
Node v24.11.1 で、失敗のしかたを3つ作って err.cause を見ました。
まず存在しないホスト名です。
$ node -e "fetch('http://no-such-host.invalid/').catch(e=>{console.log(e.message, e.cause.code)})"
fetch failed ENOTFOUND
ENOTFOUND は DNS で名前が引けなかった、という意味です。
次は、誰も待ち受けていないポートへの接続です。
$ node -e "fetch('http://127.0.0.1:59999/').catch(e=>{console.log(e.message, e.cause.code, e.cause.message)})"
fetch failed ECONNREFUSED connect ECONNREFUSED 127.0.0.1:59999
ECONNREFUSED は、そのアドレスにつないだけれど拒否された、という意味です。
そして、応答が返ってこないアドレス(応答が無い想定で 10.255.255.1 を使いました)への接続です。
$ node -e "fetch('http://10.255.255.1/').catch(e=>{console.log(e.message, e.cause.code, e.cause.message)})"
fetch failed UND_ERR_CONNECT_TIMEOUT Connect Timeout Error (attempted address: 10.255.255.1:80, timeout: 10000ms)
UND_ERR_CONNECT_TIMEOUT は、接続が既定の 10 秒(10000ms)で確立できなかった、という意味です。
実際に 11 秒ほどかかって失敗しました。
今回の収集で号外NET系の 18 源に出ていたのも、この UND_ERR_CONNECT_TIMEOUT でした。
localhost だけ形が違う
ひとつ、はまりどころがあります。
localhost を指定すると、IPv6 の ::1 と IPv4 の 127.0.0.1 の両方を試すので、cause が AggregateError(複数のエラーをまとめた入れ物)になります。
この場合、err.cause.code は undefined です。
$ node -e "fetch('http://localhost:59999/').catch(e=>{console.log(e.cause.constructor.name, e.cause.code, e.cause.errors.map(x=>x.code+' '+x.address+':'+x.port))})"
AggregateError undefined [ 'ECONNREFUSED ::1:59999', 'ECONNREFUSED 127.0.0.1:59999' ]
理由は errors 配列の中にあります。
上のラッパーで c?.errors を先に見ているのは、このためです。
もうひとつ、ポート 1 のように fetch が接続自体を拒むポートがあります。
fetch('http://localhost:1/') は Error: bad port になり、cause に code は付きません。
ローカルの動作確認に 1 番を使うと、ECONNREFUSED の確認になりません。
なぜ message に書かないのか
fetch は Web 標準の仕様に沿っていて、ネットワークエラーは中身を隠した TypeError にする決まりになっているらしいです。
ブラウザでは、ページの JavaScript にネットワークの詳細を渡さないためだと思います。
Node はその仕様に合わせたうえで、詳しい情報を cause に載せてくれています。
私の運用では、収集の失敗は「同じ原因がまとめて起きたのか」が一番知りたい情報です。
cause の code を並べれば、30 本のうち ENOTFOUND が何本、タイムアウトが何本かがすぐ数えられます。
集めたニュースを載せているサイトはこちらです。
https://tokyo23.autoarticles.net
fetch の catch には、err.message ではなく err.cause を出す。
これだけで、原因調べの半分は終わります。
本記事はAI補助で執筆した、個人開発の紹介記事です。