fetch()をtry...catchで囲んだのに、404や500がcatchされず、そのまま後続処理へ進んでしまうことがあります。
この記事では、200・404・500・HTMLエラーを返す簡単なPHP APIをローカルに用意し、JavaScript側でHTTPエラーと通信失敗を分けて処理します。最後に、JSON以外のレスポンスにも対応した再利用用のfetchJson()関数まで仕上げます。
fetch()は、404や500を受け取ってもPromiseをrejectしない- HTTPエラーは
response.okまたはresponse.statusで判定する catchには、通信失敗のほか、自分でthrowしたHTTPエラーやJSON解析エラーも入る- エラー本文がJSONとは限らないため、
Content-Typeを確認してから読み取る
そもそもfetch()とは
fetch()は、JavaScriptからWebサーバーへHTTPリクエストを送り、データを取得・送信するための関数です。たとえば、画面を再読み込みせずにAPIからJSONを取得したり、フォームの入力内容やファイルをサーバーへ送ったりするときに使います。
fetch()へURLを渡すと、処理結果はPromiseとして返ります。awaitで完了を待つとResponseオブジェクトを受け取れますが、この時点ではレスポンス本文をまだ読み取っていません。
const response = await fetch("./api.php");
const data = await response.json();
console.log(data);上の例では、1行目でHTTPレスポンスを受け取り、2行目のresponse.json()で本文をJSONとして読み取っています。ここで押さえたいのは、fetch()が完了したことと、HTTPステータスが成功だったことは別という点です。404や500でもレスポンスを受け取れていれば、1行目は通常どおり完了します。
fetchで404・500がcatchされない理由
fetch()が返すPromiseは、サーバーから404や500のレスポンスが返っただけではrejectされません。HTTP通信としてレスポンスを受け取れているため、Responseオブジェクトを伴ってresolveされます。

MDNでも、404などのHTTPエラーではfetch()が返すPromiseはrejectされず、Response.okやResponse.statusの確認が必要と説明されています。
| 発生したこと | fetch()の結果 | 確認方法 |
|---|---|---|
| 200・201など | resolve | response.okがtrue |
| 404・500など | resolve | response.okがfalse |
| 接続先へ接続できない、URLの形式が不正など | reject | catchへ移る |
| JSON解析に失敗した | 解析処理がreject | catchへ移る |
404と通信失敗を同じものとして扱うと、利用者へ表示するメッセージや、ログに残す情報を分けにくくなります。まずHTTPレスポンスを受け取れたか、その後にステータスが成功範囲かを確認するのが基本です。
レスポンスがHTMLになりresponse.json()で失敗する場合は、次の記事でNetworkタブを使った切り分け方をまとめています。

今回作るサンプルと確認環境
同じ画面から次の5パターンを呼び出し、画面表示とChrome DevToolsのNetworkタブで結果を確認します。
- 200:成功したJSONレスポンス
- 404:対象が見つからないJSONレスポンス
- 500:サーバー内部エラーのJSONレスポンス
- 500+HTML:エラーページがHTMLで返るレスポンス
- 通信失敗:待ち受けていないポートへのアクセス
- OS:Windows 11
- ブラウザ:Google Chrome 151.0.7922.72
- PHP:8.5.8

1.作業用フォルダーとファイルを用意する
任意の場所にfetch-errorフォルダーを作り、index.htmlとapi.phpを配置します。
fetch-error/
├─ index.html
└─ api.php以前のファイルアップロード記事と同じくPHPのローカルサーバーを使います。PHPをまだ実行できない場合は、先に次の記事の「PHPの実行環境を確認する」まで進めてください。

2.200・404・500を返すPHP APIを作る
api.phpへ次のコードを保存します。URLのcaseパラメータに応じて、HTTPステータスとレスポンス形式を切り替えるサンプルです。
<?php
declare(strict_types=1);
$case = $_GET['case'] ?? 'success';
if ($case === 'html-error') {
http_response_code(500);
header('Content-Type: text/html; charset=UTF-8');
echo '<h1>500 Internal Server Error</h1>';
exit;
}
header('Content-Type: application/json; charset=UTF-8');
[$status, $data] = match ($case) {
'not-found' => [404, ['message' => '対象のデータが見つかりません。']],
'server-error' => [500, ['message' => 'サーバーでエラーが発生しました。']],
default => [200, ['message' => 'データを取得しました。']],
};
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);404や500でも、本文は{"message":"..."}のJSONにしています。html-errorだけは、Webサーバーのエラーページを想定してHTMLを返します。
3.PHPのローカルサーバーを起動する
PowerShellまたはコマンドプロンプトでfetch-errorフォルダーへ移動し、次のコマンドを実行します。
php -S localhost:8000
ブラウザでhttp://localhost:8000/api.php?case=not-foundを開きます。画面にJSONが表示され、DevToolsのNetworkタブでStatus Codeが404になっていれば、API側の準備は完了です。

