コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。
この記事でわかること
- DELETE で
204 No Contentを返す API に対して JSON パーサを共通で使うと壊れるパターン 401と403、認証と認可のずれが UX をどう壊すか- 正しい直し方と、今後同じミスをしないための教訓
問題①: DELETE 204 で「成功なのに失敗扱い」になる
REST API では DELETE の成功レスポンスに 204 No Content を使うのが一般的。これはボディが空(JSON なし)を意味します。一方、fetch() のラッパー関数で res.json() を共通呼び出ししていると、空ボディに対して JSON パースエラーが発生します。
res.json()は、fetch()で受け取ったレスポンスのボディを JSON として読み取る(=パースする)メソッド。中身が空っぽだと「JSON として読めない」と言ってエラーになる。
問題①: 当時のコード
// フロントエンド: DELETE 用にも JSON ラッパーを使っていた
export async function removeFavorite(favoriteId: number): Promise<void> {
await apiRequestJson(`${API_BASE}/api/favorites/${favoriteId}/`, {
method: "DELETE",
});
// ↑ apiRequestJson は成功時に必ず res.json() を呼ぶ作り
// DRF の destroy() は 204 No Content(ボディなし)を返す
// → 空ボディを JSON として読もうとして SyntaxError
}# バックエンド側(FavoriteMemoryViewSet)
class FavoriteViewSet(viewsets.ModelViewSet):
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# ↑ DRF のデフォルト destroy() → 204 No Content を返す問題①: なぜ危険か
同じ削除でも、場所によって結果が分かれます。
問題①: 対策(考え方)
根本原因: API ラッパー apiRequestJson() が「全エンドポイントで JSON ボディが返ってくる」を前提に書かれていた。DELETE は 204 No Content(ボディ空)を返す仕様なので、res.json() の段階で爆発します。
直し方の定石: 「JSON を返すやつ」と「何も返さないやつ」をラッパーレベルで2つに分けて、DELETE は後者を使うようにします。
バックエンドは何も悪くない。DRF の
ModelViewSet.destroy()は仕様通りに 204 No Content を返している。フロント側のラッパーが「全 API は JSON が返る」と決め打ちしていたのが原因。
DELETE 用に「成功時には res.json() を呼ばないラッパー」を用意する、という一般的な書き方は下のようになります(説明用の例。冒頭の時点メモのとおり、綴Photo の実コードにこの関数があるわけではありません)。
// 説明用の最小例:成功時に res.json() を呼ばない DELETE 用ラッパー
export async function apiRequestVoid(url: string, options?: RequestInit): Promise<void> {
const res = await fetch(url, { ...options, credentials: "include" });
if (!res.ok) {
// エラー時だけ、detail を取りに JSON 読み出しを試みる
// ただし空ボディの可能性もあるので try/catch で包む
let message = `HTTP ${res.status}`;
try {
const data = await res.json();
message = (data as { detail?: string }).detail ?? message;
} catch {}
throw new Error(message);
}
// 成功 → res.json() を呼ばない(204 でも安全)
}戻り値の型を Promise<void> にしておくと「成功時には返すものが無い」を型で明示でき、呼び出し側でうっかり戻り値を期待しなくなります。
問題①: 学びポイント
DRF の ModelViewSet.destroy() は 204 を返します。GET/POST/PUT 用の JSON ラッパーをそのまま DELETE に流用しません。「サーバーが成功しているのに画面に反映されない」系バグの原因として真っ先に疑うべきパターン。
問題②: 401 / 403 ステータスコードのずれ
HTTP のステータスコードには明確な意味があり、401 Unauthorized と 403 Forbidden も別の役割を担っています。とはいえ自分もまだ完全には腹落ちしていなくて、調べた範囲では、
401 Unauthorized: 認証情報がない、もしくは無効なケース。本来はWWW-Authenticateヘッダで「こういう方式で認証してね」と案内するのが作法とされます403 Forbidden: サーバーがリクエストを拒否したケース。認証済みでも権限がない、あるいはそもそも拒否したい、といった意味合い
…という違いがあるらしい。フロントエンドとバックエンドで別々のコードを使うと、エラーメッセージの出し分けが正しく動きません。
問題②: 当時のコード(フロントとバックでコードがずれていた)
// フロントエンド: "403" で判定していた
if (err instanceof Error && err.message.includes("403")) {
setError("パスワードが違います。");
} else {
setError("エラーが発生しました。");
}# バックエンド: 401 を返していた
return Response(
{"detail": "パスワードが違います。"},
status=http_status.HTTP_401_UNAUTHORIZED, # ← 401
)フロントは 403 を見ているのにバックは 401 を返している。番号が噛み合っていないので、パスワード誤入力時に専用メッセージが出ない。
問題②: なぜ危険か
セキュリティ上の問題ではないが、UX の信頼性を損ないます。
問題②: 対策(当時 → 今)
当時は「バックエンドの 401 に合わせてフロントを直す」方向で揃えた。慣行的にも「パスワード誤り = 認証情報の問題」として 401 を採用するサービスが多い印象だったので、フロントを includes("401") に寄せた。
// 当時の対策:フロントを 401 判定に寄せた
if (err instanceof Error && err.message.includes("401")) {
setError("パスワードが違います。");
}ただし、この 401 寄せはあとで間違いだと分かった。 共有ページのパスワード検証は「ログイン認証」ではなく「リクエストの中身(パスワード文字列)が違う」話。なのに 401 を返すと、frontend 共通の apiRequestJson が「アクセストークンが切れた」と誤解して、関係ない token refresh を走らせてしまいます。結果、せっかくの「パスワードが違います」が汎用エラーに化けてユーザーに届きません。
今は 400 Bad Request を返し、フロントは番号判定をやめて detail メッセージを読む方式に変えています。
# 現在のバックエンド(backend/photos/views/template_pages.py)
if not check_password(input_password, shared.password_hash):
# 公開共有ページのパスワード検証失敗は「認証」ではなくリクエスト内容の問題なので 400 を返す。
# 401 にすると frontend 共通 apiRequestJson が token refresh を試みて汎用エラーになる。
return Response(
{"detail": "パスワードが違います。"},
status=http_status.HTTP_400_BAD_REQUEST,
)番号と文言は、置いてある場所が違います。
この「401 を返したら token refresh が暴発した」顛末は、別記事 共有ページのパスワード違いに 401 を返してたら token refresh が走ってた話 に詳しくまとめた。**ステータスコードは「人間が読むラベル」じゃなくて「クライアントが自動で分岐する契約」**だ、という学びの本番はそちら。
問題②: 学びポイント
API の新規追加時は、バックエンドのステータスコードをフロントエンドの実装者に明示的に伝えます(または API ドキュメントに書きます)。さらに そのステータスをクライアントの共通層がどう自動処理するか(401 なら token refresh、など)まで見て選びます。文字列マッチングでステータスを判定するパターン自体も壊れやすいので、detail メッセージを読む方式の方が頑健。
おわりに
このスプリントでは「削除系 API の 204 対応」と「401/403 のずれ」という、典型的なレスポンス処理の落とし穴に2つはまった。
特に 401/403 の方は、当時「401 に揃えれば解決」と思って直したものが、あとで「401 はクライアントに副作用がある」と分かって 400 に直す、という二段構えになった。「できたこと」だけでなく「あとで間違いと分かったこと」も正直に残すのが、次のイテレーションを速くするんだと思う。
この記事は 2026 年 4 月時点の記録で、その後の実装変更(401→400・UI のアーカイブ退避)を時点メモとして補足しています。HTTP ステータスコードの厳密な扱いや DRF のデフォルト挙動などは時間とともに変わる可能性があるので、参考にする際は最新の情報も合わせて確認してください。