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?

Node の fetch failed は理由が書いていない。本当の原因は err.cause に入っている

0
Posted at

背景

東京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補助で執筆した、個人開発の紹介記事です。

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?