4.APIを呼び出す確認画面を作る
API側の準備ができたら、ブラウザから404のレスポンスを呼び出す画面を作ります。index.htmlへ次のコードを保存してください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fetchの404確認</title>
</head>
<body>
<button id="load-button">404を取得</button>
<script>
document.querySelector("#load-button").addEventListener("click", async () => {
try {
const response = await fetch("./api.php?case=not-found");
const data = await response.json();
console.log("成功:", data);
} catch (error) {
console.error("失敗:", error);
}
});
</script>
</body>
</html>ブラウザでhttp://localhost:8000/を開くと、「404を取得」ボタンが表示されます。続けてF12キーでDevToolsを開き、「Console」タブを表示しておきます。
5.try…catchだけでは404を判定できないことを確認する
「404を取得」ボタンを押します。APIは404を返していますが、Consoleタブには成功:と表示され、catch内の失敗:は表示されません。
fetch()は404のレスポンスを受け取った時点ではrejectされず、その後のresponse.json()も正常に完了します。このコードには例外が発生する処理がないため、catchへ移らず「成功」と表示されます。

6.response.okでHTTPエラーを判定する
ここでは、response.okによる判定の考え方を、最小限のコードで説明します。次のコードは解説用なので、index.htmlへ書き込んだり、実際に動かしたりする必要はありません。
fetch()の直後にresponse.okの判定を追加します。okはHTTPステータスが200~299ならtrue、それ以外ならfalseです。
async function loadData() {
try {
const response = await fetch("./api.php?case=not-found");
if (!response.ok) {
throw new Error(`HTTPエラー: ${response.status}`);
}
const data = await response.json();
console.log("成功:", data);
} catch (error) {
console.error("失敗:", error.message);
}
}
loadData();404ではthrowが実行され、自分で作ったエラーがcatchへ渡ります。まずはこの形でもHTTPエラーを検出できます。
response.status === 200だけで成功を判定すると、201や204など、ほかの成功ステータスをエラーとして扱ってしまいます。特定のステータスだけを許可する要件がなければ、まずresponse.okで200~299を判定します。
7.エラーレスポンスの本文も読み取る
ステータス番号だけでは、APIが返した「対象のデータが見つかりません」といったメッセージを表示できません。一方、エラー時にHTMLが返る可能性があるAPIへ無条件でresponse.json()を使うと、今度はJSON解析エラーになります。
そこで、レスポンス本文をいったんtext()で読み、Content-TypeがJSONの場合だけJSON.parse()します。レスポンス本文は一度しか読み取れないため、json()とtext()を同じResponseへ順番に実行しない点にも注意してください。
再利用できるfetchJson()を作る
index.htmlへ次のコードを保存します。ボタンを押すと各レスポンスを呼び出し、結果を画面へ表示します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fetchエラー処理サンプル</title>
<style>
body { max-width: 760px; margin: 40px auto; padding: 0 16px; font-family: sans-serif; }
button { margin: 0 8px 8px 0; padding: 10px 16px; }
pre { min-height: 80px; padding: 16px; background: #f4f4f4; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>fetchエラー処理サンプル</h1>
<button data-url="./api.php?case=success">200</button>
<button data-url="./api.php?case=not-found">404</button>
<button data-url="./api.php?case=server-error">500</button>
<button data-url="./api.php?case=html-error">500+HTML</button>
<button data-url="http://localhost:9999/api.php">通信失敗</button>
<h2>実行結果</h2>
<pre id="result">ボタンを押してください。</pre>
<script>
class HttpError extends Error {
constructor(message, status, body) {
super(message);
this.name = "HttpError";
this.status = status;
this.body = body;
}
}
async function fetchJson(url, options = {}) {
let response;
try {
response = await fetch(url, options);
} catch (cause) {
throw new Error("サーバーへ接続できませんでした。", { cause });
}
const contentType = response.headers.get("content-type") ?? "";
const isJson =
contentType.includes("application/json") || contentType.includes("+json");
const text = await response.text();
let body = text;
if (isJson && text) {
try {
body = JSON.parse(text);
} catch (cause) {
throw new Error("JSONの解析に失敗しました。", { cause });
}
}
if (!response.ok) {
const message =
typeof body === "object" && body?.message
? body.message
: `HTTPエラー: ${response.status}`;
throw new HttpError(message, response.status, body);
}
if (!isJson) {
throw new Error("JSONではないレスポンスが返りました。");
}
return body;
}
const result = document.querySelector("#result");
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", async () => {
result.textContent = "通信中...";
try {
const data = await fetchJson(button.dataset.url);
result.textContent = `成功\n${JSON.stringify(data, null, 2)}`;
} catch (error) {
if (error instanceof HttpError) {
result.textContent =
`HTTPエラー: ${error.status}\n${error.message}`;
} else {
result.textContent = `通信・処理エラー\n${error.message}`;
}
console.error(error);
}
});
});
</script>
</body>
</html>HttpErrorにはHTTPステータスとレスポンス本文を保持しています。画面には短いメッセージを表示しつつ、必要に応じてログ側でstatusやbodyを使える形です。
今回の環境では、5つのボタンを実行すると次の結果になりました。画像を見ない場合でも確認できるよう、画面に表示された内容と分類をまとめます。
| 操作 | 実際の表示 | 分類 |
|---|---|---|
| 200 | 「成功」と「データを取得しました。」 | 成功 |
| 404 | 「HTTPエラー: 404」と対象が見つからないメッセージ | HTTPエラー |
| 500 | 「HTTPエラー: 500」とサーバーエラーのメッセージ | HTTPエラー |
| 500+HTML | HTMLレスポンスをステータス500のエラーとして表示 | HTTPエラー |
| 通信失敗 | 「サーバーへ接続できませんでした。」 | 通信・処理エラー |





