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

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

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Masaya Masaya
August 22, 2026
200

 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のエラーが通知の中⼼になっている