綴Photo の勉強ノートに戻る
コードレビュー学習2026.04.02

DELETE 204 と 401/403 ステータスコードずれ

コードレビューDjangoREST APIDRF

コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。


この記事でわかること

  • 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 として読めない」と言ってエラーになる。


問題①: 当時のコード

typescript
// フロントエンド: 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
}
python
# バックエンド側(FavoriteMemoryViewSet)
class FavoriteViewSet(viewsets.ModelViewSet):
    def destroy(self, request, *args, **kwargs):
        return super().destroy(request, *args, **kwargs)
        # ↑ DRF のデフォルト destroy() → 204 No Content を返す

問題①: なぜ危険か

同じ削除でも、場所によって結果が分かれます。

お気に入りの行は PostgreSQL の FavoriteMemory 表。画面の一覧はブラウザのメモリ。HTTP の返事は保存しない。FavoriteMemory 表HTTP(保存しない)メモリ(React state)画面削除した直後行が消える削除は成功204 ボディ空JSON なしまだ残るパースで落ちる残ったままもう一度押すもう無い行は戻らない404既に無いまだ残る残ったままページを開き直す無いまま表が正GET で JSON一覧を取り直す作り直す表から読み直し消えて見える

問題①: 対策(考え方)

根本原因: API ラッパー apiRequestJson() が「全エンドポイントで JSON ボディが返ってくる」を前提に書かれていた。DELETE は 204 No Content(ボディ空)を返す仕様なので、res.json() の段階で爆発します。

直し方の定石: 「JSON を返すやつ」と「何も返さないやつ」をラッパーレベルで2つに分けて、DELETE は後者を使うようにします。

バックエンドは何も悪くない。DRF の ModelViewSet.destroy() は仕様通りに 204 No Content を返している。フロント側のラッパーが「全 API は JSON が返る」と決め打ちしていたのが原因。

DELETE 用に「成功時には res.json() を呼ばないラッパー」を用意する、という一般的な書き方は下のようになります(説明用の例。冒頭の時点メモのとおり、綴Photo の実コードにこの関数があるわけではありません)。

typescript
// 説明用の最小例:成功時に 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: サーバーがリクエストを拒否したケース。認証済みでも権限がない、あるいはそもそも拒否したい、といった意味合い

…という違いがあるらしい。フロントエンドとバックエンドで別々のコードを使うと、エラーメッセージの出し分けが正しく動きません。


問題②: 当時のコード(フロントとバックでコードがずれていた)

typescript
// フロントエンド: "403" で判定していた
if (err instanceof Error && err.message.includes("403")) {
    setError("パスワードが違います。");
} else {
    setError("エラーが発生しました。");
}
python
# バックエンド: 401 を返していた
return Response(
    {"detail": "パスワードが違います。"},
    status=http_status.HTTP_401_UNAUTHORIZED,  # ← 401
)

フロントは 403 を見ているのにバックは 401 を返している。番号が噛み合っていないので、パスワード誤入力時に専用メッセージが出ない。


問題②: なぜ危険か

セキュリティ上の問題ではないが、UX の信頼性を損ないます。


問題②: 対策(当時 → 今)

当時は「バックエンドの 401 に合わせてフロントを直す」方向で揃えた。慣行的にも「パスワード誤り = 認証情報の問題」として 401 を採用するサービスが多い印象だったので、フロントを includes("401") に寄せた。

typescript
// 当時の対策:フロントを 401 判定に寄せた
if (err instanceof Error && err.message.includes("401")) {
    setError("パスワードが違います。");
}

ただし、この 401 寄せはあとで間違いだと分かった。 共有ページのパスワード検証は「ログイン認証」ではなく「リクエストの中身(パスワード文字列)が違う」話。なのに 401 を返すと、frontend 共通の apiRequestJson が「アクセストークンが切れた」と誤解して、関係ない token refresh を走らせてしまいます。結果、せっかくの「パスワードが違います」が汎用エラーに化けてユーザーに届きません。

今は 400 Bad Request を返し、フロントは番号判定をやめて detail メッセージを読む方式に変えています。

python
# 現在のバックエンド(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,
    )

番号と文言は、置いてある場所が違います。

合言葉の正は PostgreSQL の SharedPage.password_hash。番号と detail は HTTP の返事で保存しない。refresh トークンは Cookie。画面の文言はブラウザのメモリ。HTTP の番号(保存しない)Cookie(refresh)画面のメモリ当時401フロントは 403 待ち触らないエラーが発生しましたdetail は未読401 に揃えたとき401認証切れと誤解取り直しが走る関係ない refresh認証が必要です今400リクエスト内容の問題触らないパスワードが違いますJSON の detail を読む

この「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 のデフォルト挙動などは時間とともに変わる可能性があるので、参考にする際は最新の情報も合わせて確認してください。