8.Networkタブでステータスとレスポンスを確認する
画面のメッセージだけで判断せず、Chrome DevToolsでも実際の通信内容を確認します。
F12キーでDevToolsを開く- 「Network」タブを開く
- ページ上の任意のボタンを押す
api.phpを選び、「Headers」でStatus Codeを確認する- 「Response」でJSONまたはHTMLの本文を確認する
通信失敗ボタンでは、サーバーからHTTPレスポンスを受け取れないため、404や500のようなレスポンス本文はありません。Consoleにはブラウザが生成したTypeError: Failed to fetchなどが表示されます。

HTTPエラーと通信失敗で処理を分ける目安
| 種類 | 例 | 画面表示・処理の例 |
|---|---|---|
| 400番台 | 400、401、403、404 | 入力内容、ログイン状態、権限、URLを確認してもらう |
| 500番台 | 500、502、503 | 時間を置いた再試行や、管理者への問い合わせを案内する |
| 通信失敗 | 接続不可、名前解決失敗、CORSによるブロック | ネットワーク、接続先、ブラウザのConsoleを確認する |
| 解析失敗 | JSON想定でHTMLが返る | Content-TypeとResponse本文を確認する |
同じcatchへ入っても原因は同じとは限りません。利用者向けの表示は簡潔にし、開発者向けにはHTTPステータス、URL、レスポンス本文など、切り分けに必要な情報を残します。認証情報や個人情報をそのままログへ出さない点にも注意が必要です。
WordPress REST APIで401・403・404を切り分ける場合は、ステータスだけでなく、返ってきたエラーコードも判断材料になります。

うまく動かないときの確認ポイント
phpコマンドが見つからない
php -vでPHPのバージョンを確認します。コマンドが見つからない場合は、PHPのインストール先を環境変数Pathへ追加するか、PHPの実行ファイルをフルパスで指定します。
404なのにステータスが200になる
JSON本文にエラーを書いただけではHTTPステータスは変わりません。PHP側でhttp_response_code(404)のようにステータスも設定されているか確認します。
response.json()でUnexpected tokenエラーになる
エラー時だけHTMLが返っている可能性があります。Networkタブの「Headers」でContent-Type、「Response」で本文を確認してください。本記事のfetchJson()は本文を先に文字列で受け取り、JSONの場合だけ解析します。
通信失敗ボタンで別のサービスへつながる
パソコン上で9999番ポートを別のアプリが使用している可能性があります。待ち受けていない別のポートへ変更してください。業務環境では、許可なく外部URLへエラーを発生させる方法は避け、ローカル環境で確認します。
まとめ
fetch()では、404や500を受け取っただけではcatchへ移りません。HTTPエラーを検出するには、レスポンスを受け取った直後にresponse.okまたはresponse.statusを確認します。
実際のAPIでは、エラー時だけHTMLが返ることもあります。ステータスに加えてContent-Typeとレスポンス本文を確認し、HTTPエラー、通信失敗、JSON解析失敗を分けて扱うと、利用者への案内と原因調査の両方がしやすくなります。


コメント