Python 3.11では、新しい構文として、例外処理に try ~ except*
が追加されました
ちょっと用途がわかりにくい機能ですが、try ~ except*
は、主に asyncio による非同期処理で利用することを想定した機能で、asyncio
を使っていない場合はさしあたってあまり気にしなくても良いかもしれません。
ExceptionGroup例外¶
Python3.11では、組み込みの例外型に ExceptionGroup が追加されました。新しく追加された try ~ except*
は、主に ExceptionGroup
例外と組み合わせて使用します。
ExceptionGroup
例外は、複数の例外が発生したとき、一つの例外にまとめるためのオブジェクトです。たとえば、 2つの例外 ValueError
例外と RuntimeError
例外が発生した場合、一つの ExceptionGroup
例外として送出できます。
次の例は、ValueError
例外と RuntimeError
例外をまとめて、一つの ExceptionGroup
例外として raise
しています。
>>> excepions = [ValueError("例外1"), RuntimeError("例外2")]
>>> raise ExceptionGroup("2つの例外が発生した!", excepions)
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 1, in <module>
| ExceptionGroup: 2つの例外が発生した! (2 sub-exceptions)
+-+---------------- 1 ----------------
| ValueError: 例外1
+---------------- 2 ----------------
| RuntimeError: 例外2
+------------------------------------
ExceptionGroup
例外は、ValueError
などの例外と同じ、単なる例外オブジェクトです。try ~ except
ブロックを使うと、次のように except
節に指定して捕捉できます。
>>> try:
... excepions = [ValueError("例外1"), RuntimeError("例外2")]
... raise ExceptionGroup("2つの例外が発生した!", excepions)
...
... except ExceptionGroup as e:
... print("ExceptionGroup:", repr(e))
...
ExceptionGroup: ExceptionGroup('2つの例外が発生した!',
[ValueError('例外1'), RuntimeError('例外2')])
try ~ except*ブロック¶
では、こんどは Python3.11で導入された try ~ except*
ブロックで、 ExceptionGroup
例外を捕捉してみましょう。
>>> try:
... excepions = [ValueError("例外1"), RuntimeError("例外2")]
... raise ExceptionGroup("2つの例外が発生した!", excepions)
...
... except* ValueError as e:
... print("ValueError:", repr(e))
...
... except* RuntimeError as e:
... print("RuntimeError :", repr(e))
...
ValueError: ExceptionGroup('2つの例外が発生した!', [ValueError('例外1')])
RuntimeError : ExceptionGroup('2つの例外が発生した!', [RuntimeError('例外2')])
try ~ except
とは大きく違う点が二つ、目に付きます。
複数の except* ブロックが実行される¶
まず第一に、通常の try ~ except
ブロックであれば、複数の except
ブロックが実行されることはありません。
たとえば、次のコードで ValueError
例外が発生すると、except ValueError
のブロックだけが実行されます。RuntimeError
例外が発生すると、 except RuntimeError
のブロックだけが実行されます。
>>> try:
... raise ValueError("例外のテスト")
...
... except ValueError as e:
... print("ValueError:", repr(e))
...
... except RuntimeError as e:
... print("RuntimeError:", repr(e))
...
ValueError: ValueError('例外のテスト')
except ValueError
ブロックと except RuntimeError
ブロックのうち、実行されるのはどちらか一方だけで、両方が実行されることは決してありません。
しかし、try ~ except*
ブロックでは、ExceptionGroup
例外が発生した場合、ExceptionGroup
に登録されている例外のブロックが すべて 実行されます。先程の例では、 ValueError
と RuntimeError
が登録されていますので、except ValueError
ブロックと except RuntimeError
ブロックの 両方 が実行されます。
例外オブジェクトはExceptionGroup.exceptions属性で参照する¶
第二に、try ~ exept
ブロックで except ValueError as e
と指定した場合、変数 e
には必ず except
節に指定した ValueError
型のオブジェクトが代入されます。
一方、except* ValueError as e
の場合、except*
節の指定に関わらず、変数 e
には必ず ExceptionGroup
型のオブジェクトとなります。
上の例で、 except* ValueError as e
で指定した例外ハンドラを実行するとき、例外オブジェクト e
にはExceptionGroup
型のオブジェクトが代入されます。ValueError
型のオブジェクトではありません。
実際に発生した例外オブジェクトは、ExceptionGroup
オブジェクトの exceptions
フィールド に格納されます。exceptions
は例外オブジェクトのシーケンスで、except*
節に指定した例外型にマッチする例外オブジェクトだけが格納されます。
次の例では、ValueError
が二つ、RuntimeError
が一つ、KeyError
が一つ発生しています。
>>> try:
... excepions = [ValueError("例外1"), ValueError("例外2"),
... RuntimeError("例外3"), KeyError("例外4")]
... raise ExceptionGroup("4つの例外が発生した!", excepions)
...
... except* (ValueError, RuntimeError) as e:
... print("ValueErrorとRuntimeError:", repr(e.exceptions))
...
... except* KeyError as e:
... print("KeyError:", repr(e.exceptions))
...
ValueErrorとRuntimeError: (ValueError('例外1'), ValueError('例外2'),
RuntimeError('例外3'))
KeyError: (KeyError('例外4'),)
except* (ValueError, RuntimeError) as e
のブロックが実行するとき、e.exceptions
には二つの ValueError
と一つの RuntimeError
が格納されます。
同様に、except* KeyError as e
のブロックを実行するときには、KeyError
一つだけが格納されます。
未処理例外¶
通常の try ~ except
ブロックの場合、try
ブロックで発生して except
で指定されていない例外は、親のブロックに送出されます。
try ~ except*
の場合も同様ですが、try ~ except*
では、except*
で指定されて いない 例外だけが送出されます。次の例では、ValueError
、RuntimeError
, KeyError
が発生していますが、ValueError
とRuntimeError
は内部ブロックで捕捉しています。
親ブロックには内部のブロックで捕捉していない KeyError
だけが送出されます。
>>> try:
... try:
... excepions = [ValueError("例外1"), RuntimeError("例外2"),KeyError(" 例外3")]
... raise ExceptionGroup("3つの例外が発生した!", excepions)
... except* ValueError as e:
... print("内部 ValueError:", repr(e))
... except* RuntimeError as e:
... print("内部 RuntimeError:", repr(e))
... except* KeyError as e:
... print("外部 KeyError:", repr(e))
...
内部 ValueError: ExceptionGroup('3つの例外が発生した!', [ValueError('例外1')])
内部 RuntimeError: ExceptionGroup('3つの例外が発生した!', [RuntimeError('例外2')])
外部 KeyError: ExceptionGroup('3つの例外が発生した!', [KeyError('例外3')])
Naked例外¶
try ~ except*
ブロックで except* ValueError as e
と指定して例外を捕捉するとき、変数 e
には必ず ExceptionGroup
型のオブジェクトが代入されます。ValueError
型のオブジェクトではありません。
これは、try ~ except*
ブロックの try
ブロックで ExceptionGroup
以外の例外が送出された場合も同様です。例えば ValueError
などの例外が発生した場合でも、except*
節には自動的に ExceptionGroup
例外が渡されます。
>>> try:
... raise ValueError("ValueErrorです!")
... except* ValueError as e:
... print("ValueError:", repr(e))
...
ValueError: ExceptionGroup('', (ValueError('ValueErrorです!'),))
このように、try ~ except*
の try
ブロックで ExceptionGroup
以外の例外が発生した場合、このような例外を Naked例外 と呼びます。Naked例外が発生した場合、自動的に新しく生成した ExceptionGroup
でラップされます。
この例では、try
ブロックで ValueError
例外が送出されていますが、この ValueError
をラップする ExceptionGroup
例外が生成されて、変数 e
に代入されています。
asyncio.TaskGroup¶
これまで説明してきた try ~ except*
ブロックと ExceptionGroup
は、主に asyncio による非同期処理で使うことを想定して開発されました。
通常の同期的な処理の場合、実行している処理は常にひとつだけです。例外が発生するとそこで処理は中断しますので、「複数の例外が発生する」ということはありません。
しかし、複数の非同期タスクを起動すると、それぞれの非同期タスクで別々に例外が発生する可能性があります。
Python 3.10までの複数タスク¶
Python 3.10では、複数の非同期タスクを同時に実行する場合、asyncio.gather() や asyncio.wait() などを利用します。
次の非同期関数 get_sum()
は、asyncio.gather()
を使ってWeb APIから複数のデータを取得し、結果を集計しています。
import asyncio
import aiohttp
async def fetch(session, params):
async with session.post(url="/api", json=params) as resp:
resp.raise_for_status()
data = await resp.json()
return data['value']
async def get_sum():
async with aiohttp.ClientSession("https://www.example.com") as session:
results = await asyncio.gather(
fetch(session, {"id":1}),
fetch(session, {"id":2}),
fetch(session, {"id":3}),
)
return sum(results)
async def main():
while True:
try:
await get_sum()
break
except aiohttp.ClientConnectorError:
# 最初のエラーがClientConnectorErrorの場合はリトライする
pass
except aiohttp.ClientResponseError:
# 最初のエラーがClientResponseErrorの場合は一秒まってからリトライする
await asyncio.sleep(1)
asyncio.run(main())
この処理は、ほぼ同時にWeb APIサーバにリクエストを3回送出します。どれか一つでもエラーが発生した場合、asyncio.gather()
は最初のエラーを例外として送出して、呼び出し元に復帰してします。
エラーが発生したのが一つだけならこれでも構いません。しかし、2番目と3番目のリクエストでもエラーが発生していている場合、この二つのエラー情報はそのまま捨てられてしまいます。
発生したすべてのエラーを取得する方法もありますが、取得したエラー情報やトレースバックをすべて呼び出し元に伝えるのは、通常の例外処理に比べるとかなり面倒です。
Python 3.11の複数タスク¶
そこで、Python 3.11で複数のタスク実行を管理する asyncio.TaskGroup が追加されました。TaskGroup
は、発生した例外をすべて ExceptionGroup例外
にまとめて送出します。
先程の処理は、TaskGroup
を使って次のように書けます。
import asyncio
import aiohttp
async def fetch(session, params):
# 3.10版と同じ
async with session.post(url="/api", json=params) as resp:
resp.raise_for_status()
data = await resp.json()
return data['value']
async def get_sum():
async with aiohttp.ClientSession("https://www.example.com") as session:
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch(session, {"id":1}))
task2 = tg.create_task(fetch(session, {"id":2}))
task3 = tg.create_task(fetch(session, {"id":3}))
return task1.result() + task2.result() + task3.result()
async def main():
while True:
try:
await get_sum()
break
except* aiohttp.ClientConnectorError:
# ClientConnectorErrorの場合はリトライする
pass
except* aiohttp.ClientResponseError:
# ClientResponseErrorが一つでも発生していたら一秒まってからリトライする
await asyncio.sleep(1)
asyncio.run(main())
TaskGroup
は非同期コンテキストマネージャとして使用し、TaskGroup.create_task()
で登録したタスクがすべて終了するか、例外が発生すると終了します。例外が発生した場合、他の実行中のタスクは自動的にキャンセルします。
登録したタスクで例外が発生した場合、TaskGroup
はすべての例外を ExceptionGroup例外
にまとめて送出します。ですので、呼び出し元では try ~ except*
で発生したすべての例外を捕捉し、より適切な例外処理を実行できるようになります。
この例の場合、try ~ except
の場合には最初に受け取ったエラーに応じた例外処理しかできていませんが、TaskGroup
と try ~ except*
を使ったバージョンでは、発生しているすべてのエラーを参照して処理を決定しています。