紡Book の勉強ノートに戻る
コードレビュー学習2026.05.11

正しいエラー文(パスワードが違います)がユーザーに届かなくなった — エラーの種類を表す番号(HTTPステータス)401と400の使い分け

HTTPAPI設計Django REST framework

コードレビューで見つかった「ステータスコードの意味づけが噛み合っていない」問題を、修正と一緒にまとめたものです(修正済み)。

この記事でわかること

  • HTTP 401 と 400 の意味の違い
  • ステータスコードがクライアント側の挙動を分岐させる設計の落とし穴
  • 「認証していない」と「認証してるけどリクエスト内容が変」の分け方
  • frontend 共通 fetch の apiRequestJson が 401 で token refresh を試みる構造

起きていたこと

写真の共有ページ(パスワード認証付きの公開URL)で、ユーザがパスワードを間違えたとき:

python
# backend: SharedPagePublicView
def post(self, request, token):
    shared = get_object_or_404(SharedPage, share_token=token, ...)
    if not check_password(input_password, shared.password_hash):
        return Response(
            {"detail": "パスワードが違います。"},
            status=status.HTTP_401_UNAUTHORIZED,  # ← ここ
        )

backend は 401 を返す。意図は「パスワード違うよ」。

frontend は共通の apiRequestJson でこれを受ける:

ts
export async function apiRequestJson<T>(input, init?): Promise<T> {
    const res = await fetch(input, withCsrf(init));
 
    if (res.status === 401) {
        // ← 401 なら認証切れだと判断して token refresh を試す
        const refreshed = await tryRefresh();
        if (refreshed) {
            const retry = await fetch(input, withCsrf(init));
            ...
        }
        throw new Error("認証が必要です");
    }
    ...
}

apiRequestJson は「401 = アクセストークン切れ」と解釈して、refresh token で再発行を試みる作りになっていた。これは認証ありエンドポイント向けの正しい挙動。

置き場所。パスワードの正は DB。401 は応答に乗るだけで保存しない。入力したパスワードリクエスト本文メモリ・保存しないCookie ではないPostgreSQLphotos_sharedpagepassword_hash照合の正。DB に保存HTTP 応答401detail に正しい文番号も文も保存しない401 を見た共通 fetch が body を捨て、入館パスの取り直しへ進む。関数は交差点で、置き場所ではない。ブラウザ Cookierefresh_token共有のパスワードではない画面の error認証が必要ですReact のメモリ。保存しないdetail の文は応答にあったが、401 分岐が body を捨てるので届かない。表示は汎用エラーになる。

正しいエラーメッセージ("パスワードが違います")がユーザに届かない。

何が間違っていたか: ステータスコードの意味

HTTP の主要なクライアントエラーコードを並べると:

Code 意味 典型例
400 Bad Request リクエストの内容が変 バリデーションエラー、形式不正
401 Unauthorized 認証情報がない / 無効 トークン切れ、ログインしてない
403 Forbidden 認証はOK / 権限がない admin 専用 API に一般ユーザがアクセス
404 Not Found リソースがない URL のパスが間違ってる
409 Conflict リクエストが現在の状態と矛盾 同じ名前で既に登録済み
422 Unprocessable Entity 形式は OK / 意味的に処理不能 フォーム / JSON のバリデーション失敗

「公開共有ページのパスワード検証失敗」は、どれに当たるか:

  • 401 か? → 違う。共有ページは permission_classes = [] の公開エンドポイント。そもそも認証してない / 認証する必要もない
  • 400 か? → こっちが近い。「リクエストの中身(パスワード文字列)が間違っている」=リクエスト内容の問題
  • 403 か? → 微妙。「認証はOK / 権限がない」のニュアンスだが、認証自体が無い

→ 400 Bad Request が semantically 正しい

置き場所。refresh_token は Cookie。パスワードの正は DB。番号は保存しない。ログイン切れ。401 が正しいブラウザ Cookierefresh_token閉じても残るHTTP 応答 401保存しない番号だけが乗る同じ Cookie を POST取り直し先も Cookie共有パスワードではないパスワード違い。400 が正しい入力したパスワードリクエスト本文メモリ・保存しないPostgreSQLpassword_hashDB に保存HTTP 応答 400detail に正しい文番号も文も保存しない画面の errordetail を表示メモリ。保存しない

直し方

backend を 400 に変えた:

python
if not check_password(input_password, shared.password_hash):
    # 公開共有ページのパスワード検証失敗は「認証」ではなくリクエスト内容の問題なので 400 を返す。
    # 401 にすると frontend 共通 `apiRequestJson` が token refresh を試みて汎用エラーになる。
    return Response(
        {"detail": "パスワードが違います。"},
        status=status.HTTP_400_BAD_REQUEST,
    )

frontend 側もハンドラを直した。元は文字列マッチで「err.message.includes("401")」を判定していたが、ステータス番号で分岐するより**detail フィールドのメッセージを使う**ほうが頑健です:

ts
try {
    const data = await fetch_verifyShare(token, password);
    setPage(data);
} catch (err) {
    // 404 のみ専用文言。それ以外は backend の detail("パスワードが違います。")を formatApiError で抽出。
    if (err instanceof Error && err.message.includes("404")) {
        setError("このページは存在しないか、無効化されています");
    } else {
        setError(formatApiError(err, "エラーが発生しました。もう一度お試しください"));
    }
}

formatApiError は backend が返す {detail: "..."} を綺麗に展開してくれるユーティリティ。

教訓: ステータスコードは「副作用」を持つ

ステータスコードは数字じゃなくて契約で、クライアント(特にミドルウェア層)はそれを見て自動分岐します:

  • 401 を返すと → クライアントは認証フローに入ります(refresh、再ログイン誘導)
  • 403 を返すと → クライアントは権限不足のメッセージを出します
  • 400 を返すと → クライアントは body の detail を読んで表示します
  • 404 を返すと → クライアントは「ない」ことを表示します

「ユーザに伝えたい意味」だけでなく「クライアントが受け取った時に何を自動でする想定か」も含めて選ぶ必要があります。

学びポイント

  • 「認証失敗」と「認証OK / リクエストの中身がおかしい」は別物。前者は 401、後者は 400
  • ステータスコードはクライアント側のミドルウェアで分岐の起点になる。**「副作用」**を持ちます
  • frontend 共通 fetch が 401 をどう扱うかを把握してから backend のコードを書くと噛み合います
  • {detail: "..."} のメッセージを返す DRF 系なら、frontend は「detail を読む」前提でハンドラを書くと頑健です

おわりに

HTTP のステータスコードは「人間が読むためのラベル」ではなくて、「機械が分岐するための契約」。401 と 400 の使い分けは、書き手と読み手(ライブラリも含めて)の暗黙の合意があります。

「動いてるからOK」のレベルだと噛み合わせのズレは見えません。意図と契約が一致しているかを、コードレビューで確認する価値があります。


この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。