コードレビューで見つかった「ステータスコードの意味づけが噛み合っていない」問題を、修正と一緒にまとめたものです(修正済み)。
この記事でわかること
- HTTP 401 と 400 の意味の違い
- ステータスコードがクライアント側の挙動を分岐させる設計の落とし穴
- 「認証していない」と「認証してるけどリクエスト内容が変」の分け方
- frontend 共通 fetch の
apiRequestJsonが 401 で token refresh を試みる構造
起きていたこと
写真の共有ページ(パスワード認証付きの公開URL)で、ユーザがパスワードを間違えたとき:
# 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 でこれを受ける:
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 で再発行を試みる作りになっていた。これは認証ありエンドポイント向けの正しい挙動。
正しいエラーメッセージ("パスワードが違います")がユーザに届かない。
何が間違っていたか: ステータスコードの意味
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 正しい
直し方
backend を 400 に変えた:
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 フィールドのメッセージを使う**ほうが頑健です:
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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。