Upgrade to Pro — share decks privately, control downloads, hide ads and more …

raiseをやめて得たもの、失ったもの

Avatar for Masaya Masaya
August 22, 2026
38

 raiseをやめて得たもの、失ったもの

Avatar for Masaya

Masaya

August 22, 2026

Transcript

  1. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  2. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  3. raise された例外は、どこで catch すべき? ? ? ? バッチ API ?

    Webhook ? 関数 ? 関数 関数 ? 関数 ? 関数 ? 関数 ? 関数 raise ! 伝播しうる経路は、呼び出し経路の数だけある(この図で 10 通り) 正しい受け止め場所はコードのどこにも書いていない 全経路を人間が覚えているしかない
  4. エラー通知! 全経路を守り続けることでしか防げない バッチ API except ✓ 関数 except ✓ 関数

    関数 Webhook except ✓ 関数 関数 except なし 関数 関数 raise ! except を書いた経路は止まる。でも、 10 経路のうち 1 つ漏れただけで素通り 呼び出し元が増えるたび、覚えることが増える防御は、いつか破れる
  5. "重複エラー "は、成功である 確認メールを送る 注文を作成 IntegrityError: duplicate key 冪等なリトライなら “既に完了 ”

    = 成功 ✓ except IntegrityError: pass # 黙殺? 正常? 区別がつかない 何もせず ③ へ進んで いい“エラー” 例外の型名は「成功かもしれない」を運べない。 通知に混ざれば、ただのエラーに見える 処理の途中には、 “エラーの顔をした成功 ” がある それを分類できるのは文脈だけで、型名ではない
  6. 例外だけど業務的に例外として扱いたくないもの 事象 正体 してほしいこと 実は成功 何もせず続行(さっきの話) 起きるのが普通の業務 ユーザーに伝えるだけ。通知は不要 429 Rate

    Limit・タイムアウト・ LLM 混雑 一時的。待てば直る リトライする(毎回通知しないでほしい) LLM のトークン上限超過・入力が大きすぎる 何度やっても直らない リトライしない。入力・設計を直す 本物のバグ すぐ通知してほしい。すぐ直す 重複エラー(冪等リトライの 2 回目) 在庫切れ・クーポン期限切れ NoneType・KeyError・契約違反 扱いは 5 通りあるのに、例外はぜんぶ同じ “赤い通知 ”の顔で飛んでくる 分類は、受け取った人間に丸投げされる
  7. 抜け漏れを自動で見つける術がない Python の typing には「raise を表す記法」が無い 何を投げるかはシグネチャの外。 どこに except が要るかを、

    mypy も linter も判定できない 型に現れない以上、静的検査は抜け漏れを見つけられない 人間の注意力とレビューだけ 通知が来て初めて気づく 型に無い失敗は、 CI で守れない raise をやめたくなったきっかけ
  8. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか? 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  9. そもそもPython の失敗の扱いは? ✓ 失敗の道具は例外だけ。しかも⾮検査 C の errno 的な戻り値⽂化も、Java の検査例外も無い。何を raise

    するかは型検査の外 ✓ EAFP。Pythonの設計思想によるもの EAFP、「認可をとるより許しを請う⽅が容易」。公式ドキュメントの⽤語集に載っている設計思想
  10. 他の⾔語は失敗をどう表してきたか C Java Go Rust 戻り値と errno 検査例外 (T, error)

    の多値 Result<T, E> 失敗はただの整数。無視 できるし、 型からは何も分からない 投げる例外をシグネチャ に書かせた。 例外は無い。失敗は値で 返す。 ただし、両⽅⼊りうる 例外は無い。失敗は値で 返す。 ⽚⽅だけ⼊る。match で 網羅する 新しい⾔語ほど、失敗は値で返す⽅に寄っている。Go と Rust の違いは、その値の形
  11. 直積。Go の (T, error) は、両⽅⼊りうる 直積: (T, error) 値とエラーの全部の組み合わせが、型の上では存在する。複数値返すと⾔う点でPython似 order,

    err := CreateOrder(cart) // (Order, nil) OK // (nil, err) 失敗 // (Order, err) ← 型の上では作れてしまう 両⽅ある、両⽅ない、というおかしな状態を型が⽌めてくれない
  12. 直和。Rust の Result<T, E> は、⽚⽅だけ 直和: Result<T, E> Ok(T) か

    Err(E) の、どちらか⼀⽅だけ match create_order(cart) { Ok(order) => ..., Err(OutOfStock) => ..., Err(PaymentDeclined) => ..., } // 分岐が漏れるとコンパイルエラー おかしな状態が型の定義から消える。分岐の漏れもコンパイラが⾒つけてくれる これPythonでも書けないの??Python でこれを書ける道具が揃ったのが、3.10 以降
  13. Python が直和を書ける⾔語になった 3.10 X | Y と match ⽂ 3.11

    typing.assert_never 3.12 PEP 695 の型パラメータ構⽂ エラーの型を union で並べて、match で分岐できるようになった match の漏れを mypy や pyright が⾒つけてくれる class Result[T, E]: と書ける。ジェネリクスがだいぶ読みやすくなった ずっと例外の⽂化 (EAFP) の⾔語だったけれど、いまは直和を型で書いて、漏れを CI で⾒つ けられる
  14. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  15. Result 型とは Result<T, E> のイメージ Ok(値) 成功時の結果を包む 成功か失敗か、 どちらか⼀⽅だけが⼊る箱。 どちらか⼀⽅のみ

    成功のときは成功の値のみを、 Err(理由) 失敗のときは失敗の値のみを取得できる 失敗時のエラー内容を包む
  16. うちの Result 型はこんな形(簡易版) @dataclass(frozen=True) class AnyError: # 全エラーの基底。 error_message: str

    class Result[T, E: AnyError]: is_ok: bool is_err: bool ok: T # 成功時のみ触れる err: E # 失敗時のみ触れる def ok(value: T) -> Result[T, E]: ... # 成功を作成する def err(error: E) -> Result[T, E]: … # エラーを作成する def expected(self, msg: str) -> T: ... # Ok と⾔い切る。Err なら AssertionError で⽌める # デコレーター 拾い忘れた例外をエラーに変換する def shield_exception(): ...
  17. うちの Result 型はこんな形(簡易版) @dataclass(frozen=True) class AnyError: # 全エラーの基底。 error_message: str

    class Result[T, E: AnyError]: is_ok: bool # okがどうかを返却する is_err: bool # errかどうかを返却する ok: T # 成功時のみ触れる err: E # 失敗時のみ触れる def ok(value: T) -> Result[T, E]: ... # 成功を作成する def err(error: E) -> Result[T, E]: … # エラーを作成する def expected(self, msg: str) -> T: ... # Ok と⾔い切る。Err なら AssertionError で⽌める # デコレーター 拾い忘れた例外をエラーに変換する def shield_exception(): ...
  18. うちの Result 型はこんな形(簡易版) @dataclass(frozen=True) class AnyError: # 全エラーの基底。 error_message: str

    class Result[T, E: AnyError]: is_ok: bool # okがどうかを返却する is_err: bool # errかどうかを返却する ok: T # 成功時のみ触れる err: E # 失敗時のみ触れる def ok(value: T) -> Result[T, E]: ... # 成功を作成する def err(error: E) -> Result[T, E]: … # エラーを作成する def expected(self, msg: str) -> T: ... # Ok と⾔い切る。Err なら AssertionError で⽌める # デコレーター 拾い忘れた例外をエラーに変換する def shield_exception(): ...
  19. うちの Result 型はこんな形(簡易版) @dataclass(frozen=True) class AnyError: # 全エラーの基底。 error_message: str

    class Result[T, E: AnyError]: is_ok: bool # okがどうかを返却する is_err: bool # errかどうかを返却する ok: T # 成功時のみ触れる err: E # 失敗時のみ触れる def ok(value: T) -> Result[T, E]: ... # 成功を作成する def err(error: E) -> Result[T, E]: … # エラーを作成する def expected(self, msg: str) -> T: ... # Ok と⾔い切る。Err なら AssertionError で⽌める # デコレーター 拾い忘れた例外をエラーに変換する def shield_exception(): ...
  20. 実際どんな⾵に書いているのか # ① 作る def create_order(cart) -> Result[Order, OutOfStock |

    PaymentDeclined]: if not stock.has(cart.items): return Result.err(OutOfStock(...)) # 失敗は Result.err ... return Result.ok(order) # 成功は Result.ok
  21. 実際どんな⾵に書いているのか # ② 受ける result = create_order(cart) if result.is_err: match

    result.err: case OutOfStock(): ... case PaymentDeclined(): ... case _: assert_never(result.err) # 漏れは mypy が⾒つける order = result.ok
  22. 実際どんな⾵に書いているのか # ② 受ける result = create_order(cart) if result.is_err: match

    result.err: case OutOfStock(): ... case PaymentDeclined(): ... case _: assert_never(result.err) # 漏れは mypy が⾒つける order = result.ok
  23. いちばん効いたのは網羅性チェック def create_order(cart) -> Result[Order,OutOfStock | PaymentDeclined]: ... result =

    create_order(cart) if result.is_err: match result.err: case OutOfStock(): ... case PaymentDeclined(): ... case _: assert_never(result.err) 1 種類増やしたら、何が 起きる?
  24. いちばん効いたのは網羅性チェック def create_order(cart) -> Result[Order, OutOfStock | PaymentDeclined | CouponExpired]:

    ... エラーを 1 種類増やすと result = create_order(cart) if result.is_err: match result.err: err_value を match している場所すべてで CI が落ちる case OutOfStock(): ... case PaymentDeclined(): ... case _: assert_never(result.err) $ mypy . error: Argument 1 to "assert_never" has incompatible type "CouponExpired"; expected "Never" try/except ではこうならな い。増えた失敗を except は 気づかずに飲み込み続ける
  25. Result の世界の中は、これで書ける ✅ 何で失敗するかはシグネチャに並ぶ 関数のシグネチャから返却する型がわかる ✅ エラーの型を増やせば mypy が落ちる match

    している場所すべてで CI が落ちる。⼈が探して回らなくていい この2つの恩恵を享受できることが最⼤のメリット
  26. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  27. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  28. 現実問題、周りは例外だらけ 標準ライブラリ raise Django ORM raise あなたのアプリ 中は Result だけにしたい。

    でも呼ぶ相⼿は全部 raise してくる。 httpx / boto3 raise raise をResultに変換してあげる必要があ DBドライバ raise 外部SDK raise ります 境⽬
  29. 境界は、外部を呼ぶ関数の中 R es ul t ra is e アダプター ユースケース

    ドメイン アダプターは例外を受けたら 必ずResultに変換してから 中に渡す
  30. 外部を使う関数はこう書く @Result.shield_exception def fetch_product(product_id) -> Result[Product, ProductNotFound | FatalError]: try:

    row = ProductModel.objects.get(pk=product_id) # 外 (Django ORM) はここだけ except ProductModel.DoesNotExist: return Result.err(ProductNotFound(product_id)) return Result.ok(convert(row)) ✅ ✅ 外部を触る関数は例外を受けて、Resultを返す except はここで書き切って、Err に変える。ユースケースやドメインに try/except は書かない FatalError が並んでいるのは、想定外の例外の受け⽫ DoesNotExist 以外が⾶んできたらどうするか。
  31. shield_exception。変換し忘れた例外を拾う def shield_exception(func): def wrapper(*args, **kwargs) -> Result[R, F |

    FatalError]: try: return func(*args, **kwargs) except Exception as err: _logger.error(..., trace=stack_trace) return Result.err(FatalError.from_exception(err)) return wrapper 戻り値の型が F | FatalError に増える。呼び出し側は FatalError も match するしかない
  32. boto3 はこう包んでいる class S3Client: @Result.shield_exception() def download_text(self, bucket, key) ->

    Result[str, FatalError]: obj = self._s3.get_object(Bucket=bucket, Key=key) return Result.ok(obj["Body"].read().decode()) ✅ ✅ 外部 SDK は raise してくる前提。包む側はデコレータ 1 つ ClientError は業務エラーではなく想定外。except は書かず shield_exception に任せる (中⾝は次で) 使う側は Result しか⾒ない SQS も DynamoDB も ECS も同じ形
  33. リトライも、例外ではなく Err を⾒てやる fetch = Retrier( fetch_order, # Result を返す関数

    max_retries=3, retry_condition=lambda e: isinstance(e, Timeout), # どの Err なら再試⾏するか ) result = fetch(order_id) ✅ # is_err ならバックオフして再試⾏ 例外を待つ retry デコレータは、値で返る失敗を⾒ない Err は例外ではないので、tenacity のようなものは素通しする。なので Result を⾒てリトライする Retrier を⾃前で持っている ✅ どの Err なら再試⾏するかを、エラー型ごとに決められる
  34. atomic() はロールバックしない with transaction.atomic(): reserve_stock(order) result = charge_payment(order) if result.is_err:

    return result # ← 在庫はコミットされる! Err は例外ではない。 ただの return。 Django からは成功に⾒え るので そのままコミットする atomic() は __exit__ に例外が来たときだけロールバックする。 値で返る失敗は⾒えないし、ブロックの中から rollback を呼ぶ⼿段もない
  35. 対処。インフラの中では例外に合わせる やったこと。Err を専⽤の例外に包んで raise し、atomic の外で Err に戻す def transaction_with_result(cb):

    try: with transaction.atomic(): if (r := cb()).is_err: raise _CustomError(r.err) # rollback 発⽕ return r except _CustomError as e: return Result.new(e.inner) # 外で Err に戻す
  36. 境界のルール 外部を呼ぶ関数の中で except して Err に変える 境⽬はそこだけ。ユースケース、ドメイン の中に try/except を書いたら負け

    変換し忘れは shield_exception が FatalError にして型に乗せる 想定外の失敗が黙って消える経路が、そもそも無い
  37. コードは、確実に⻑くなる 例外なら 5 ⾏ Result だと 11 ⾏ def create_order(cart)

    -> Order: reservation = reserve_stock(cart.items) price = apply_coupon(cart.coupon) charge_payment(cart, price) return Order(...) def create_order(cart) -> Result[Order, ...]: r1 = reserve_stock(cart.items) if r1.is_err: return Result.err(r1.err) r2 = apply_coupon(cart.coupon) if r2.is_err: return Result.err(r2.err) r3 = charge_payment(cart, r2.ok) if r3.is_err: return Result.err(r3.err) return Result.ok(Order(...)) Rust の ? にあたる糖⾐が Python には無い。受けるたびに 2 ⾏ずつ増える LBYLの思想になるので、Pythonの設計思想から逸れる
  38. まだ払い続けているもの Result の触り⽅を間違えても、 mypyでは防げない 現在の設計だとErr のまま ok を触ることをmypyで → は防げていない。レビュー頼み(RustのResultだと

    ここまで制限できる) 学習コスト。Python らしくない書 規約と⼿本コード。AI は規約より既存コードを真似 → るので、最初は⼈が書く き⽅を、⼈にも AI にも教える それでも本番のアプリでは払う価値があった?
  39. ⽬次 1 なんで raise をやめたの? 2 じゃあどうするのか 3 Result って何、どう書く

    4 raise をやめて得たもの 5 raise をやめて失ったもの 6 実際 raise やめてよかったの?
  40. 通知の中⾝も変わった 導⼊前(例外) 導⼊後(Result) AttributeError FatalError(error_message='...') KeyError InfrastructuralError(...) ValueError / TypeError

    「メール送信に失敗しました」 InternalServerError 「外部サービス への決済失敗」 Exception (素の Exception) DhlApiError(...) ValidationError AssertionError (契約違反で⽌めた) 捕捉に失敗した例外ではなく、Resultのエラーが通知の中⼼になっている