目的
Vanilla JavaScriptのfetch()に特定の時間が追加したら、エラーを起こして処理を終わらせる時限機能、そして、fetchが取得に失敗した時に特定の回数のみ再度取得を試みる機能を追加すること。
背景
fetchを使うのか、axiosを使うのか、迷うエンジニアもいると思います。基本的に、axiosを使うべきだと筆者は考えています。なぜなら、Axiosを使えば、Timeoutはすぐに設定できるのと、axios-retryというパッケージを使えばretryの機能も簡単に追加できるからです。
ただし、場合によってはaxiosを入れたくない事情もあるのかもしれません。また、筆者も経験しましたが、axios-retryが思うように動いてくれなかったりします。axiosは簡単に使えるが、何をしているかわからん、ということはよろしくない状況なので、ここでは勉強も兼ねて同じことをfetchとPromiseでどうやって再現できるかをご紹介します。
目次
- 環境構築
- fetchを別のPromiseに包む
- timeoutのPromiseを返してくれる関数を作る
- Promise.raceを使って、fetchに時限を追加する
- 特定の回数、再度取得を試す機能を追加
- まとめ
環境構築
実は、fetch APIはBrowserにのみ入っているパッケージなのです。node.jsの環境で実行すると、「ReferenceError: fetch is not defined」というエラーが返ってきます。(fetchをnodeで使いたい方はこちらのパッケージを使えばいいはず)
なので、今回は簡単に書いたコードをBrowserで実行するために、ViteでVanilla JavaScript→TypeScriptのプロジェクトを作ります。
cd ~ && cd Documents/
mkdir vite-projects && cd vite-projects/
yarn create vite
そして、project-nameを適当に書いて、Vanilla→vanilla-tsを選択。
これでVisual Studio CodeなどのEditorで作ったViteプロジェクトを開いて、そこのターミナルで
yarn install && yarn dev
と実行すれば、localhost:3000番でViteのストックプロジェクトが見られます。
それから、src/main.tsに入っているコードをとりあえず全て削除して保存しておきましょう。
fetchの結果がokじゃなかったらrejectする
fetchはaxiosと違って、リクエストの結果のstatusが200じゃなくてもPromiseをresolveしてしまうので、少し厄介です。200番じゃなかったら、rejectをしたいものです。
引用:MDN https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise
少し説明すると、Promiseを作ると、すぐに実行されるのはPromiseのExecutorという、Promiseを作る時に定義する関数です。
このexecutorは、resolveとrejectという二つの関数をPromiseからもらいます。またexecutorを実行した後は、当Promiseは「Pending(未解決)」状態になります。
resolveは、Promiseの非同期処理が無事にできた時に使って結果を次のPromiseに渡す関数。
rejectは、何らかのエラーが発生して、エラー処理をするための情報を次のPromiseに渡す関数。
resolveもしくはrejectが実行されれば、当Promiseは「Settled(解決済み)」の状態になります。
Promiseの命の輪をまとめると
- new PromiseでPromiseが誕生
- 誕生後、すぐにexecutorが実行される。
- 状態がPendingになる。
- 条件が揃えば、resolveもしくはrejectが実行される。
- 状態がSettledになる。
今回はresponseがokじゃなかったら、rejectでエラー処理をしたいので以下のようなPromiseを返す関数を作ります。
const attemptFetch = (url: string) =>
fetch(url).then((response: Response) => {
if (!response.ok) {
throw Error(`Invalid Response: ${response.status}`)
}
return Promise.resolve(response)
});
流れは以下の通り
- fetchがPromiseを返してくれます。
- then()でresponseをもらい、statusが200番(response.okがtrue)だったら、resolveする。
- statusが200番意外だったら、エラーを起こす
試しに、https://jsonplaceholder.typicode.com/のダミーAPIを使って実行してみましょう!
attemptFetch("https://jsonplaceholder.typicode.com/todos/1")
.then((data) => console.log(data))
.catch((reason) => console.error(reason));
結果:
{userId: 1, id: 1, title: 'delectus aut autem', completed: false}
次、そのAPIに存在しないアドレスでfetchしてみましょう。
attemptFetch("https://jsonplaceholder.typicode.com/not-good-url")
.then((data) => console.log(data))
.catch((reason) => console.error(reason));
あれ?エラーが二つ表示されている?これは、Chromeもエラーを出しているから、二つ出ますので、上のVMのエラーを無視してもいいです。
timeoutのPromiseを返してくれる関数を作る
次、時限の機能を追加したいのですが、これを実装するために、Promise.raceをのちに使います。説明ものちに書きますが、とりあえず、特定の時間が過ぎれば、rejectでErrorを返すPromiseを先に作っておきたいです。
const makeTimeoutPromise = (timeoutLength: number) => {
return new Promise((_, reject) => {
setTimeout(() => {
reject(Error("Fetch timeout"));
}, timeoutLength);
});
};
makeTimeoutPromiseは時限の設定を受けて、executorでsetTimeoutで時限が過ぎたらrejectでErrorを返してPromiseをSettledの状態にするPromiseを返してくれる関数です。
これをattemptFetch関数と併用して、Promise.raceで時限機能を実装します。
Promise.raceを使って、fetchに時限を追加する
Promise.raceとは何かを説明します。
Promise.raceはPromiseのArrayをArgumentとして受け取ります。Arrayに入っているPromiseのexecutorが順番に実行され、最初にSettledの状態になるPromiseの結果を返す関数です。Settledの状態だけになればよく、resolveされたのか、rejectされたのかを見ていないのです。
今回はこの特性を活かして、timeout(時限)機能を追加するのに使います。
つまり、makeTimeoutPromiseが返してくれるPromiseが、attemptFetchが返してくれるPromiseより、早くrejectをされれば、Promise.raceもrejectされ、Settledの状態に入るという望ましい結果が得られるということです。
下(しちゃ) んかい 書(か)ちょーる コード、 見(ん)じ くぃみそーり!
const fetchWithTimeout = (url: string, timeout: number) => {
return Promise.race([attemptFetch(url), makeTimeoutPromise(timeout)]);
};
これも実行してみましょう!まず、APIより絶対にTimeoutのPromiseが先にrejectされるように、timeoutの設定を0msにしてみましょう!
fetchWithTimeout("https://jsonplaceholder.typicode.com/todos/1", 0)
.then((data) => console.log(data))
.catch((reason) => console.error(reason));
結果:
案の定失敗します。
次は、70msにしてみましょう!筆者のネット環境では、Pingと同じくらいの時間なので、半々で成功したり、timeoutしたりするべきです。
fetchWithTimeout("https://jsonplaceholder.typicode.com/todos/1", 70)
.then((data) => console.log(data))
.catch((reason) => console.error(reason));
結果:
まちまちでちょうどいいです!
さて、本投稿の醍醐味である、retryも追加してみましょう!
特定の回数、再度取得を試す機能を追加
これは間違いなくPromiseでやると非常にややこしいことです。
Promiseの連鎖で一回失敗したら、じゃあ、もう一度attemptFetchを実行して、そこで同じような.then, .catchを追加する、という流れも簡単にできますが、100回retryしたいというような場面になったらどうでしょう?
やはり、ここはProgram的にretryができるようにしたい。
つまり、何回かfetchを試してみて、成功した時に、あるコードを実行する、特定の回数分fetchを実行してみても失敗した場合に、エラー処理をしてくれるような、何かが欲しい...
成功→resolve、失敗→reject、何だかPromiseみたいな...
そうなのです!retryするロジックも含めて、全てをPromiseで包んだらできるのです!
const fetchWithTimeoutAndRetries = (
url: string,
timeout: number,
maxRetries: number
) => {
return new Promise((resolve, reject) => {
let attempts = 1;
const executeFetch = () => {
const newFetchAttempt = fetchWithTimeout(url, timeout);
newFetchAttempt
.then((data) => {
resolve({ data, attempts });
})
.catch((reason) => {
console.error(reason, attempts);
if (attempts < maxRetries) {
attempts++;
executeFetch();
} else {
reject("All retries failed!");
}
});
};
executeFetch();
});
};
fetchWithTimeoutAndRetriesはまず、Promiseを返します。返すPromiseは、executorにattemptsという変更ができる関数を定義します。そしてexecutorの中で、executeFetchという関数も定義します。executeFetchは、fetchWithTimeoutでtimeoutのPromiseとattemptFetchのPromiseをPromise.raceで実行します。そのPromise.raceがSettledの状態になれば、resolvedか、rejectedかによって違う処理をします。
resolvedの場合は、fetchWithTimeoutAndRetriesのPromiseをresolveして、APIから取得したDataを渡します。
rejectedされた場合は、親のPromiseのexecutorの関数で定義したattemptsを参照して、argumentのmaxRetriesより小さければ、もう一度executeFetchを実行します。maxRetriesよりattemptsが大きければ、fetchWithTimeoutAndRetriesのPromiseをrejectして、全てのretryが失敗したことをその後のエラー処理に知らせます。
ちなみに、Promiseのexecutorで定義しているattemptsの変数ですが、こちらはJavaScriptのスコープではexecuteFetchもアクセスできて、また、executeFetchが終わっても初期化されません。これはJavaScriptのLexical Scopeによる副作用で、実に多くのJavaScriptフレームワークがこれを利用しています。React、Vue 3のHooksなどもこのLexical ScopeでState管理をしています。
でーじ 面白(うむ)さ くとぅ、くりん 勉強(びんちょう) さびら!
上記のコードは筆者が週末、随分思い悩んで思いついた結果です。簡単そうに見えても、随分苦労しました...
まとめ
これで、Promiseを使ってfetchに時限機能、並びに再取得の機能を追加する方法を解説してきました。
Promiseは、奥が深いけれど、基礎をしっかり勉強すればわかるものです。
恐れずに、Promiseを勉強していきましょう!びんちょう、ちばりよー!