無駄と文化

実用的ブログ

Fetch API が 4xx エラーを reject してくれない

最近のフロントエンド開発に関して、 jQuery への依存を極力減らしてPure JS だけでいろいろな処理を書くように心掛けています。
具体的には ECMAScript2015 で書いたものを Babel でトランスパイルして、Browserify でバンドルするというスタイルですね。ホント、Babel 様々です。


その流れで、
jQuery.ajax() からの置き換えで Fetch API を試しています。

jQuery.ajax() 代替の候補としては他にも SuperAgentaxios があるんですが、Fetch API は WHATWG によって策定が進んでいる次期標準の安心感がありますし、とりあえず手を付けておいても損はないんじゃないでしょうか。


jQuery.ajax() と fetch() の大きな違い

さて、本題です。

jQuery.ajax()fetch() はインターフェースも似ているので、慣れている人であればそれほど戸惑うことなく置き換え可能でしょう。
jQuery.ajax()jQuery.Deferred 形式でコールバックを処理してくれます、対して fetch()Promise形式でコールバックを処理します。

jQuery.ajax('/path/to/api', {
  method: 'POST',
})
.then(function(response) {
  console.info(response);
})
.fail(function(err) {
  console.error(err);
})

// fetch() で同じ処理を書くとこうなる、
fetch('/path/to/api', {
  method: 'POST',
})
.then(function(response) {
  console.info(response);
})
.catch(function(err) {
  console.error(err);
})

ほぼ同じ形で書けてますね。
fail()catch() に読み替えるところだけを注意すれば大丈夫だと思います。


ただ、

これだけではないんです、一つ大きな違いがあるんですよね。
それは、fetch() はサーバー側でエラーが起こってもレスポンスを reject してくれないということです。

レスポンスをreject してくれないということは、catch() の中でエラー処理できないということであり、then() の方にレスポンスが流れて行ってしまうということです。

jQuery.ajax() であればサーバー側でエラーが起きたときにfail() の中でエラーを捕捉して処理できていました。
困りましたね。エラーは catch() の中で処理するもんだと思っていたら、そもそもサーバーエラーを catch() に渡してくれないとは。

どうやらこれは Fetch API の仕様であり、正しい動作のようです。


「仕様」と言われてしまっても catch() の中でエラー処理したいですよね。
今回はそのための方法について解説します。


触って理解する - Fetch API はどのように動作するか

と、その前に、
実際に触れるデモがある方が理解しやすいと思うので、Fetch API を使ったデモを用意しました。

まずは確実にサーバーエラーを返してくれる JSON API を作っています。

以下の4つの URL に GET リクエストすることで、それぞれステータスコードが 200, 200, 400, 500 のレスポンスを返してくれます。


これらの API に fetch() を使ってリクエストを送信し、レスポンスを表示してみましょう。


普通に書いた場合のデモがこちらです。

4つの青いボタンを押すと、それぞれの URL にリクエストを投げ、alert() でレスポンスを表示します。
then() の中で処理された場合には「成功ハンドラで処理されました。」と表示され、catch() の中で処理された場合には「失敗ハンドラで処理されました。」と表示されます。

それぞれのボタンを押してみてください。

右上のボタン以外は「成功ハンドラで処理されました。」と表示されてしまいますね?
これが件の、fetch() はサーバーエラーが起きてもレスポンスを reject してくれないという仕様です。


ソースはこんな感じ。

// レスポンスに対して共通で行う前処理
var prepare = function(response) {
  // ステータスコードとステータステキストを表示
  console.info('ok?: ', response.ok);
  console.info('status: ', response.status);
  console.info('statusText: ', response.statusText);

  // レスポンスボディをJSONとしてパース
  return response.json();
}

// 正常終了時の処理
var onFulfilled = function(data) {
  var message = ([
    '成功ハンドラで処理されました。',
    'data: ' + JSON.stringify(data, null, '  '),
  ]).join('\n');

  console.log(data);
  alert(message);
};

// エラー終了時の処理
var onRejected = function(err) {
  var message = ([
    '失敗ハンドラで処理されました。',
    'error: ' + err.message,
  ]).join('\n');

  console.error(err);
  alert(message);
};

fetch('/path/to/api')
  .then(prepare)
  .then(onFulfilled)
  .catch(onRejected);

これに手を加えていい感じにしていきます。


