0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【2026年版】try-exceptで握り潰す10パターンまとめ

0
Last updated at Posted at 2026-09-02

例外が try/except をすり抜ける3つの出口

except Exception を書いておけば全部拾える、と自分は思っていた。実際に10パターン試したら、4つは終了コード0のまま通過した。10個中4個、つまり40%。そのうち3つは警告すら出ない。CIは緑のまま、例外だけが消える。

この記事で持って帰れるのは3つ。例外がすり抜ける10個の型と、その一つひとつの実行ログ。すり抜けが起きる出口は3方向しかないという整理。そして自分のリポジトリで機械的に探すコマンド。

全部 macOS 15.7.4 / Python 3.14.5 / Node.js 26.0.0 で動かして、出力をそのまま貼っている。

書いたコード 起きること 直し方
except Exceptionsys.exit() を囲む 止まらずプロセスが終わる SystemExit を明示して再送出
except: (bare) Ctrl-C まで飲む except Exception に変える
節の外で e を使う NameError 別の変数に代入しておく
except A or B B が拾われない except (A, B)
finally の中の return 例外が消える 3.14の SyntaxWarning を潰す
except Exception を先に書く 下の節が実行されない 狭いクラスから並べる
__exit__True を返す with の例外が消える None を返す
await を書き忘れる catch を素通り await.catch()
setTimeout の中で throw 外の catch に入らない コールバックの中で囲む
Promise.all 2件目以降の失敗を捨てる Promise.allSettled

想定読者は、try-except / try-catch を日常的に書くけれど、どこまでが網の内側かを詰めて考えたことがない人。ログに出ないエラーを追った経験があると、たぶん7章目と9章目が刺さる。逆に BaseExceptionException の使い分けを説明できる人には、後半のJavaScript側しか新しい話がない。

sys.exit(1) が try で止まらなかった

土台になる実験から。「異常系は全部ここで拾う」つもりの except Exception に、sys.exit(1) を通す。

p1_sysexit.py
# 「全部拾うつもり」の except Exception
import sys

def cleanup():
    print("cleanup: 一時ファイルを消した")

try:
    print("処理開始")
    sys.exit(1)          # ここで終わるつもりで書いた
    print("ここは通らない")
except Exception as e:
    print(f"except Exception が拾った: {type(e).__name__}")
finally:
    cleanup()
print("関数の外まで来た")

出力がこれ。

処理開始
cleanup: 一時ファイルを消した
[exit=1]

except Exception が拾った が出ていない。関数の外まで来た も出ていない。finally だけが走って、プロセスは終了コード1で終わった。つまり sys.exit() は例外を投げているのに、except Exception の網には入らなかった。

理由は継承関係にある。実際に確かめる。

p1b_baseexception.py
# 継承関係を print で確かめる
for cls in (SystemExit, KeyboardInterrupt, GeneratorExit, ValueError):
    print(f"{cls.__name__:<18} Exception のサブクラス? {issubclass(cls, Exception)}")
print("MRO:", [c.__name__ for c in SystemExit.__mro__])
SystemExit         Exception のサブクラス? False
KeyboardInterrupt  Exception のサブクラス? False
GeneratorExit      Exception のサブクラス? False
ValueError         Exception のサブクラス? True
MRO: ['SystemExit', 'BaseException', 'object']

SystemExit の継承をたどると BaseException に直結していて、Exception を経由しない。公式の例外階層でも、プログラムを終わらせる系の3つだけが Exception の外に置かれている。「業務エラーを拾う網」と「終了要求」を混ぜないための設計で、これは意図的な線引き。

だから終了系を素通しするなら、先に名指しして投げ直す。

fix_p1.py
# 終了系だけ先に通す
import sys
try:
    sys.exit(1)
except (SystemExit, KeyboardInterrupt):
    print("  終了系は素通しする")
    raise
except Exception as e:
    print("  業務エラーだけここで処理:", e)
finally:
    print("  finally: 後片付けは必ず走る")
  終了系は素通しする
  finally: 後片付けは必ず走る
[exit=1]

bare except は Ctrl-C まで飲む

except Exception が終了系を拾わないなら、コロンだけの except: はどうか。こちらは BaseException を拾う。Ctrl-C と同じ SIGINT を自分に送って比べた。

p2_bare.py
# bare except と except Exception に Ctrl-C 相当を投げ比べる
import os, signal

