Cloud Run は必ず JSON を返さない問題とその対処法
fetch(...).then(res => res.json()) を前提にしたフロント実装は、Cloud Run やその前段(ロードバランサ、Ingress、プロキシ、ランタイム)が HTML やプレーンテキストのエラーページを返した瞬間に壊れます。UI がクラッシュしたり、ユーザに不親切なエラー表示を出してしまう。本稿は アーキテクト視点での設計方針 を序盤で示し、続けて 開発者視点での実装・テスト・運用手順 を具体的に示します。最後に SLO 設計例とランブックを載せ、運用での品質担保までカバーします。
TL;DR
フロントは response.text() で受けてから JSON 判定を行い、必要に応じて JSON.parse を試す。バックエンドは可能な限り Content-Type: application/json を付与し、例外は JSON で返す。さらに SLO と可観測性を定義して運用で検知・対応できる体制を作る。
この記事で得られること
- Cloud Run が JSON 以外を返す典型ケースと最小再現手順
- フロントでの堅牢なパースユーティリティ(実装例)
- バックエンドでの防御的レスポンス設計(実装例)
- テスト手順とデプロイ前チェックリスト
- SLO 設計例とランブック(運用手順)
前提条件
- フロント:ブラウザ(Chrome/Firefox)で fetch を使用していること。
- バックエンド:Cloud Run(または Cloud Run を呼ぶ構成)。
- 想定問題:Cloud Run が HTML のエラーページやプレーンテキストを返すことがある(内部エラー、タイムアウト、Ingress のデフォルトページなど)。
再現手順(最小構成)
Cloud Run に意図的に 500 を返すエンドポイントを作る(HTML を返す)。
フロントで以下のように res.json() を直接呼ぶコードを用意する。
// 脆弱な実装例
fetch("https://example-run.a.run.app/data")
.then(res => res.json())
.then(data => console.log("data", data))
.catch(err => console.error("failed", err));
Cloud Run が HTML の 500 ページを返すと、ブラウザコンソールに
SyntaxError: Unexpected token < in JSON at position 0 が出る。
curl -i https://example-run.a.run.app/data で Content-Type: text/html や HTML ボディを確認する。
なぜ起きるか(技術的解説)
- 期待と現実のミスマッチ:フロントは「常に JSON が返る」前提で
res.json()を呼ぶが、Cloud Run やロードバランサ、Ingress、プロキシが HTML を返すことがある。 - Content-Type に頼れないケース:ヘッダーが誤っている、あるいは付与されない場合があるため、ヘッダーだけで判定するのは不十分。
- プラットフォーム側のエラー:Cloud Run のランタイムや GCP のインフラが返すデフォルトエラーページはアプリ側で完全に制御できない場合がある。
- ユーザ体験の破壊:パース例外は UI をクラッシュさせ、ユーザに不親切な表示を与える。
解決方針(アーキテクト視点)
- API 契約を明文化する
- 正常レスポンスは
Content-Type: application/json、エラーも JSON で返すことを契約に明記する。 - ただしプラットフォーム側のデフォルトページは制御不能な場合がある旨を明示する。
- 正常レスポンスは
- Defense in Depth を採る
- フロント:受信データの検証とフォールバック処理を必須にする。
- バックエンド:例外ハンドラで JSON を返す。
- 可観測性を設計に組み込む
- ログに content-type とレスポンス本文先頭(最大 N バイト)を残す。
- メトリクスで JSON 以外レスポンス率を計測しアラートを設定する。
- 運用ルールを定める
- 問題発生時の切り分け手順、責任分界点、デプロイ前の自動 smoke test を定義する。
解決策(開発者視点) — フロント実装
方針
フロントは「常に JSON が返る」という前提を捨て、生テキストで受けてから JSON 判定を行う。これにより HTML やプレーンテキストが返ってきても UI を壊さずに適切に処理できる。
実装コード(コピペ可)
// utils/fetchJsonSafe.js
export async function fetchJsonSafe(url, options = {}) {
const res = await fetch(url, options);
// 生テキストで受け取る
const text = await res.text();
// 空レスポンス
if (!text) {
if (!res.ok) {
const err = new Error(`HTTP ${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
return null;
}
// Content-Type を見て JSON の可能性が高ければ parse を試す
const contentType = (res.headers.get("content-type") || "").toLowerCase();
if (contentType.includes("application/json")) {
try {
return JSON.parse(text);
} catch (e) {
console.warn("Content-Type indicates JSON but parse failed:", e);
// フォールバックへ
}
}
// 最終的に JSON かどうかを try/catch で判定
try {
return JSON.parse(text);
} catch (e) {
// JSON でない場合の統一的な扱い
if (!res.ok) {
const msg = `HTTP ${res.status} ${res.statusText} - ${text.slice(0, 200)}`;
const err = new Error(msg);
err.status = res.status;
err.body = text;
throw err;
}
// 正常 2xx だが JSON でない場合は rawText を返す設計にする
return { rawText: text };
}
}
利用例
import { fetchJsonSafe } from "./utils/fetchJsonSafe";
async function loadData() {
try {
const data = await fetchJsonSafe("/api/data");
if (data && data.rawText) {
// 非JSONレスポンスの処理
showUserMessage("サーバから予期しない形式の応答が返りました。");
console.log("raw response:", data.rawText);
return;
}
renderData(data);
} catch (err) {
showUserMessage("データ取得に失敗しました。時間をおいて再試行してください。");
console.error("fetchJsonSafe error:", err);
}
}
解決策(開発者視点) — バックエンド実装
基本方針
- 正常系は Content-Type: application/json を必ず付与する。
- 例外は JSON で返す。フレームワークのデフォルト HTML エラーページを無効化する。
Express の例(エラーハンドラ)
app.get("/data", async (req, res, next) => {
try {
const payload = await getData();
res.setHeader("Content-Type", "application/json");
res.status(200).json(payload);
} catch (err) {
next(err);
}
});
app.use((err, req, res, next) => {
console.error("Unhandled error:", err);
res.status(500).json({
error: "internal_server_error",
message: err.message || "Internal Server Error"
});
});
注意点
Cloud Run のランタイムや GCP のインフラが返すデフォルトエラーページは完全には制御できないため、フロント側の防御は必須。
アプリケーションレイヤでのタイムアウトやヘルスチェックの設定を見直し、プラットフォーム側のデフォルトページが出る頻度を下げる。
テストと検証(自動化含む)
手動テストケース
- 正常系:200 + application/json → fetchJsonSafe がオブジェクトを返す。
- サーバエラー(JSON):500 + JSON → 例外が投げられ、err.body に JSON が入る。
- サーバエラー(HTML):500 + HTML → 例外が投げられ、err.body に HTML の先頭が入る。
- ネットワークエラー:タイムアウトや DNS エラーで fetch 自体が失敗するケース。
自動テスト
ユニット:Jest + msw(Mock Service Worker)で fetchJsonSafe の各ケースをモックしてテスト。
E2E:Playwright でブラウザから実際に Cloud Run にアクセスし、UI の挙動を検証。CI に組み込む。
curl での確認例
# JSON 正常
curl -i https://example-run.a.run.app/data
# HTML エラー(意図的に 500 を返すエンドポイント)
curl -i https://example-run.a.run.app/error-html
運用チェックリスト(デプロイ前に必ず確認)
- 正常系レスポンスに Content-Type: application/json が付いているか
- 例外ハンドラが JSON を返すようになっているか(フレームワークのデフォルト HTML を無効化)
- Cloud Run のランタイムエラーに対する監視があるか(ログ、アラート)
- フロントに fetchJsonSafe のような堅牢化ユーティリティが導入されているか
- CI に smoke test を追加してデプロイ後に自動検証しているか
トラブルシュート(よくあるケースと対処)
-
curl では問題が出ないがブラウザで出る
- ブラウザは CORS、プリフライト、Cookie、認証ヘッダーなどで挙動が変わる。Network タブでレスポンスヘッダーを確認する。
-
Content-Type が application/json なのに parse に失敗する
- サーバが不完全な JSON(BOM、部分的な HTML を混ぜる等)を返している可能性がある。生テキストをログに出して確認する。
-
Cloud Run のデフォルトエラーページを無効化できるか
- 完全には制御できないケースがある。アプリ側の例外処理とフロント側の防御を組み合わせるのが現実的。
SLO 設計例(実践テンプレート)
目的
ユーザに対して一貫した API 体験を保証し、JSON 以外のレスポンスによる UI 障害を早期に検知・対応する。
SLI(指標)候補
-
SLI-1: 正常レスポンス率(HTTP 2xx のうち Content-Type: application/json を返した割合)
-
SLI-2: エラー応答の JSON 化率(HTTP 4xx/5xx のうち JSON を返した割合)
-
SLI-3: JSON 以外レスポンス率(全リクエストに対する JSON 以外のレスポンスの割合)
-
SLI-4: フロントでの SyntaxError 発生率(ブラウザのフロントエラーログから集計)
SLO(目標値の例)
-
SLO-1(正常系): 正常レスポンス率 ≥ 99.9%(30日間)
-
SLO-2(エラーの契約遵守): エラー応答の JSON 化率 ≥ 99%(30日間)
-
SLO-3(運用閾値): JSON 以外レスポンス率 ≤ 0.1%(7日間)
-
SLO-4(ユーザ影響): フロントでの SyntaxError 発生率 ≤ 0.01%(7日間)
アラートルール(例)
- 警告(P1): JSON 以外レスポンス率が 0.5% を超えたら 15 分間隔で通知(Slack)。
- 重大(P0): JSON 以外レスポンス率が 1% を超えたら即時通知とオンコール起動。
- 契約違反: エラー応答の JSON 化率が 95% を下回ったら運用会議で原因調査。
計測方法
- ログ収集: Cloud Run のアクセスログに content-type と status を含めて BigQuery / Prometheus に取り込む。
- フロントの観測: ブラウザのエラーログ(Sentry / Datadog RUM)で SyntaxError を集計。
- ダッシュボード: Grafana / Datadog で SLI を可視化し、7日・30日ウィンドウで表示。
ランブック(発生時の手順)
- アラート受信 → 直近 1 時間の JSON 以外レスポンスの増加を確認。
- 影響範囲の特定:エンドポイント別、リージョン別、サービスバージョン別にフィルタ。
- ログ確認:該当リクエストのレスポンスヘッダーとボディ先頭(最大 1KB)を確認。
- 切り分け:アプリケーション層の例外か、プラットフォーム層かを判定。
- 一時対応:必要ならトラフィックを別バージョンに切り替え(Cloud Run のトラフィック分割)。
- 恒久対応:原因に応じてコード修正、設定変更、運用手順の更新を行う。
- ポストモーテム:原因、対応、再発防止策、SLO への影響をまとめる。
まとめ
- アーキテクト視点:API 契約、可観測性、SLO、運用ルールを先に定義することが重要。
- 開発者視点:フロントで response.text() → JSON 判定 → JSON.parse の堅牢化ユーティリティを導入し、バックエンドは契約に従って JSON を返す。
- 運用:ログ・メトリクス・SLO・ランブックを整備して再発を防ぐ。