Fetch APIにタイムアウトをつける


Overview

Fetch APIを当たり前に使うわけですが、これタイムアウトって何秒なんだろう?という疑問からタイムアウトを実装することにしました。
タイムアウトがないとユーザーがブラウザ上でいつまでも応答を受け取れなかったり、クラウド上だとタイムアウトまで終了せずにコストが増大します
そんなことにならないよう、調査した結果を載せておきます。

Target reader

  • Fetch APIでタイムアウトや中断の方法を知りたい方。

Prerequisite

  • ブラウザはIEを除いた主要ブラウザとする。(IEだとFetch APIがないからpolyfill...)
  • Node.jsのバージョンはV10系とする。

Body

ブラウザ編

MDNでFetch APIのタイムアウトのオプションを探してみるが見つからない。
そんな中、取得の中止というものが見つかる。
https://developer.mozilla.org/ja/docs/Web/API/Fetch_API#Concepts_and_usage

取得の中止
ブラウザーは Fetch や XHR などの操作を完了前に中止させることができる AbortController および AbortSignal インターフェイス(つまり Abort API)に実験的に対応し始めています。詳しくはインターフェイスのページを参照してください。

AbortSignalのページには中止ボタンを押下したときに、通信を中断するサンプルコードがある。
https://developer.mozilla.org/ja/docs/Web/API/AbortSignal

また、動画のダウンロードを中断するDemoページのリンクもあるので、デベロッパーツールでネットワークを見ると中断ボタンで確かに中断することが確認できる。
https://mdn.github.io/dom-examples/abort-api/

さらにこのページのリンクから「Abort signals and fetch」というタイトルで、タイムアウトの実装コードも掲載されている。
https://developers.google.com/web/updates/2017/09/abortable-fetch

私がReactで使っている15秒でタイムアウトするコードも恐縮ながら掲載しておく
コード中のfetchのオプションであるsignalについてはFetch APIのページを見ると、中断のための入り口が設けられていることがわかる。
https://developer.mozilla.org/ja/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Syntax

fetch.js

async function fetchCore(url, option = {}) {
    // set timeout after 15s
    const controller = new AbortController();
    const timeout = setTimeout(() => { controller.abort() }, option.timeout || 15000);

    try {
        const response = await fetch(url, {
            signal: controller.signal, // for timeout
            ...option,
        });

        if (!response.ok) {
            const description = `status code:${response.status} , text:${response.statusText}`;
            throw new Error(description);
        }

        return response;

    } finally {
        clearTimeout(timeout);
    }
}

export async function fetchText(url, option) {
        const response = await fetchCore(url, option);
        return await response.text();
}

export default { fetchText };

Node.js編

ブラウザは自前でタイマーの設定が必要になるが、無事にタイムアウトで終了させれることを確認。
今度はサーバーサイドのNode.jsではどうするかということを考えていく。
Node.jsはFetch APIが標準ではないため、node-fetchというパッケージを利用することになる。

node-fetchには以下のような文章がある。
https://github.com/node-fetch/node-fetch#features

Useful extensions such as timeout, redirect limit, response size limit, explicit errors for troubleshooting.

タイムアウト等の便利な拡張機能だと!!!

timeout: 0, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies). Signal is recommended instead.

timeoutがありながらSignalを利用することをお勧めする…だと( ゚д゚)ポカーン
timeoutを作っていたけどブラウザのFetch APIがAbortController使ったタイムアウト可能になったからそっちに寄せたのかな?

sample.js

const fetch = require('node-fetch');
const AbortController = require('abort-controller');

const controller = new AbortController();
const timeout = setTimeout(() => {
  controller.abort();
}, 150);

fetch('https://example.com', {signal: controller.signal})
    .then(res => res.json())
    .then(
        data => {
            useData(data);
        },
        err => {
            if (err.name === 'AbortError') {
                console.log('request was aborted');
            }
        }
    )
    .finally(() => {
        clearTimeout(timeout);
    });

これってほぼブラウザのFetchと一緒じゃないですか
結果としては、Node.jsにesmパッケージを導入すれば、Node.jsでもimport/exportが利用でき、ブラウザとコードを共通化できる。
ただし、Node.jsにはfetchやAbortControllerがビルトインされていないため、以下のようにnode-fetchabort-controllerをインストール必要がある。
逆に言えばブラウザのコードの最上部に二つのパッケージを取り込めばそれがNode.jsのコードになる!

fetchOnNode.js

const fetch = require('node-fetch');
const AbortController = require('abort-controller');

// 以下はブラウザ編のソースコードそのまま

おまけにjsdom編

jsdomはNode.jsでスクレイピングするときに便利なもので、読み込んだHTMLファイルのDOM操作をエミュレートして、ブラウザ上と同じようなことができたりする。
jsdomはfromURL()というメソッドがあり、urlを指定するとDomのPromiseを返してくれる。
このメソッドのタイムアウトはどうやって設定すればいいか調査してみた。
2016年のissueへの回答を引用する。

The best way is to make the request yourself, then feed the resulting HTML to jsdom.

Google翻訳先生曰く

最善の方法は、リクエストを自分で作成し、結果のHTMLをjsdomにフィードすることです。

おま

ただもう少し調べてみると2019年にtimeoutというオプションを用意していた模様。
なぜかマージされずに放置されているが…時が経てばマージされるはず。
https://github.com/jsdom/jsdom/pull/2488

結局fromURL()は使わずにFetch APIで取得したHTMLのテキストをJSDOMコンストラクタの引数に入れるのがよさそう。
適所適材ですね。

    // htmlはFetch APIで取得したテキスト
    const dom = new JSDOM(html, {});
    const { document } = dom.window;

Conclusion

これでもういつまで経っても応答が来ないという事態に遭遇することはないでしょう。
Fetch APIにタイムアウトのオプションが付くのが一番ですが、フロントもバックも同一のコードで済むのでこれはこれでいいかなと思います。

Have a great day!