def send_ctrl_c():
    os.kill(os.getpid(), signal.SIGINT)   # Ctrl-C と同じシグナル

print("[A] bare except")
try:
    send_ctrl_c()
except:                                   # 「念のため全部拾う」つもりの1行
    print("  飲んだ: Ctrl-C がここで止まった")

print("[B] except Exception")
try:
    send_ctrl_c()
except Exception:
    print("  飲んだ")
print("  ここは表示されない")
[A] bare except
  飲んだ: Ctrl-C がここで止まった
[B] except Exception
Traceback (most recent call last):
  File "p2_bare.py", line 15, in <module>
    send_ctrl_c()
    ~~~~~~~~~~~^^
  File "p2_bare.py", line 5, in send_ctrl_c
    os.kill(os.getpid(), signal.SIGINT)   # Ctrl-C と同じシグナル
    ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt
[exit=130]

[A] は Ctrl-C を飲み込んで、何事もなかったように次へ進んだ。[B] は素通りして終了コード130で落ちた。この130が正しい姿で、シェルは「SIGINTで終わった」と解釈できる。

これがループの中にあると、Ctrl-C を連打しても止まらないスクリプトができあがる。自分が過去に一番長く時間を溶かしたのも、この形だった。リンター側でも ruff の E722 が bare except を単独ルールとして持っている。

except の as e は、節を抜けると消える

例外オブジェクトを後で使いたくて、節の外で参照する書き方。

p3_scope.py
# except の as e を節の外で使う
try:
    int("abc")
except ValueError as e:
    print("節の中:", e)

print("節の外:", e)   # ここが NameError
節の中: invalid literal for int() with base 10: 'abc'
Traceback (most recent call last):
  File "p3_scope.py", line 7, in <module>
    print("節の外:", e)   # ここが NameError
                     ^
NameError: name 'e' is not defined

変数が未定義になっている。Python 3 は except 節を抜けるときに、as で束縛した名前を明示的に削除する。言語リファレンスの try 文に、節の終わりで del e 相当が走ると書いてある。例外オブジェクトがトレースバック経由でフレームを掴んだまま残るのを避けるため。

後で使うなら、別の名前に移しておく。

fix_p3.py
# 節の中で別の変数に退避する
err = None
try:
    int("abc")
except ValueError as e:
    err = e            # 名前を移し替える

print("節の外:", err)
節の外: invalid literal for int() with base 10: 'abc'

厄介なのは、e という名前が外側に既にある場合。削除は無条件に走るので、元の値まで巻き込まれる。

p3_shadow.py
# 外側に既にある e が消えるか
e = "既存の値"
print("try の前:", e)
try:
    int("abc")
except ValueError as e:
    pass
try:
    print("try の後:", e)
except NameError as err:
    print("try の後: NameError ->", err)
try の前: 既存の値
try の後: NameError -> name 'e' is not defined

except を通っただけで、無関係だったはずの e が消えた。ループの中に置くと、2周目から NameError になる。

except A or B は、B を拾わない

複数の例外を1つの節で受けたい時、タプルではなく or でつないでしまう形。文法エラーにならないので通ってしまう。

p4_or.py
# except A or B と書いてしまった場合
print("式の評価結果:", (ValueError or TypeError))

try:
    None + 1                       # TypeError が出る
except ValueError or TypeError as e:
    print("拾えた:", type(e).__name__)
式の評価結果: <class 'ValueError'>
Traceback (most recent call last):
  File "p4_or.py", line 5, in <module>
    None + 1                       # TypeError が出る
    ~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

1行目の出力が答えを言っている。ValueError or TypeError は例外の集合ではなく、ただの or 式。クラスオブジェクトは真なので左辺がそのまま返り、except ValueError と書いたのと同じになる。TypeError は最初から候補に入っていない。

正しくはタプルで囲む。except (ValueError, TypeError) as e と書けば両方拾う。ぶっちゃけ見た目が似ているので、レビューでも見落としやすい。

finally に return を書くと、例外が消える

後片付けのつもりで finallyreturn を1行足すと、投げたはずの例外が外へ出なくなる。

p5_finally.py
# finally の return
def load(path):
    try:
        return open(path).read()
    except FileNotFoundError:
        raise RuntimeError(f"設定ファイルが無い: {path}")
    finally:
        return ""              # 後片付けのつもりで書いた1行

