Python | Web フレームワーク:エラー処理

Python
スポンサーリンク

概要(Web のエラー処理は「こけ方」をデザインすること)

Web フレームワークのエラー処理は、

「何かがおかしくなったときに、
アプリがどう“こけるか”をきちんと決めておくこと」

です。

エラーは必ず起きます。
外部APIが落ちる、DBがつながらない、想定外の入力が来る。
問題は「エラーをゼロにすること」ではなく、

内部的にはちゃんと例外として検知する
外から見たときには分かりやすいエラーレスポンスを返す
ログとして残して、あとから原因を追える

この3つを丁寧に整えておくことです。

ここでは FastAPI を例にしながら、初心者向けに

HTTPException の基本
バリデーションエラーと自動エラーレスポンス
自分で例外を定義して、共通のエラーフォーマットで返す
予期しないエラーを一括で受ける「最後の砦」

といったポイントを、具体例でかみ砕いて説明していきます。


FastAPI の基本的なエラーの流れを理解する

何も意識しないときの「素の落ち方」

まず、FastAPI で何の工夫もなく例外を投げるとどうなるかを見てみます。

from fastapi import FastAPI

app = FastAPI()

@app.get("/zero")
def divide_by_zero():
    return 1 / 0
Python

この状態で /zero にアクセスすると、
Python の ZeroDivisionError が発生します。

FastAPI は、内部でこの例外をキャッチして、
HTTPステータス 500(Internal Server Error)と、
エラーメッセージを含んだ JSON を返します。

ブラウザやクライアントから見ると、
「何かサーバー側で予期しないエラーが起きた」
という扱いです。

これは「最後の手段」としては悪くありませんが、
実務では次のような問題があります。

ユーザーにとって、理由が分からない(何がダメなのか説明がない)
APIの利用者にとって、機械的に扱いづらい(どの種別のエラーか判別しづらい)
ログとしては残るが、エラーレスポンスの形式がバラバラになる

そこで、「意図的に投げるエラー」と「本当に想定外のバグ」を分けて扱う必要が出てきます。


HTTPException を使った「意図的なエラー返却」

404 や 400 をきちんと返す書き方

FastAPI では、「こういう条件ならエラーとして扱いたい」というときに
HTTPException を使います。

たとえば、ユーザーが見つからなかったときに 404 を返したいケース。

from fastapi import FastAPI, HTTPException

app = FastAPI()

fake_users = {
    1: {"id": 1, "name": "Taro"},
    2: {"id": 2, "name": "Hanako"},
}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = fake_users.get(user_id)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user
Python

ここで重要なポイントは三つあります。

一つ目は、HTTPException を「自分の意思で」投げていることです。
これは「アプリ側が、この状況は 404 として扱うべきだと判断した」ことを意味します。

二つ目は、ステータスコードを明示していることです。
404 は「Not Found」、400 は「Bad Request」、403 は「Forbidden」など、
意味に応じたステータスを選びます。

三つ目は、detail に「人間にも機械にも意味が分かるメッセージ」を入れている点です。
実際のエラーレスポンスは次のような JSON になります。

{
  "detail": "User not found"
}

これならフロントエンドも、

ステータスコード → 404
detail → “User not found”

を見て「ユーザーがいなかった」と判断できます。

400(入力がおかしい)も同じように扱う

例えば、クエリパラメータの組み合わせが不正な場合などは、400 を返します。

from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/divide")
def divide(a: int, b: int):
    if b == 0:
        raise HTTPException(status_code=400, detail="b must not be 0")
    return {"result": a / b}
Python

ここでは、
ゼロ割りを「サーバー内部のバグ」ではなく、
「クライアントの入力ミス」として扱っているわけです。

HTTPException は、「業務的に想定しているエラー」を
HTTP レスポンスとしてきちんと表現するための道具です。


バリデーションエラーは自動で 422 が返る(ここはフレームワークの仕事)

型や必須項目のミスは Pydantic が見つけてくれる

FastAPI(+Pydantic)では、
リクエストボディの形や型がおかしいとき、自動で 422 が返ります。

例えば、次のようなリクエストモデルを考えます。

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float
    in_stock: bool = True

@app.post("/items")
def create_item(item: Item):
    return item
Python

クライアントが次の JSON を送ります。

{
  "name": "Apple",
  "price": "abc"
}

price が数値ではないので、Pydantic が ValidationError を投げます。
FastAPI はそれを受け取って、
ステータス 422 とエラー内容の JSON を返します。

つまり、

「入力の形や型がおかしい」
→ 自動的に 422 が返る(HTTPException を自分で投げる必要はない)

という流れになっています。

このおかげで、

型レベルのバリデーション
必須項目のチェック

はほぼ「何も書かなくても」エラー処理がされる状態になります。

重要なのは、

「どこまでを Pydantic のバリデーションに任せて、
どこからを HTTPException で表現するか」

を切り分ける意識です。

型や必須項目 → モデルに任せる
ビジネスルール(数値の範囲、論理的な矛盾など) → 自分でチェックして HTTPException

という分担にすると、エラー処理の構造がきれいになります。


自分で例外クラスを定義して、共通のエラーレスポンスを返す

いつも同じ形でエラーを返したくなる理由

実務で API を作るとき、エラーのレスポンス形式を揃えたくなります。

