(例外はどこで拾われるのか)=
# 例外はどこで拾われるのか
本番環境でアプリケーションが例外を送出したとき、何が起きるのでしょうか。
- **開発環境**: Django は親切なデバッグページを表示し、FastAPI は JSON 形式のエラーレスポンスを返します。
- **本番環境**: `DEBUG = False` の Django は素っ気ない「Server Error (500)」を返し、FastAPI も最小限の `{"detail": "Internal Server Error"}` を返すだけです。
この違いは、例外が「どの層で捕捉されるか」を理解していれば自然に説明できます。Python の Web アプリケーションでは、リクエストは複数の層を通過して処理されます。ビューの中で例外が発生した場合、その例外はまずミドルウェアに伝播し、次にフレームワークのエラーハンドラに伝播し、さらにサーバ(Gunicorn や Uvicorn)に伝播します。各層には例外を捕捉する仕組みがあり、どの層で捕捉されるかによって、ユーザーに返されるレスポンスの内容やログの記録方法が変わります。
以降では、この「例外の伝播経路」をビューの中から外に向かって順に辿り、各層がどのように例外を扱うかを見ていきます。
```{mermaid}
flowchart TD
V["ビュー / エンドポイント
try/except で捕捉"] -->|捕捉しない| M[ミドルウェア
process_exception / try-except]
M -->|捕捉しない| F["フレームワーク
exception handler
4xx / 5xx レスポンス生成"]
F -->|handler 自体が失敗| S["サーバ
Gunicorn / Uvicorn
最後の砦として 500"]
```
Vol.2「Django を WSGI 視点で見る」で Django のミドルウェアチェーンを、Vol.2「FastAPI を ASGI 視点で見る」で FastAPI のミドルウェアと exception handler を学びましたが、それらの知識が「例外が発生したとき」という文脈でどう機能するかを見ていきます。
## View 内
例外の旅の出発点は、ビュー(Django)またはエンドポイント関数(FastAPI)の中です。
もっとも基本的なエラー処理は、ビューの中で `try/except` を使って例外を捕捉し、適切なレスポンスを返すことです。
```{tip}
ビューの中で例外を捕捉する場合、「予期している例外」と「予期していない例外」を区別することが重要です。
```
```python
# Django のビュー
from django.http import JsonResponse
def get_order(request, order_id):
try:
order = Order.objects.get(id=order_id)
except Order.DoesNotExist:
return JsonResponse({"error": "Order not found"}, status=404)
return JsonResponse({"id": order.id, "total": order.total})
```
```python
# FastAPI のエンドポイント
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
order = await fetch_order(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return {"id": order.id, "total": order.total}
```
Django の例では、`Order.DoesNotExist` という例外を捕捉して 404 レスポンスを自分で組み立てています。FastAPI の例では、`HTTPException` を送出しています。FastAPI の `HTTPException` は通常の Python 例外ですが、フレームワークが特別扱いする例外であり、送出されるとフレームワークの exception handler が捕捉して適切な HTTP レスポンスに変換します。
上の例の `Order.DoesNotExist` は予期している例外です。ユーザーが存在しない注文 ID を指定することは想定内であり、404 を返すのは正常なアプリケーションの振る舞いです。
一方、データベースの接続エラーやメモリ不足のような予期しない例外を、ビューの中で安易に `except Exception` で握りつぶしてはいけません。
```python
# やってはいけないパターン
def get_order(request, order_id):
try:
order = Order.objects.get(id=order_id)
return JsonResponse({"id": order.id, "total": order.total})
except Exception:
return JsonResponse({"error": "Something went wrong"}, status=500)
```
```{danger}
このコードは一見すると「どんなエラーでも 500 を返す堅牢なコード」に見えますが、実際には問題を隠蔽しています。データベースが停止しているのか、コードにバグがあるのか、メモリが枯渇しているのか――原因が何であれ同じ「Something went wrong」になり、ログにもトレースバックが記録されません。13-6 で構築した可観測性の仕組みが無意味になります。
```
予期しない例外はビューの中で捕捉せず、外側の層に伝播させるのが正しい設計です。
## middleware
ビューから送出された例外が `try/except` で捕捉されなかった場合、次に通過するのはミドルウェアの層です。
Vol.2「Django を WSGI 視点で見る」で Django のミドルウェアチェーンを学んだとき、リクエストがミドルウェアを順番に通過し、ビューに到達し、レスポンスが逆順にミドルウェアを通過して返される、という流れを確認しました。例外が発生した場合もこの逆順の流れに従います。Django のミドルウェアには `process_exception` メソッドがあり、ビュー内で捕捉されなかった例外を処理する機会が与えられます。
```python
# Django のカスタムミドルウェアで例外を処理する
import logging
logger = logging.getLogger(__name__)
class ErrorLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
logger.error(
"Unhandled exception in %s %s",
request.method, request.path,
exc_info=True,
extra={"request_path": request.path},
)
# None を返すと、例外はさらに外側の層に伝播する
# HttpResponse を返すと、そのレスポンスがクライアントに返される
return None
```
`process_exception` が `None` を返した場合、例外は次のミドルウェア(逆順なので、リクエスト時に先に通過したミドルウェア)の `process_exception` に伝播します。すべてのミドルウェアが `None` を返した場合、例外はフレームワークのデフォルトエラーハンドラに到達します。
FastAPI(Starlette)のミドルウェアでは、例外処理の仕組みが異なります。ASGI ミドルウェアは `try/except` を使って `call_next` の呼び出しを囲むことで、例外を捕捉できます。
```python
# FastAPI のミドルウェアで例外を処理する
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
@app.middleware("http")
async def error_logging_middleware(request: Request, call_next):
try:
response = await call_next(request)
return response
except Exception as exc:
logger.error(
"Unhandled exception in %s %s",
request.method, request.url.path,
exc_info=True,
)
# 例外を再送出してフレームワークのハンドラに任せるか、
# 自分でレスポンスを返すかを選ぶ
raise
```
```{note}
ミドルウェアで例外を捕捉する利点は、ビューごとに `try/except` を書かなくても、アプリケーション全体にわたる横断的なエラー処理(ログ記録、エラー通知、カスタムエラーレスポンスの生成)を一箇所にまとめられることです。
```
Vol.1「WSGI が生まれた背景」で WSGI ミドルウェアの概念を学び、Vol.1「WSGI の上に何が必要になるのか」で Werkzeug のミドルウェアを見ましたが、エラー処理はミドルウェアの典型的な活用場面です。
## フレームワーク
すべてのミドルウェアを通過しても捕捉されなかった例外は、フレームワーク自身のエラーハンドラに到達します。
Django では、`django.core.handlers.exception` モジュールの `response_for_exception` 関数が最終的な例外処理を担います。この関数は、例外の種類に応じて次のように適切なレスポンスを生成します。
| 例外クラス | HTTP ステータス |
|---|---|
| `Http404` | 404 Not Found |
| `PermissionDenied` | 403 Forbidden |
| `SuspiciousOperation` | 400 Bad Request |
| それ以外 | 500 Internal Server Error |
```{warning}
本番環境で `DEBUG = True` にしてはいけない理由のひとつは、デバッグページにデータベースの接続情報や環境変数といった機密情報が含まれるからです。`DEBUG = False` の場合は、`handler500` に登録されたビュー(デフォルトでは `django.views.defaults.server_error`)が呼び出され、`500.html` テンプレートがレンダリングされます。
```
FastAPI では、`HTTPException` は専用の exception handler が処理します。それ以外の例外は、Starlette のデフォルトの `ServerErrorMiddleware` が捕捉し、500 レスポンスを返します。FastAPI では、カスタムの exception handler を登録して特定の例外に対する応答をカスタマイズできます。
```python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class PaymentError(Exception):
def __init__(self, order_id: str, message: str):
self.order_id = order_id
self.message = message
@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
return JSONResponse(
status_code=402,
content={
"error": "payment_failed",
"order_id": exc.order_id,
"detail": exc.message,
},
)
```
この仕組みにより、ビューの中で `raise PaymentError("ORD-123", "Card declined")` と書くだけで、フレームワークが適切な JSON レスポンスを生成してくれます。ビューのコードからエラーレスポンスの構築ロジックを分離できるため、ビューはビジネスロジックに集中できます。
フレームワークのエラーハンドラのもう一つの重要な役割は、例外をログに記録することです。Django は `django.request` ロガーに ERROR レベルで例外を記録します。13-6 で構築したログ収集の仕組みがこのロガーを拾っていれば、本番環境で発生した未捕捉の例外はすべて記録されます。
## サーバ
フレームワークのエラーハンドラ自体が例外を送出するという、まれですが起こりうるケースがあります。テンプレートのレンダリング中にエラーが発生した場合(たとえば `500.html` テンプレートが見つからない場合)や、exception handler のコード自体にバグがある場合です。このとき例外は WSGI/ASGI のインターフェースを超えて、サーバ層にまで到達します。
**Gunicorn の場合**
- ワーカーが処理中に未捕捉の例外を送出した場合、その例外をログに記録し、クライアントに 500 レスポンスを返します。
- ワーカー自体は終了せず、次のリクエストを処理し続けます。
- ただし、例外がワーカーの実行環境を回復不能な状態にした場合(C 拡張のセグメンテーションフォールトなど)、ワーカープロセスは異常終了し、arbiter が新しいワーカーを起動します。
**Uvicorn の場合**
- ASGI アプリケーションが例外を送出し、まだレスポンスヘッダを送信していなければ、Uvicorn は 500 レスポンスを返します。
- ただし、レスポンスヘッダがすでに送信済みの場合は、ステータスコードを変更することができません。
- このケースでは、ユーザーは 200 のステータスコードと不完全なレスポンスボディを受け取ることになります。
Vol.2「なぜ ASGI が必要になったのか」で ASGI の send の仕組みを学んだとき、「レスポンスヘッダは一度しか送信できない」という制約を確認しましたが、エラー処理の文脈ではこの制約が実務的な意味を持ちます。
```{important}
サーバ層は、例外の「最後の砦」です。ここまで到達する例外は、アプリケーションのコードでは対処できなかった深刻な問題を示しています。サーバ層のエラーログを日常的に監視し、ここに例外が記録されたら調査する、という運用フローを組み込んでおくことが重要です。
```
---
例外の伝播経路を図示すると、次のようになります。
```
ビュー / エンドポイント
│ try/except で捕捉 → 適切なレスポンスを返す
│ 捕捉しない →
▼
ミドルウェア
│ process_exception / try-except で捕捉 → ログ記録、レスポンス返却
│ 捕捉しない →
▼
フレームワークのエラーハンドラ
│ 例外の種類に応じて 4xx/5xx レスポンスを生成
│ exception handler 自体が失敗 →
▼
サーバ (Gunicorn / Uvicorn)
│ 最後の砦として 500 レスポンスを返す
│ ログに記録する
```
この図は、Vol.1「本書の対象読者とゴール」で描いた「ブラウザからレスポンスまでの全体像」を、例外処理の視点から再描画したものです。リクエストが外から内へ層を通過して処理されるように、例外は内から外へ層を通過して捕捉されます。
どの層で例外を捕捉すべきかは、「その層が例外について適切な判断を下せるか」で決まります。
- **ビュー**: ビジネスロジック上の判断(存在しないリソースに 404 を返す)
- **ミドルウェア**: 横断的な関心事(エラーログの記録、エラー通知)
- **フレームワーク・サーバ**: 予期しない例外の安全網
次節では、この理解を踏まえて、本番環境でのエラーレスポンスの設計――ユーザーに何を見せ、何を見せないか――を考えていきます。
(HTTP ステータスコードと失敗の種類)=
## HTTP ステータスコードと失敗の種類
前節では、例外がビューからミドルウェア、フレームワーク、サーバへと伝播する経路を辿りました。各層で例外が捕捉されたとき、最終的にクライアントに返されるのは HTTP レスポンスです。そのレスポンスの先頭に置かれるステータスコードは、「何が起きたか」をクライアントに伝える最初の手がかりになります。
Vol.1「HTTP は何をやりとりしているのか」で HTTP の基礎を学んだとき、ステータスコードの分類(2xx は成功、4xx はクライアント側のエラー、5xx はサーバ側のエラー)を確認しました。以降では、Web アプリケーションの開発で頻繁に扱うステータスコードを取り上げ、それぞれがどのような「失敗の種類」に対応するかを見ていきます。
```{tip}
ステータスコードを正しく使い分けることは、API のクライアントにとっても運用チームにとっても重要です。すべてのエラーに 500 を返す、あるいはすべてに 400 を返すアプリケーションは、どちらにとっても扱いづらいものになります。
```
```{mermaid}
flowchart TD
E[エラー発生] --> Q{責任はどこに}
Q -->|クライアント側| C4xx[4xx クライアントエラー]
Q -->|サーバ側| S5xx[5xx サーバエラー]
C4xx --> A400[400 リクエスト不正]
C4xx --> A401[401 未認証]
C4xx --> A403[403 権限なし]
C4xx --> A404[404 リソースなし]
C4xx --> A422["422 バリデーション失敗
(FastAPI)"]
S5xx --> B500[500 内部エラー]
S5xx --> B502[502 上流接続失敗]
S5xx --> B504[504 上流タイムアウト]
```
### 400
400 Bad Request は、「リクエストの形式が不正であり、サーバはこのリクエストを理解できない」ことを意味します。
次のようなケースが 400 に該当します。
- クライアントが送信した JSON の構文が壊れている
- 必須のクエリパラメータが欠けている
- Content-Type ヘッダが実際のリクエストボディと一致しない
Django では、`SuspiciousOperation` 例外やそのサブクラス(`DisallowedHost`、`RequestDataTooBig` など)が送出されると、フレームワークが自動的に 400 を返します。ビューの中で明示的に 400 を返す場合は、リクエストデータの検証に失敗したときです。
```python
# Django
from django.http import JsonResponse
import json
def create_order(request):
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse(
{"error": "Invalid JSON in request body"},
status=400,
)
if "product_id" not in body:
return JsonResponse(
{"error": "product_id is required"},
status=400,
)
# ...
```
FastAPI では、リクエストボディの JSON 解析やパスパラメータの型変換に失敗した場合、フレームワークが自動的に 422(後述)を返します。400 を明示的に返したい場合は `HTTPException` を使います。
400 と 422 の使い分けについては、422 の項で詳しく述べます。
### 401
401 Unauthorized は、「認証が必要だが、リクエストには認証情報が含まれていないか、認証情報が無効である」ことを意味します。名前に「Unauthorized」とありますが、実際には「Unauthenticated」の意味です。
```{note}
認証(あなたは誰か)と認可(あなたに権限があるか)の混同は HTTP の仕様自体に起因する歴史的な問題です。実務上は「ログインしていない」または「認証トークンが無効」の場合に 401 を使います。
```
Django REST Framework を使っている場合、認証バックエンドが認証に失敗すると自動的に 401 が返されます。FastAPI では、セキュリティの依存関係(`Depends` で注入する認証ロジック)が認証に失敗したときに `HTTPException(status_code=401)` を送出するのが一般的です。
```python
# FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPBearer
app = FastAPI()
security = HTTPBearer()
async def get_current_user(credentials=Depends(security)):
user = await verify_token(credentials.credentials)
if user is None:
raise HTTPException(
status_code=401,
detail="Invalid or expired token",
)
return user
```
401 を返す際は、HTTP の仕様上 `WWW-Authenticate` レスポンスヘッダを含めることが求められています。このヘッダは、クライアントがどの認証スキーム(Bearer、Basic など)を使うべきかを示します。
### 403
403 Forbidden は、「認証は成功したが、このリソースへのアクセス権限がない」ことを意味します。
| ステータス | 意味 |
|---|---|
| 401 | あなたが誰かわからない(未認証) |
| 403 | あなたが誰かは分かったが、この操作は許可されていない(権限なし) |
Django では、ビューの中で `PermissionDenied` 例外を送出するか、`return HttpResponseForbidden()` を返すことで 403 を表現します。Django のパーミッションシステム(`@permission_required` デコレータや `has_perm` メソッド)が権限チェックに失敗したときも、内部的に `PermissionDenied` が送出されます。
```python
# Django
from django.core.exceptions import PermissionDenied
def delete_order(request, order_id):
order = get_object_or_404(Order, id=order_id)
if order.user != request.user:
raise PermissionDenied("You cannot delete another user's order")
order.delete()
return JsonResponse({"status": "deleted"})
```
FastAPI では、同様に `HTTPException(status_code=403)` を送出します。
```{caution}
403 と 404 の選択は、セキュリティの観点から慎重に判断すべき場面があります。たとえば、管理者専用のエンドポイント `/admin/users/` に一般ユーザーがアクセスした場合、403 を返すとそのエンドポイントが存在することをクライアントに教えてしまいます。存在自体を隠したい場合は、あえて 404 を返すという選択もあります。
```
### 404
404 Not Found は、「リクエストされたリソースが見つからない」ことを意味します。もっとも馴染み深いステータスコードでしょう。
Django では、`Http404` 例外を送出するか、`get_object_or_404` ショートカットを使うことで 404 を表現します。Vol.2「Django を WSGI 視点で見る」で解説した URL 解決の流れで、どの URL パターンにもマッチしなかった場合も 404 が返されます。
```python
# Django
from django.shortcuts import get_object_or_404
def get_order(request, order_id):
order = get_object_or_404(Order, id=order_id)
return JsonResponse({"id": order.id, "total": order.total})
```
FastAPI では `HTTPException(status_code=404)` を使います。ルーティング(Vol.2「FastAPI を ASGI 視点で見る」で解説したルーティングの流れ)でどのエンドポイントにもマッチしなかった場合も、Starlette が自動的に 404 を返します。
```{note}
404 の設計で見落とされがちなのは、一覧エンドポイントと個別リソースエンドポイントの違いです。
- `GET /api/orders/`(一覧): 結果が 0 件でも 200 を返し、空の配列 `[]` をレスポンスボディに含めるのが一般的です。
- `GET /api/orders/999`(個別): そのリソースが存在しない場合は 404 を返します。
```
### 405
405 Method Not Allowed は、「リクエストされた URL は存在するが、指定された HTTP メソッドは受け付けていない」ことを意味します。
たとえば、`GET /api/orders/` は有効だが `DELETE /api/orders/` は定義されていない場合に 405 が返されます。Django のクラスベースビューは、ビューが `get` メソッドを定義しているが `delete` メソッドを定義していない場合に自動的に 405 を返します。FastAPI でも、あるパスに `@app.get` は定義されているが `@app.delete` が定義されていない場合に 405 が返されます。
405 レスポンスには `Allow` ヘッダを含めることが HTTP の仕様で求められています。このヘッダは、そのURLで許可されている HTTP メソッドの一覧を示します(例: `Allow: GET, POST, HEAD`)。Django も FastAPI もこのヘッダを自動的に付与します。
404 と 405 の違いは、API のクライアントにとって有用な情報です。
| ステータス | 意味 |
|---|---|
| 404 | その URL 自体が存在しない |
| 405 | その URL は存在するが、使い方が間違っている |
クライアント開発者がデバッグする際、405 と `Allow` ヘッダを見れば「DELETE ではなく POST を使うべきだった」とすぐに気づけます。
### 422
422 Unprocessable Entity は、「リクエストの構文は正しいが、内容のセマンティクスが不正である」ことを意味します。もともとは WebDAV の拡張仕様で定義されたステータスコードですが、API の文脈で広く使われるようになりました。
FastAPI がこのステータスコードを積極的に使う点は特筆に値します。Vol.2「FastAPI を ASGI 視点で見る」で解説した Pydantic によるデータ検証が失敗した場合、FastAPI は自動的に 422 を返し、どのフィールドがどのように不正だったかを詳細に示すレスポンスボディを生成します。
```python
# FastAPI
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
product_id: int
quantity: int = Field(gt=0)
@app.post("/orders/")
async def create_order(order: OrderCreate):
# Pydantic の検証を通過したデータだけがここに到達する
return {"status": "created"}
```
`quantity` に `-1` を送信すると、FastAPI は次のような 422 レスポンスを返します。
```json
{
"detail": [
{
"type": "greater_than",
"loc": ["body", "quantity"],
"msg": "Input should be greater than 0",
"input": -1
}
]
}
```
400 と 422 の使い分けは、API 設計においてしばしば議論になります。一般的な整理は次の通りです。
| ステータス | 使うべきケース | 例 |
|---|---|---|
| 400 | リクエストの構文そのものが壊れている | JSON が解析できない、必須ヘッダがない |
| 422 | 構文は正しいが、値がビジネスルールに違反している | 数量が負の数、メールアドレスの形式が不正 |
ただし、この区分けに厳密な標準はなく、すべてのバリデーションエラーに 400 を使う API も多く存在します。API の中では一貫した基準を持つことが求められます。
フレームワークごとのデフォルト動作の違いも把握しておきましょう。
- **Django REST Framework**: バリデーションエラーに 400 を返します。
- **FastAPI**: バリデーションエラーに 422 を返します。
この差異を知っておくと、Django と FastAPI を併用するシステムで API レスポンスの一貫性を保つ設計がしやすくなります。
### 500
500 Internal Server Error は、「サーバ内部で予期しないエラーが発生した」ことを意味します。14-1 で見たように、ビュー、ミドルウェア、フレームワークのいずれの層でも捕捉されなかった例外は、最終的にフレームワークまたはサーバが 500 を返します。
500 は「何かがおかしい」という以上の情報をクライアントに伝えません。そして、意図的にそうすべきです。
```{warning}
500 の詳細な原因をクライアントに返すことは、セキュリティ上のリスクになります。トレースバックやデータベースのクエリ、内部の設定情報がレスポンスに含まれれば、攻撃者にシステムの内部構造を教えることになります。Django が `DEBUG = False` で素っ気ない「Server Error」だけを返すのは、この原則に基づいています。
```
クライアントに見せる情報と、運用チームが調査に使う情報は、明確に分離すべきです。
- **クライアントへ**: 「エラーが発生しました。問題が続く場合はサポートにお問い合わせください」程度のメッセージと、エラーを一意に特定するためのリクエスト ID(あれば)を返します。
- **運用チームへ**: 詳細な原因はサーバ側のログに記録し、13-6 で構築したログ収集基盤を通じて調査します。
```python
# FastAPI でリクエスト ID を含むエラーレスポンスを返す例
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
except Exception:
logger.error("Unhandled exception", extra={"request_id": request_id},
exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"request_id": request_id,
},
headers={"X-Request-ID": request_id},
)
```
ユーザーが「エラーが出ました」と報告するとき、`request_id` を教えてもらえれば、ログの中から該当するリクエストの詳細を即座に特定できます。
### 502/503/504
これら 3 つのステータスコードは、13-8 のトラブルシューティングの観点で詳しく扱いました。ここでは、14-1 の「例外はどこで拾われるのか」という文脈での位置づけを補足します。
502、503、504 は、アプリケーションのコードが返すものではありません。リバースプロキシ(nginx)やロードバランサーが返すものです。つまり、14-1 で整理した「ビュー → ミドルウェア → フレームワーク → サーバ」という例外の伝播経路の、さらに外側にある層が返すステータスコードです。
アプリケーション開発者の視点から見ると、502/503/504 が返されているということは、自分のコードが生成したレスポンスがクライアントに届いていないことを意味します。問題はアプリケーションの外側にあるか、アプリケーションの外側から見た振る舞い(タイムアウト、プロセスの停止、接続の拒否)に起因しています。
```{tip}
これらのエラーの調査では、アプリケーションのログだけでなく、次の順で確認してください。
1. nginx のエラーログ
2. Gunicorn/Uvicorn のログ
3. 13-7 で設定したヘルスチェックの状態
```
13-8 で示した切り分けのフロー図は、そのまま本番環境の障害対応手順として使えます。
---
本節で取り上げた各ステータスコードに共通しているのは、「エラーの責任がどこにあるか」を示しているという点です。
- **4xx 系**: クライアントの責任(リクエストが不正、認証が必要、権限がない、リソースが存在しない)
- **5xx 系**: サーバの責任(予期しないエラー、上流サーバの障害、タイムアウト)
この区分けを正しく使うことで、API のクライアントは「自分のリクエストを修正すべきか、サーバの復旧を待つべきか」を判断できます。運用チームは、4xx の増加は API の使い方の問題として、5xx の増加はシステムの問題として、それぞれ適切な対応を取ることができます。
次節では、これらのステータスコードが返されるとき、レスポンスボディに何を含めるべきか――エラーレスポンスの設計について考えます。
(Django と FastAPI の例外処理の比較)=
## Django と FastAPI の例外処理の比較
前節では、各ステータスコードがどのような失敗に対応するかを整理しました。しかし、「400 を返す」「500 を返す」と言っても、実際にそのレスポンスを生成する仕組みはフレームワークによって大きく異なります。
- **Django**: テンプレートベースのエラーページを中心に設計されています。
- **FastAPI**: JSON レスポンスを前提とした exception handler の仕組みを持っています。
本書は Django と FastAPI のどちらかに偏らないことを方針としていますが、両者のエラー処理の設計思想の違いを理解しておくことは、「なぜそのフレームワークはそういう挙動をするのか」を考える力に直結します。
```{mermaid}
flowchart LR
subgraph Django
D1[例外発生] --> D2["handler400 / 403 / 404 / 500
HTTP ステータスコードに対応"]
D2 --> D3["テンプレート
400.html / 404.html etc."]
end
subgraph FastAPI
F1[例外発生] --> F2["@app.exception_handler
Python 例外クラスに対応"]
F2 --> F3["JSONResponse
統一フォーマット"]
end
```
Vol.2「Django を WSGI 視点で見る」で Django の内部構造を、Vol.2「FastAPI を ASGI 視点で見る」で FastAPI の内部構造を学んだ知識が、ここで合流します。
### Django の handler400/403/404/500
Django のエラー処理は、URL 設定(`urls.py`)に登録するカスタムエラーハンドラと、テンプレートによるエラーページのレンダリングを中心に設計されています。
14-1 で、フレームワークの層が例外の種類に応じて適切なレスポンスを生成すると説明しました。Django はこの処理を、`handler400`、`handler403`、`handler404`、`handler500` という 4 つのハンドラに委譲します。これらはルートの `urls.py` で上書きでき、カスタムのビュー関数を割り当てることができます。
```python
# myproject/urls.py
from django.urls import path
from myapp import views
urlpatterns = [
# 通常のURLパターン
path("orders/", views.order_list),
# ...
]
# カスタムエラーハンドラの登録
handler400 = "myapp.views.custom_400"
handler403 = "myapp.views.custom_403"
handler404 = "myapp.views.custom_404"
handler500 = "myapp.views.custom_500"
```
カスタムハンドラを登録しない場合、Django はデフォルトのハンドラ(`django.views.defaults` モジュール内)を使います。デフォルトのハンドラは、対応するテンプレート(`400.html`、`403.html`、`404.html`、`500.html`)を探してレンダリングします。テンプレートが見つからなければ、最小限のテキストだけを含むレスポンスを返します。
```{note}
この設計には Django の歴史的な背景が反映されています。Django はサーバサイドで HTML をレンダリングする「フルスタック」のフレームワークとして生まれました。エラーページも HTML として提供し、サイトのデザインに合わせたカスタムエラーページを表示するのが自然な発想です。
```
カスタムの 404 ハンドラを書く場合、次のようになります。
```python
# myapp/views.py
from django.http import JsonResponse
from django.shortcuts import render
def custom_404(request, exception):
# API リクエストかブラウザリクエストかで応答を分ける
if request.content_type == "application/json":
return JsonResponse(
{"error": "Not found", "path": request.path},
status=404,
)
return render(request, "errors/404.html", status=404)
```
ここで注目すべきは、`handler404` の関数シグネチャに `exception` 引数がある点です。Django は `Http404` 例外のインスタンスをこの引数に渡します。一方、`handler500` には `exception` 引数がありません。500 エラーが発生した時点で、例外の情報をクライアントに渡すべきではない、という Django の設計判断がシグネチャに表れています。
```python
def custom_500(request):
# handler500 には exception 引数がない
# 例外の詳細はクライアントに渡さず、ログに記録する
return render(request, "errors/500.html", status=500)
```
Django のエラー処理でもう一つ重要なのは、`DEBUG = True` と `DEBUG = False` での挙動の違いです。
| 設定 | 表示される内容 |
|---|---|
| `DEBUG = True` | 例外のトレースバック、ローカル変数の値、SQL クエリのログ、リクエストのヘッダや POST データ、`settings.py` の内容(機密情報はマスク) |
| `DEBUG = False` | カスタムハンドラまたはデフォルトの最小限のエラーページのみ |
この切り替えは、14-1 で述べた「ビューの中で予期しない例外を握りつぶしてはいけない」という原則を支える仕組みでもあります。例外を握りつぶさずに伝播させれば、フレームワークが環境に応じた適切な処理をしてくれます。
Django REST Framework を使っている場合は、エラー処理の仕組みがさらに一層追加されます。DRF は独自の exception handler を持ち、`APIException` とそのサブクラスを捕捉して、一貫した JSON 形式のエラーレスポンスを生成します。DRF のデフォルトの exception handler はバリデーションエラーに 400、認証エラーに 401、権限エラーに 403 を返します。カスタムの exception handler を登録することで、レスポンスのフォーマットを統一したり、エラー通知サービスとの連携を追加したりできます。
```python
# Django REST Framework のカスタム exception handler
from rest_framework.views import exception_handler
import logging
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
# DRF のデフォルト処理を呼び出す
response = exception_handler(exc, context)
if response is None:
# DRF が処理しなかった例外(予期しない例外)
logger.error("Unhandled exception in DRF view",
exc_info=True)
return None # Django のデフォルト処理に委ねる
# レスポンスに一貫したフォーマットを適用する
response.data = {
"error": {
"status": response.status_code,
"detail": response.data,
}
}
return response
```
この handler は `settings.py` の `REST_FRAMEWORK` 設定で登録します。
```{caution}
DRF の exception handler は Django 本体の `handler400` 等とは別の仕組みであり、DRF のビュー内で発生した例外だけを処理します。DRF のビュー以外(通常の Django ビューやミドルウェア)で発生した例外は、Django 本体のエラーハンドラが処理します。この二重構造を理解しておかないと、「DRF のビューではエラーが JSON で返るのに、特定のエンドポイントだけ HTML が返る」といった混乱が生じます。
```
### FastAPI / Starlette の exception handler
FastAPI のエラー処理は、Starlette の exception handler をベースにした、よりプログラマティックな仕組みです。
FastAPI では、特定の例外クラスに対する handler を `@app.exception_handler` デコレータで登録します。14-1 で示した `PaymentError` の例がこのパターンです。このアプローチでは、Python の例外の型とエラーレスポンスの生成ロジックを直接結びつけます。
```python
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
app = FastAPI()
# HTTPException のハンドラをカスタマイズする
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"status": exc.status_code,
"detail": exc.detail,
"path": request.url.path,
}
},
)
# バリデーションエラーのハンドラをカスタマイズする
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
return JSONResponse(
status_code=422,
content={
"error": {
"status": 422,
"detail": "Validation failed",
"errors": exc.errors(),
}
},
)
```
FastAPI の exception handler は、Python の例外クラスの継承関係を活用できます。`PaymentError` の親クラスとして `ApplicationError` を定義し、`ApplicationError` に対する handler を登録すれば、そのサブクラスのすべてが同じ handler で処理されます。
```python
class ApplicationError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
class PaymentError(ApplicationError):
pass
class InventoryError(ApplicationError):
pass
@app.exception_handler(ApplicationError)
async def application_error_handler(request: Request, exc: ApplicationError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"status": exc.status_code,
"detail": exc.detail,
}
},
)
```
この設計により、ビューのコードでは `raise PaymentError(402, "Card declined")` や `raise InventoryError(409, "Out of stock")` と書くだけで、統一されたフォーマットのエラーレスポンスが自動的に生成されます。ビューにエラーレスポンスの構築ロジックが散らばることがありません。
FastAPI / Starlette のエラー処理には、exception handler とは別に `ServerErrorMiddleware` と `ExceptionMiddleware` という 2 つのミドルウェアが関与しています。リクエストの処理中に例外が発生すると、まず `ExceptionMiddleware` が登録済みの exception handler を探して実行します。該当する handler がなければ、`ServerErrorMiddleware` が最後の砦として 500 レスポンスを返します。`DEBUG` モードが有効な場合(Starlette の `debug=True`)、`ServerErrorMiddleware` は HTML のトレースバックページを生成します。
Django と FastAPI の exception handler の決定的な違いは、何をトリガーにするかです。
| フレームワーク | トリガー | 発想 |
|---|---|---|
| Django `handler400` 等 | HTTP ステータスコード | このステータスコードが返されるとき、どのビューでページを生成するか |
| FastAPI `@app.exception_handler` | Python の例外クラス | この例外が送出されたとき、どのようなレスポンスを生成するか |
この違いは、両フレームワークの設計思想を反映しています。
- **Django**: 「Web ページを返す」フレームワークとして出発し、エラーページもページの一種として扱います。
- **FastAPI**: 「API レスポンスを返す」フレームワークとして設計され、エラーもデータの一種として扱います。
どちらが優れているということではなく、想定するユースケースが異なります。しかし、現実のアプリケーションでは境界は曖昧です。Django で API を構築することも、FastAPI で HTML を返すこともあります。Django REST Framework が Django 本体とは別の exception handler を持つのは、まさにこの境界の曖昧さに対処するためです。自分のアプリケーションが「誰に対して」エラーを返すのか――ブラウザのユーザーか、API のクライアントか、あるいはその両方か――を意識した上で、エラー処理の仕組みを選ぶ必要があります。
---
Django はステータスコードに対応するハンドラとテンプレートを中心に、FastAPI は例外クラスに対応する handler と JSON レスポンスを中心に設計されています。いずれのフレームワークも、例外を伝播させればフレームワークが適切に処理するという基本原則は共通しています。
この理解は、次節以降で扱う本番環境でのセキュリティの話題にも直結します。エラーレスポンスに何を含めるか、含めないか。デバッグ情報をどう隠蔽するか。CSRF や CORS の設定をどう構成するか。いずれも、フレームワークのエラー処理の仕組みを理解していることが前提になります。
(入力値を信用しない)=
## 入力値を信用しない
「LLM にコードを生成してもらったら、入力値のチェックが一切入っていなかった」――これは現場で本当によくある話です。生成されたコードはロジックの骨格としては正しくても、外部から送られてくるデータを無条件に信頼してしまうことが少なくありません。
{numref}`例外はどこで拾われるのか`({ref}`例外はどこで拾われるのか`)のここまでで例外の捕捉位置やステータスコードの選び方を学びましたが、そもそも例外を減らし、攻撃を未然に防ぐには「入力値を信用しない」という原則が欠かせません。以降では、次の4つの観点から、Django と FastAPI それぞれの守り方を見ていきます。
- **バリデーション**: 形式・範囲の確認
- **エスケープ**: 出力先に応じた変換
- **型変換**: 型の境界での検証
- **サニタイズ**: 危険要素を除去しつつ受け入れる
```{mermaid}
flowchart LR
I[ユーザー入力] --> V[バリデーション
形式・範囲の確認]
V --> T[型変換
文字列 → 適切な型]
T --> S[サニタイズ
危険要素を除去]
S --> E[エスケープ
出力先に応じた変換]
E --> O[安全な出力
HTML / SQL / ヘッダ]
```
### バリデーション
バリデーションとは、受け取ったデータがアプリケーションの期待する形式と範囲に収まっているかを確認する工程です。次のような条件を、データベースに書き込む前に検証します。
- 名前は空でないこと
- 価格は 0 以上であること
- メールアドレスが RFC に従った形式であること
Django ではフォームやシリアライザがバリデーションの中心です。`forms.Form` や `ModelForm` を定義すると、各フィールドに型制約と `clean_*` メソッドによるカスタム検証が適用されます。
```python
# Django: forms.py
from django import forms
class OrderForm(forms.Form):
product_name = forms.CharField(max_length=100)
quantity = forms.IntegerField(min_value=1, max_value=9999)
email = forms.EmailField()
def clean_product_name(self):
name = self.cleaned_data["product_name"]
if "<" in name or ">" in name:
raise forms.ValidationError("商品名に不正な文字が含まれています。")
return name
```
FastAPI では Pydantic モデルがバリデーションを自動的に担います。型ヒントに基づいてリクエストボディやクエリパラメータを検証し、不正な値には 422 Unprocessable Entity を返します。`Field` の制約やカスタムバリデータも宣言的に記述できます。
```python
# FastAPI: schemas.py
from pydantic import BaseModel, Field, field_validator
class OrderCreate(BaseModel):
product_name: str = Field(max_length=100)
quantity: int = Field(ge=1, le=9999)
email: str = Field(pattern=r"^[\w\.\+\-]+@[\w\-]+\.[\w\.\-]+$")
@field_validator("product_name")
@classmethod
def no_angle_brackets(cls, v: str) -> str:
if "<" in v or ">" in v:
raise ValueError("商品名に不正な文字が含まれています。")
return v
```
```{important}
両フレームワークに共通する原則は、バリデーションをビジネスロジックの手前に置くことです。Django では `form.is_valid()` を呼ばずに `request.POST` を直接使うコードは危険であり、FastAPI でも Pydantic モデルを経由せずに `await request.json()` で辞書を取り出してしまうと、型チェックが一切行われません。フレームワークが用意したバリデーション機構を「飛ばさない」ことが最初の防衛線です。
```
### エスケープ
バリデーションを通過した値であっても、出力先の文脈に応じた無害化が必要です。これがエスケープです。
HTML コンテキストでのエスケープは XSS(クロスサイトスクリプティング)対策の基本です。Django テンプレートエンジンは変数を出力する際に `<`, `>`, `&`, `"`, `'` を自動的にエスケープします。`{{ user_input }}` と書くだけで `` は `<script>alert('xss')</script>` に変換され、ブラウザ上でスクリプトとして実行されません。
```{warning}
`{{ user_input|safe }}` や `{% autoescape off %}` を使うと自動エスケープが解除されます。ユーザー入力に対してはこれらを使用しないでください。
```
FastAPI は API サーバとして JSON を返すケースが多く、ブラウザが HTML として解釈する場面は限られます。しかし Jinja2 テンプレートを使っている場合や、ユーザー入力を HTML メールに埋め込む場合は同じリスクが発生します。FastAPI で Jinja2 を使う場合もデフォルトで自動エスケープが有効ですが、`Markup()` で明示的にエスケープを解除した箇所には注意が必要です。
SQL コンテキストでは、Django の ORM と FastAPI で一般的に使われる SQLAlchemy のいずれもパラメータ化クエリを内部で使用しており、通常の使い方であれば SQL インジェクションは防がれます。しかし Django の `extra()` や `RawSQL`、SQLAlchemy の `text()` に `f-string` でユーザー入力を埋め込むと保護が無効になります。
```python
# 危険な例(Django)
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{name}'")
# 安全な例(Django)
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [name])
```
```python
# 危険な例(SQLAlchemy)
from sqlalchemy import text
session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
# 安全な例(SQLAlchemy)
session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})
```
```{tip}
エスケープの要点は「データの出力先(HTML、SQL、URL、ログ)ごとに適切な変換を行う」ことであり、入力時に一括して処理するものではありません。
```
### 型変換
Web アプリケーションでは、HTTP リクエストから届くデータは基本的に文字列です。URL パスパラメータ `/orders/42` の `42` もクエリストリング `?page=3` の `3` も文字列として受信されます。これをプログラム内部の整数や日付に変換する工程が型変換であり、ここで不正な値が紛れ込むと予期しない動作が生じます。
Django のフォームフィールドは `to_python()` メソッドで文字列を Python オブジェクトに変換し、変換できなければ `ValidationError` を送出します。例えば `IntegerField` に `"abc"` を渡すと「整数を入力してください。」というエラーになります。
FastAPI では Pydantic と Python 型ヒントの組み合わせにより、パスパラメータの型変換が自動的に行われます。
```python
# FastAPI: パスパラメータの型変換
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
# order_id は自動的に int に変換される
# "/orders/abc" へのリクエストは 422 が返る
...
```
注意すべきは、暗黙的な型変換と明示的な型変換の違いです。Python の `int("0x1a", 16)` は16進数を受け付けますし、`bool("false")` は `True` を返します(空でない文字列はすべて真)。Pydantic v2 は `strict` モードを提供しており、`bool` フィールドが文字列の `"true"` を暗黙的に変換することを禁止できます。
```python
from pydantic import BaseModel, ConfigDict
class StrictSettings(BaseModel):
model_config = ConfigDict(strict=True)
is_active: bool # True/False のみ受付、"true" は拒否
count: int # 整数のみ受付、"123" は拒否
```
```{caution}
型変換は「フレームワークに任せれば安全」という領域ですが、フレームワークを迂回して自前で変換するコード(`int(request.GET["id"])`)を書くと `ValueError` が未処理のまま 500 エラーになる場合があります。型変換もバリデーションと同様に、フレームワークの仕組みを経由させることが鉄則です。
```
### サニタイズ
バリデーションが「不正な入力を拒否する」行為であるのに対し、サニタイズは「入力から危険な要素を除去または無害化して受け入れる」行為です。典型的なユースケースは、リッチテキストエディタから投稿される HTML フラグメントの処理です。
ユーザーが投稿した HTML をそのまま保存・表示すると、`` と書き込めてしまえば、その投稿を閲覧した全ユーザーの Cookie が外部サーバに送信されます。
XSS には大きく3種類があります。
| 種類 | 説明 |
|---|---|
| Stored XSS | データベースに保存された悪意あるスクリプトが表示時に実行される |
| Reflected XSS | リクエストパラメータに含まれたスクリプトがレスポンスにそのまま反映される |
| DOM-based XSS | サーバを介さずクライアント側の JavaScript が DOM を操作する過程で発生する |
Django のテンプレートエンジンは変数出力時に `<`, `>`, `&`, `"`, `'` を自動エスケープします。`{{ comment }}` と書くだけで `" でも
# 出力は "<script>alert('xss')</script>" になる
```
FastAPI は JSON API として使う場合、レスポンス自体は `application/json` であるためブラウザが HTML として解釈することはありません。しかし Jinja2 テンプレートで HTML を返す場合や、フロントエンドが受け取った JSON を `innerHTML` でそのまま描画してしまう場合には XSS が成立します。FastAPI 側で Jinja2 を使うときはデフォルトの自動エスケープが有効ですが、フロントエンドとの責任分界を明確にしておくことが重要です。
```{tip}
API が返す JSON データはすべて「信頼できないデータ」としてフロントエンド側でもエスケープする、という二重防御が理想です。
```
リッチテキストを扱う場合は前節で紹介した nh3 によるサニタイズを保存時に適用し、許可リストに含まれないタグを除去してからデータベースに格納します。
### CSRF
CSRF(Cross-Site Request Forgery)は、ログイン済みユーザーのブラウザを利用して、そのユーザーの意図しないリクエストを送信させる攻撃です。攻撃者は自分のサイトに以下のような隠しフォームを仕込みます。
```html