print("戻り値:", repr(load("/does/not/exist.conf")))
print("例外は飛んでこなかった")
p5_finally.py:8: SyntaxWarning: 'return' in a 'finally' block
  return ""              # 後片付けのつもりで書いた1行
戻り値: ''
例外は飛んでこなかった
[exit=0]

RuntimeError を送出しているのに、呼び出し側には空文字が返った。終了コードは0。設定ファイルが無いまま、アプリは正常に起動したことになる。

ここは2026年時点で状況が変わった箇所で、Python 3.14 から SyntaxWarning が出るようになった。PEP 765finally を抜ける return / break / continue を非推奨にして、3.14 の変更点に入っている。PEP のステータスは Final で、今は警告どまり。将来の禁止は明言されていないので、警告を放置しても動きは変わらない。

裏を返すと、3.13以前で書いたコードは無警告でこの挙動をしている。自分の手元では、警告をエラーに格上げすると即座に落とせた。

check_finally.sh
# SyntaxWarning をエラーに格上げして走らせる
python3 -W error::SyntaxWarning p5_finally.py
  File "p5_finally.py", line 8
    return ""              # 後片付けのつもりで書いた1行
    ^^^^^^^^^
SyntaxError: 'return' in a 'finally' block

並び順と __exit__ の戻り値、無言で捨てる2つ

ここから2つは、警告も終了コードも出ない型。まず except の並び順。

p7_order.py
# except の並び順
def parse(raw):
    try:
        return int(raw)
    except Exception:
        print("  -> except Exception に入った")
        return None
    except ValueError:
        print("  -> except ValueError に入った")
        return 0

print("parse('abc'):", parse("abc"))
  -> except Exception に入った
parse('abc'): None

ValueError の節は書いてあるのに、一度も実行されない。except は上から順に issubclass で判定するので、広いクラスを先に置くと後ろが全部死ぬ。Python は到達不能な except 節を警告しない。呼び出し側は 0 を期待して None を受け取り、その先で TypeError になって初めて気づく。狭いクラスから順に並べれば直る。

もう1つがコンテキストマネージャ。__exit__ の戻り値が例外の運命を決める。

p7_exit.py
# __exit__ の戻り値
class Timer:
    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc, tb):
        print(f"  __exit__ が受け取った例外: {exc_type.__name__ if exc_type else None}")
        return True            # 「後始末は成功した」つもりで書いた1行

with Timer():
    raise RuntimeError("DBに繋がらない")

print("with を抜けた。例外はどこへ行った?")
  __exit__ が受け取った例外: RuntimeError
with を抜けた。例外はどこへ行った?
[exit=0]

__exit__RuntimeError を確かに受け取っている。受け取った上で True を返したので、Python は「この例外は処理済み」と解釈して握り潰した。データモデルの仕様にそう書いてある。「後始末が成功した」という意味で True を返すと、意味が反転する。return を書かなければ None になり、例外はそのまま外へ出る。

fix_p7.py
# __exit__ は None を返す
class Timer:
    def __enter__(self): return self
    def __exit__(self, exc_type, exc, tb):
        print(f"  __exit__: {exc_type.__name__ if exc_type else None}")
        # return しない = None = 例外はそのまま外へ

with Timer():
    raise RuntimeError("DBに繋がらない")
  __exit__: RuntimeError
Traceback (most recent call last):
  File "fix_p7.py", line 9, in <module>
    raise RuntimeError("DBに繋がらない")
RuntimeError: DBに繋がらない

await を1つ忘れると、catch は素通りする

ここからJavaScript。try で囲んだ非同期関数の await を1つ書き忘れる。

p8_await.mjs
// await を1つ書き忘れた async 関数
async function saveUser(id) {
  throw new Error(`保存に失敗: ${id}`);
}

function handler() {
  try {
    saveUser(42);            // await を書き忘れた
    console.log("  保存できた(と思っている)");
  } catch (e) {
    console.log("  catch が拾った:", e.message);
  }
}

process.on("unhandledRejection", (e) => {
  console.log("  unhandledRejection まで飛んだ:", e.message);
});

handler();
setTimeout(() => console.log("  イベントループ1周後"), 0);
  保存できた(と思っている)
  unhandledRejection まで飛んだ: 保存に失敗: 42
  イベントループ1周後

