Pythonで例外を正しく処理する方法

1. 概要

Pythonはeafp(Easier to Ask for Forgiveness than Permission)のスタイルを推奨しています。つまり、実行してから、エラーが発生したら処理するというアプローチです。そのため、例外がPythonコード中のいたるところに現れます。間違った方法で扱うと、例外が無視される、トレースバックが読みにくい、ログが重複するなどの問題が生じます。

この記事では、以下の中核的なテクニックについて説明します。try/except/else/finally、正確な例外型のキャッチ、カスタム例外システムの定義、raise ... fromによる例外の連鎖、ログ記録、ExceptionGroup、およびpytestでの例外テスト。この記事を読み終わったら、プロジェクトにすぐ適用できる明確なルールセットが手に入ります。例はPython 3.11以降を対象としています。

2. try/except/else/finallyの構造

完全な例外処理ブロックには4つの部分があります。

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"数量が無効です: {raw!r}")
        return 0
    else:
        print("パースに成功しました")
        return quantity
    finally:
        print("パースを終了します")


print(parse_quantity("3"))
print(parse_quantity("ba"))

各部分の役割:

  • try: 例外をスロー可能なコードのみを含みます。ここではint(raw)です。
  • except ValueError: int()が文字列をパースできない場合に実行されます。
  • else: tryが例外をスローしない場合に実行されます。「通常のパス」のコードはここに配置し、tryブロックに詰め込むべきではありません。
  • finally: returnまたは例外が発生した場合でも常に実行されます。この部分はリソースのクリーンアップに適しています。

結果:

パースに成功しました
パースを終了します
3
数量が無効です: 'ba'
パースを終了します
0

finallyが関数の戻り値が出力される前に実行されることに注意してください。Pythonは関数を離れる直前にfinallyを実行します。

2.1. なぜelseを使うべきか

次の2つの書き方を比較してください。

# 良くない: tryが広すぎる
try:
    quantity = int(raw)
    total = quantity * unit_price  # unit_priceが間違った型だったら?
except ValueError:
    total = 0

もしunit_priceが文字列だと、乗算はValueErrorをスローしません。しかし、他の多くの計算が予想外の場所からValueErrorをスローする可能性があり、それが「数量が無効」と誤認識されてしまいます。tryブロックはできるだけ狭く保ちましょう。残りの部分はelseに移動します。

3. 特定の例外をキャッチし、すべてをキャッチしない

最も一般的なエラーは、except:単独またはexcept Exception:を使用してから無視することです。

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # APIの遅い呼び出しをシミュレート
        return "PAID"
    except:  # 非常に悪い
        return "UNKNOWN"

except:単独だと、KeyboardInterruptとSystemExitもキャッチしてしまいます。Ctrl+Cを押すと、プログラムは停止せず"UNKNOWN"を返します。Pythonの例外階層は次のようになっています。

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

ルール:

  • 最も狭い例外型をキャッチします。つまり、実際に処理できる例外です。例えば、KeyError、TimeoutError。
  • except Exceptionはアプリケーションの「境界」でのみ使用します。ワーカーループ、最上位のHTTPハンドラなど。使用する場合は、常にログを記録します。
  • except BaseExceptionまたはexcept:は決して使用しないでください。ただし、すぐに再スロー(raise)する場合を除きます。

複数の型をキャッチするにはタプルを使用します。

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"{sku}の価格を読み込めません: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
B2の価格を読み込めません: ValueError
0.0
Z9の価格を読み込めません: KeyError
0.0

4. ドメインのためのカスタム例外を定義する

ビジネスロジック固有の場合、モジュールのルート例外を作成し、それから継承します。

class PaymentError(Exception):
    """支払いモジュールのルート例外。"""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"オーダー{order_id}: 残高{balance}は{amount}の支払いに不足しています"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

使い方:

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("不足額:", exc.amount - exc.balance)
except PaymentError:
    print("その他の支払いエラー")
オーダーORD-42: 残高100は250の支払いに不足しています
不足額: 150

この組織方法には3つの利点があります。

  1. 呼び出し側のコードはPaymentErrorをキャッチしてすべての支払いエラーを処理することも、InsufficientFundsErrorを個別にキャッチすることもできます。
  2. 例外は構造化されたデータ(order_id、balance、amount)を保持します。単なる文字列ではなく、処理側はメッセージを解析する必要がありません。
  3. 独自のエラーはシステムエラー(ValueErrorなど)と分離されているため、誤ってキャッチされることがありません。

注記: BaseExceptionではなくExceptionから継承します。PEP 8の慣例に従い、クラス名はErrorで終わる必要があります。

5. raise ... fromで例外を連鎖させる

低レベルのエラーをドメイン例外に変換する際、元の原因を保持する必要があります。

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("設定ファイルが無効です") from exc


load_config("{port: 8080}")

トレースバックは両方のエラーを表示します。

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: 設定ファイルが無効です

from excは元のエラーを__cause__属性に割り当てます。デバッガはJSONエラーがどの列にあるかをすぐに確認できます。

from excを省略した場合、Pythonは__context__を通じてエラーをリンクしますが、メッセージは*“During handling of the above exception, another exception occurred”*になります。これは、ハンドラが失敗したことを暗示していますが、実際にはそうではありません。常に明示的にfromを記述します。