例えば、

{
  "error_code": "USER_NOT_FOUND",
  "message": "ユーザーが見つかりませんでした",
  "detail": {}
}

のように、「エラーコード」を必ず含めたい、といった要件が出ます。

この場合、HTTPException だけでは少し物足りなくなってきます。

そこで、自分で「ドメイン例外」を定義して、
それをまとめてハンドリングするパターンを使います。

独自例外+exception_handler の例

まず、独自例外クラスを定義します。

class UserNotFoundError(Exception):
    def __init__(self, user_id: int):
        self.user_id = user_id
Python

次に、この例外が発生したときの処理を FastAPI に登録します。

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(UserNotFoundError)
async def user_not_found_handler(request: Request, exc: UserNotFoundError):
    return JSONResponse(
        status_code=404,
        content={
            "error_code": "USER_NOT_FOUND",
            "message": f"User {exc.user_id} not found",
        },
    )
Python

最後に、エンドポイントでこの例外を「普通の Python の例外」として投げます。

fake_users = {1: {"id": 1, "name": "Taro"}}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = fake_users.get(user_id)
    if user is None:
        raise UserNotFoundError(user_id)
    return user
Python

これで、

UserNotFoundError がどこで投げられても、
user_not_found_handler がそれを受け取り、
統一された 404 の JSON を返す

というパターンになります。

ポイントは、

エンドポイント側は「ビジネス用の例外」を投げるだけ
HTTP のステータスコードや JSON の形は、exception_handler 側で一元管理

にしていることです。

エラー形式を揃えたいとき、
この「独自例外+ハンドラ」はかなり強力です。


予期しないエラーを一括で拾う「最後の砦」

Exception 全体に対するハンドラを用意する

本番運用では、
どれだけ頑張っても「想定外のエラー」は出ます。

そのときに、「500 を返して終わり」ではなく、

統一された形のエラー JSON を返す
ログに詳細を残す

ことが重要になります。

FastAPI では、Exception のハンドラを定義できます。

import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()
logger = logging.getLogger(__name__)

@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
    logger.exception(f"Unhandled error: {exc}")
    return JSONResponse(
        status_code=500,
        content={
            "error_code": "INTERNAL_SERVER_ERROR",
            "message": "予期しないエラーが発生しました。",
        },
    )
Python

このハンドラは、

HTTPException や Pydantic の ValidationError など、
個別にハンドルしていない例外

が最後に流れ着く場所です。

ここでやるのは二つだけです。

ログに stack trace を残す
クライアントには、あまり内部情報を漏らさないメッセージで 500 を返す

これを入れておくと、「どこかで何かが壊れた」場合でも、

利用者には「サーバー側の問題が起きた」とだけ伝える
開発者はログを見て詳細を追いかけられる

という状態を保てます。


エラー処理の設計で本当に大事なところを整理する

どのエラーに、どのステータスコードを割り当てるか

エラー処理は、HTTPステータスコードの選び方がとても重要です。

ざっくりした指針としては、次のような考え方を持っておくと良いです。(厳密なルールというより「感覚」として)

400系(クライアントエラー)
クライアントの入力や操作が原因のエラー。
例:
必須パラメータがない(400)
権限がない(403)
存在しないリソースへのアクセス(404)
バリデーションエラー(422)

500系(サーバーエラー)
サーバー側のバグや外部依存の失敗など、
クライアントにはどうしようもないエラー。
例:
コード上の例外
DB障害
外部API障害

HTTPException では、ここを意識して status_code を選びます。
特に、「本当にクライアントが悪いのか? サーバーの都合なのか?」を自分に問いかけてから決めると、APIの設計がブレにくくなります。

エラーメッセージとエラーコードのバランス

クライアントに返す情報としては、

人間向けの message
機械向けの error_code

の両方を持たせることが多いです。

message は人間が画面で見る用なので、
「ユーザーが見たときに意味が分かる日本語」などで書きます。

error_code はログやプログラムから使うためのものなので、

USER_NOT_FOUND
INVALID_PARAMETER
EXTERNAL_SERVICE_ERROR

のような英語の定数にするのが一般的です。

レスポンスモデルとして、

from pydantic import BaseModel

class ErrorResponse(BaseModel):
    error_code: str
    message: str
Python

のようなものを作り、
エラー系のハンドラはすべてこの形で返す、という設計もよく使われます。


まとめ(エラー処理は「落ち方の設計」)

Python の Web フレームワーク(FastAPI)におけるエラー処理を整理すると、こうなります。

  • 何もしないと例外は 500 として返るが、業務的に想定しているエラーは HTTPException や独自例外を使って、意味のあるステータスコードとメッセージで返す。
  • 型や必須項目のような入力の基本的なチェックは、Pydantic モデルに任せれば 422 として自動的にエラーレスポンスが返る。
  • 独自例外+exception_handler を使うと、「同じ種類のエラーは同じ JSON 形式で返す」という統一感を持たせられる。
  • Exception 全体に対するハンドラを用意しておくことで、予期しないエラーも 500 として一括処理しつつ、ログには詳細を残せる「最後の砦」を作れる。
  • HTTPステータスコード、エラーメッセージ、error_code、ログの残し方をセットで設計することが、「壊れたときに強い Web API」を作るカギになる。

タイトルとURLをコピーしました