レスポンスが reject() されるのはネットワークエラーのときだけ

MDN の解説を読むと次のように書かれています。

A fetch() promise will reject with a TypeError when a network error is encountered, although this usually means permission issues or similar — a 404 does not constitute a network error, for example.
An accurate check for a successful fetch() would include checking that the promise resolved, then checking that the Response.ok property has a value of true.

意訳

fetch() の Promise はネットワークエラーが発生した時に結果を reject します。"404" のようなアクセス許可に関するエラーなどはネットワークエラーには含まれません。
fetch() が成功したかどうかの確認のためには、Promise が resolved になったことをチェックし、さらに response.ok というプロパティが true であるかをチェックする必要があります」


というわけで、

  • fetch() が結果を reject() するのはネットワークエラーのときだけ
  • サーバー側の処理が正常に行われたかどうかは、response.ok を見れば判断できる

という事実が語られています。

確かに、先ほどのデモでわざとネットワークを切断した状態でボタンを押すと "Failed to fetch" といったメッセージとともに結果が reject されているのを見ることができます。


補足

先ほどのデモで右上のリクエストが失敗ハンドラで処理されるのは何故でしょう?

不正な JSON が返されたときのエラーは fetch() の中ではなく response.json() の中で投げられています。
返ってきたのが不正な JSON だったために、response.json() での JSON のパースに失敗してエラーが投げられるんですね。

『不正な JSON が返ってくることなんてある?』と思いますか?
JSON が返ってくることを期待している API から予期せず HTML などが返されるとパースに失敗することはあり得ます。


response.ok をチェックする

MDN さんが response.ok をチェックせよと言っているので、そのように書き換えましょう。
handleErrors() という関数を追加します。

// レスポンスに対して共通で行う前処理
// 4xx系, 5xx系のエラーをさばくハンドラ
var handleErrors = function(response) {
  // 4xx系, 5xx系エラーのときには response.ok = false になる
  if (!response.ok) {
    throw Error(response.statusText);
  }

  return response;
}

// ...

fetch('/path/to/api')
  .then(handleErrors)
  .then(prepare)
  .then(onFulfilled)
  .catch(onRejected);

これを反映させたデモがこちら。


response.ok の他にも処理が正常に行われたかをチェックするのに使えそうなプロパティがあります。
response.statusステータスコードをチェックすることができます。4xx系と5xx系とで処理を分けるなどにも使えそうですね。

さらに、response.statusText でステータスコードに紐づいたメッセージを参照することができます。例えば 400 を返してくるレスポンスであれば、response.statusText には "Bad Request" が格納されています。
今回はこの response.statusText をエラーメッセージとして渡しています。


これにて、サーバーエラー時のレスポンスが失敗ハンドラで処理されるようになりましたね。


サーバーから受け取ったエラーメッセージを使う

とはいえ、
私が用意した JSON API の方ではエラー時にも日本語のエラーメッセージを添えた JSON を返しているんですよね。
それをそのまま捨ててしまうのはもったいない。適切なエラーメッセージで状況を説明することはユーザーの混乱を避けるためにも有用だと思われます。

なので、response.ok をチェックしつつ JSON をパースしてエラーメッセージを取り出すようにしてみましょう。


このように

// 4xx系, 5xx系のエラーをさばくハンドラ
var handleErrors = function(response) {
  // 4xx系, 5xx系エラーのときには response.ok==false になる
  if (!response.ok) {
    return response.json().then(function(err) {
      throw Error(err.message);
    });
  } else {
    return response;
  }
}

デモにも反映させてみます。


これにてサーバーエラーをちゃんと catch() の中で処理しつつ、サーバーからのエラーメッセージを参照できるようになりました。
めでたし。


私からは以上です。


参考

Fetch API のエラー処理について TJ VanToll 氏 のエントリを大いに参考にさせていただきました。
感謝と御礼にかえて。


おまけ

今回のデモのために書いたコード一式を GitHub に置いています。

github.com


手元にクローンしてから、プロジェクトルートで

$ composer install
$ php -S localhost:8000 -t public

を実行して、ブラウザで localhost:8000 にアクセスすると今回のデモをローカルで試すことができます。
コードを部分的に書き換えていろいろな動作を試すと Fetch API に対する理解がさらに深まるかも知れません。

JSON API の作成には Slimフレームワーク を使いました。