catch は動かず、成功したことになっている。async 関数は呼んだ瞬間に Promise を返すだけで、中の throw は Promise の reject になる。try ブロックは同期的にそこを抜けきっていて、後から来た reject を受け取る口がない。

process.on("unhandledRejection") を外すと、Node は最後に落とす。

Error: 保存に失敗: 42
    at saveUser (file:///.../p8_nohandler.mjs:2:37)
Node.js v26.0.0
[実際のexit=1]

Node 15 以降は未処理の reject をプロセス終了として扱う。CLI の --unhandled-rejections の既定値が throw になっている。落ちるだけマシで、ブラウザや古い設定だとコンソールに出て終わる。

対策は await を書くか .catch() を繋ぐか。人力で見つけるのは無理なので、typescript-eslint の no-floating-promises に任せるのが早い。

setTimeout の中の throw と、Promise.all の2件目

残り2つも「時間軸のズレ」で説明がつく。setTimeout のコールバックで投げる。

p9_settimeout.mjs
// try の中で setTimeout を呼ぶ
process.on("uncaughtException", (e) => {
  console.log("  uncaughtException まで飛んだ:", e.message);
});

try {
  setTimeout(() => {
    throw new Error("1秒後に失敗した");
  }, 10);
  console.log("try ブロックは即座に抜ける");
} catch (e) {
  console.log("  catch が拾った:", e.message);
}
try ブロックは即座に抜ける
  uncaughtException まで飛んだ: 1秒後に失敗した

setTimeout の仕事はコールバックを登録することだけ。10ms 後の実行は別のタイミングで、その時にはもう try を抜けている。囲むなら、コールバックの中に try を持ち込む。

fix_p9.mjs
// コールバックの中で捕まえる
setTimeout(() => {
  try {
    throw new Error("1秒後に失敗した");
  } catch (e) {
    console.log("  コールバック内の catch が拾った:", e.message);
  }
}, 10);

最後が Promise.all。3つのうち2つを失敗させる。

p10_all.mjs
// Promise.all と Promise.allSettled
const job = (name, ms, ok) =>
  new Promise((res, rej) =>
    setTimeout(() => (ok ? res(name) : rej(new Error(`${name} が失敗`))), ms));

try {
  await Promise.all([job("A", 10, false), job("B", 30, false), job("C", 50, true)]);
} catch (e) {
  console.log("Promise.all の catch が受け取った数: 1 件");
  console.log("  内容:", e.message);
}

const r = await Promise.allSettled([job("A", 10, false), job("B", 30, false), job("C", 50, true)]);
console.log("allSettled が返した件数:", r.length);
for (const x of r) console.log("  ", x.status, x.reason ? x.reason.message : x.value);
Promise.all の catch が受け取った数: 1 件
  内容: A が失敗
allSettled が返した件数: 3
   rejected A が失敗
   rejected B が失敗
   fulfilled C

A は10ms後、B は30ms後に失敗させている。2件 → 1件。失敗は2件あるのに、catch に届いたのは先に転んだ A だけ。Promise.all は最初の reject で確定し、残りの結果は捨てられる。MDN の Promise.all の言う「短絡」がこれ。バッチ処理でこれをやると、失敗率の集計が実態より小さく出る。全部の結果が要るなら Promise.allSettled に替える。3件そのまま返ってきている。

「raise e はトレースバックを消す」は再現しなかった

よく聞く注意に「例外を投げ直す時に raise e と書くとスタックトレースが失われる」がある。自分も避けてきたので、実際に測った。

p6_raise.py
# raise e と bare raise の traceback を行数で比べる
import traceback

def level3():
    int("abc")

def level2():
    level3()

def run(style):
    try:
        level2()
    except ValueError as e:
        if style == "raise e":
            raise e            # 変数を投げ直す
        raise                  # bare raise

for style in ("raise e", "raise"):
    try:
        run(style)
    except ValueError:
        tb = traceback.format_exc()
        frames = tb.count("  File ")
        print(f"--- {style}: traceback {frames} フレーム ---")
--- raise e: traceback 5 フレーム ---
--- raise: traceback 4 フレーム ---

減るどころか1フレーム増えた。raise e の側は raise e と書いた行そのものがフレームとして追加され、level3 まで含む元のトレースは全部残っている。Python 3 の例外オブジェクトは __traceback__ を自分で持ち歩くので、変数経由でも情報が落ちない。

つまり Python 3 に限れば、この注意は当たらない。ただし raise e を選ぶ理由も無い。ノイズが1行増えるだけなので、投げ直しは bare raise でいい。原因の連鎖を明示的に切る raise ... from None は別の話で、こちらは本当に情報が消える。

10個のうち、CIが緑のまま通るのは4個

10パターンを、終了コードと標準エラー出力の有無で並べ直した。数字は実測。

# パターン 終了コード 警告・エラー出力
1 except Exceptionsys.exit() 1 無し
2 bare except で Ctrl-C 130 有り
3 節の外で e を参照 1 有り
4 except A or B 1 有り
5 finallyreturn 0 有り (3.14の新警告)
6 except の並び順 0 無し
7 __exit__True 0 無し
8 await 忘れ 1 有り
9 setTimeout 内の throw 1 有り
10 Promise.all の2件目 0 無し

終了コード0で通ったのが 5・6・7・10 の4個で、全体の40%。うち警告すら出ないのが 6・7・10 の3個で30%。テストが「例外が出ないこと」を確認していると、この3つは全部パスする。

危険度の順序も見えてくる。すぐ落ちる 2・3・4・8・9 は、その日のうちに気づく。放っておくと効くのは、静かに None0 を返し続ける 5・6・7・10 のほう。

出口で分類すると3方向しかない。Exception の親を通る (1・2)、try を抜けた後に来る (8・9・10)、捕まえた後に自分で捨てる (3・4・5・6・7)。冒頭の図はこの3分類を描いたもの。

この検証の限界

測ったのは macOS 15.7.4 / Python 3.14.5 / Node.js 26.0.0 の1環境だけ。特に5番目の SyntaxWarning は 3.14 で入った挙動なので、3.13以前では警告が出ない。手元のバージョンは python3 -V で先に確認したほうがいい。

JavaScript側の3つはランタイム依存が残る。ブラウザには process.on が無いし、未処理の reject をどう扱うかも実装差がある。Node 26 での結果として読んでほしい。

10パターンという数は自分の観測範囲での区切りで、網羅ではない。asyncio のタスク内例外、logging.error(e) でトレースが落ちる話、スレッドの中の例外は今回入れていない。どれも同じ「時間軸のズレ」か「捕まえた後に捨てる」に分類できる。

今日・今週・今月やること

今日 (5分)。自分のリポジトリで bare except を数える。

today.sh
# bare except の件数をファイル別に出す
grep -rc --include='*.py' -E '^\s*except\s*:' . | grep -v ':0'

# finally の直後3行に return / break / continue が無いか
grep -rn --include='*.py' -A3 -E '^\s*finally\s*:' . | grep -E 'return|break|continue'

今週 (30分)。Python 3.14 に上げて、警告をエラーとして落とす。CIに1行足すだけで5番目の型が消える。

this_week.sh
# SyntaxWarning をエラー扱いにしてテストを走らせる
python3 -W error::SyntaxWarning -m pytest

# __exit__ が True を返している箇所を洗い出す
grep -rn --include='*.py' -A8 'def __exit__' . | grep -E 'return True'

今月。JavaScript側をリンターに任せる。no-floating-promises を有効にすると8番目が機械的に落ちる。あわせて Promise.all の呼び出しを検索し、全件の結果が要る場所を allSettled に置き換える。Python側は ruff の E722 をCIに入れる。

this_month.sh
# 全件の結果が必要な Promise.all を洗い出す
# --include はブレース展開しない。'*.{js,ts,mjs}' と書くと0件になる
grep -rn --include='*.js' --include='*.ts' --include='*.mjs' 'Promise.all(' . | wc -l

まとめ

except Exception は「全部」ではない。BaseException の直下にいる3つは網の外を通り、非同期の throwtry を抜けた後に来て、finallyreturn__exit__True は捕まえた例外を自分の手で捨てる。

10パターンのうち4つは終了コード0で、3つは警告すら出ない。テストが緑でも、そこに例外が無かった証明にはならない。まず grep -rc --include='*.py' -E '^\s*except\s*:' . を1回叩くところから。

raise e はトレースバックを消す」は Python 3 では起きなかった。5フレーム対4フレームで、むしろ増えている。避ける理由はノイズが1行増えることだけ。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?