元のエラーが内部詳細であり有用でない場合は、from Noneを使用してそれを隠します。

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"顧客{customer_id}が見つかりません") from None

6. コンテキストマネージャでリソースをクリーンアップ

ファイル、接続、ロックの場合、手書きのtry/finallyよりもwithの方が安全です。

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

ファイルは常にクローズされます。ループが例外をスローした場合でもそうです。

特定の種類のエラーを本当に無視したい場合、contextlib.suppressはtry/except: passよりも意図が明確です。

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

この行を読むと、誰もが「ファイルが存在する場合は削除し、存在しない場合はスキップする」ことをすぐに理解します。

7. ログ記録と例外の再スロー

exceptブロック内では、logger.exception()を使用して完全なトレースバックをログに記録します。

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("オーダー%s は資金不足で却下されました", order_id)
        return False
    except PaymentError:
        logger.exception("オーダー%s の不明な支払いエラー", order_id)
        raise
    return True

注意すべき点:

  • logger.exception(...)はlogger.error(..., exc_info=True)と同等です。この関数はexcept内でのみ呼び出す必要があります。
  • 単独のraiseは正確な例外をスロー戻し、トレースバックを保持します。raise excと書かないでください。トレースバックにこの行を指す余分な行が追加されます。
  • 予期されたエラー(InsufficientFundsError)にはwarningだけで十分で、トレースバックは不要です。

注記: 「ログして再スロー」のパターンをすべてのレイヤーで避けます。1つのエラーはログに5回現れ、ノイズが生じます。エラーを実際に処理する場所、またはアプリケーションの最外層でのみログを記録します。

8. ExceptionGroupで複数の例外を同時に処理する

Python 3.11から、複数の例外を同時にスローできます。このアプローチは、データを検証または複数のタスクを並行実行する場合に便利です。

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantityは0より大きい必要があります"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("オーダーが無効です", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("無効な値:", e)
except* KeyError as group:
    for e in group.exceptions:
        print("フィールドが足りません:", e)
無効な値: quantityは0より大きい必要があります
フィールドが足りません: 'customer'

通常のexceptとは異なり、複数の except*ブランチを同時に実行できます。各ブランチは、その型と一致するエラーのみを含むExceptionGroupサブセットを受け取ります。まだ処理されていないエラーがある場合、それらは自動的に再スローされます。

asyncio.TaskGroupもこのメカニズムを使用しています。複数の子タスクが失敗すると、ExceptionGroupが得られます。

9. pytestで例外の動作をテストする

pytestをインストール:

pip install pytest

PaymentServiceとload_configのテストを書く:

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # 残高は差し引かれていない


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

説明:

  • pytest.raises(...)は、例外がない場合、または例外の型が間違っている場合にテストを失敗させます。
  • match=は正規表現を受け取り、str(exception)と照合します。
  • exc_info.valueは実際の例外オブジェクトです。これにより、amountや__cause__などの属性をチェックできます。
  • 最初のテストは、エラー後の状態もチェックします。残高は変わらないままである必要があります。これは見落とされることが多い部分です。

テストを実行します:

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. よくある間違い

例外を無言で無視する。

try:
    service.charge(order_id, amount)
except Exception:
    pass  # オーダーは「成功」だが、お金は差し引かれていない

このタイプのエラーはほぼ不可能にデバッグできます。本当に無視する必要がある場合、suppressを特定の例外型と共に使用し、理由をコメントします。

finally内のreturn。

def risky() -> str:
    try:
        raise RuntimeError("データが失われます")
    finally:
        return "ok"  # 例外が消えます!

finally内のreturn、break、またはcontinueは、流布中の例外を無効にします。Python 3.14からは、インタプリタはこのケースに対してSyntaxWarningを出します。このように書かないでください。

一般的なExceptionをスロー。 raise Exception("エラー")は呼び出し側がExceptionをキャッチすることを強制し、それは他のすべてをキャッチすることを意味します。適切な組み込み例外(ValueError、TypeError)またはドメイン例外を使用します。

ホットループで通常のフローに対して例外を使用する。 eafpはPythonicなスタイルです。しかし、「エラー」ケースが大部分の時間発生する場合、dict.get()またはif checkの方が、より速く、より明確です。

# keyがしばしば存在しない場合はより良い
discount = discounts.get(customer_id, 0)

入力を検証するためにassertを使用する。 assertはpython -Oで実行すると無効になります。外部からのデータにはraise ValueError(...)を使用します。

11. 結論

Pythonで例外を正しく処理することは、いくつかの簡単な原則を中心に回ります。

  • tryを狭く保ち、後続のコードをelseに移し、finallyまたはwithでクリーンアップします。
  • 最も特定の例外型をキャッチします。Exceptionはアプリケーションの境界でのみキャッチし、常にログを記録します。
  • ドメイン用のカスタム例外システムを作成し、構造化されたデータを保持します。
  • raise ... from excを使用して元の原因を保持し、単独のraiseで再スローします。
  • 複数のエラーが同時に発生する場合、ExceptionGroupとexcept*を使用します。
  • pytest.raisesでテストを書き、エラー型とエラー後の状態の両方をチェックします。

これらのルールを適用すると、トレースバックがより読みやすくなり、ログがクリーンになり、エラーが無言で無視されることはなくなります。

pythonexceptionerror-handlingpytestbest-practices