概要
例外オブジェクトもPythonでは「ただのオブジェクト」— bool()すればTrueになる
asyncio.gather(return_exceptions=True)は失敗した呼び出しの戻り値に例外インスタンスをそのまま代入する。呼び出し側が型チェックを怠ると、失敗が成功として扱われるサイレントな誤動作を生む。
asyncio.TaskGroupは「1つの異常終了で全体を巻き込む」構造化並行性
Python 3.11+で導入。子タスクの未捕捉例外はExceptionGroupとして集約され、他の実行中タスクは自動キャンセルされる。「想定内の失敗」と「本当に予期しないバグ」を区別する設計が前提になる。
except*(PEP 654)は例外の型ごとに一括で処理を分岐できる
通常のexceptは最初の1つの例外しか捕まえられないが、TaskGroup配下で複数タスクが同時に異なる例外で落ちた場合でも、except*は同じ型の例外をまとめてeg.exceptionsとして受け取れる。
「成功」でも「明確な不適用」でもない第3の状態を持つ
部分的な失敗を握りつぶして適当な既定値で処理を継続するのは最悪のパターン。PENDING_RETRYという保留状態を用意し、安全側に倒す設計が事故を防いだ。
問題
ECサイト MOps 販促システムチームが運用するcampaign-eligibility-checker(キャンペーン適用可否判定バッチ)は、1件のキャンペーン判定につきクーポンサービス・在庫サービス・会員ランクサービスの3つの外部サービスを非同期に呼び出し、結果を集約して適用可否を返している。
制約・前提条件
- Python 3.12+。
asyncio.TaskGroup(3.11+)とasyncio.timeout()(3.11+)、except*によるExceptionGroup(PEP 654)ハンドリングを使うこと coupon_client/inventory_client/member_clientは既存の外部提供クライアントと仮定してよい(一時的な障害時はTransientServiceErrorを送出するものとする)- 1つの外部サービスが失敗・タイムアウトしても、例外オブジェクトをtruthy評価で「成功扱い」にしないこと。成功/失敗は明示的な型(dataclass等)で表現すること
- 一時的なエラー・タイムアウトには最小限のリトライ(線形バックオフ)を入れること
- 1つでもチェックが最終的に失敗した場合は「不適用」と断定せず、保留ステータスとして区別できるようにすること
- dataclass(
frozen=True, slots=True)・PEP 695型エイリアス・Google スタイル docstring を使うこと
悪いコード (Before)
import asyncio
async def check_campaign_eligibility(user_id: str, campaign_id: str) -> dict:
"""キャンペーン適用可否を3つの外部サービスの結果から判定する。"""
# 問題1: return_exceptions=Trueで例外を握りつぶし、
# 失敗時は例外オブジェクトが結果に混入する
coupon_ok, stock_ok, member_rank = await asyncio.gather(
coupon_client.check_coupon_eligibility(user_id, campaign_id),
inventory_client.check_stock(campaign_id),
member_client.get_member_rank(user_id),
return_exceptions=True,
)
# 問題2: 例外オブジェクトかどうかを判定せずそのままbool評価している
is_eligible = (
bool(coupon_ok)
and bool(stock_ok)
and member_rank in ("gold", "platinum")
)
return {"campaign_id": campaign_id, "eligible": is_eligible}
ヒント(段階的開示)
ヒント1 — 方向性
asyncio.gather(..., return_exceptions=True)が返す配列には、成功した値と失敗した例外オブジェクトが同じ型(object)として混在する」という設計上の罠にある。Pythonではほぼ全てのオブジェクトがbool()でTrueになる——例外インスタンスも例外ではない。したがってbool(coupon_ok)は、coupon_okが本物の成功値でも、失敗して代入された例外オブジェクトでも、どちらもTrueになりうる。「失敗した」という情報と「値」という情報を、同じ変数・同じ型で表現しようとしたことが根本原因である。
ヒント2 — アプローチ
- 問題1・2: 各外部呼び出しの結果を「成功/失敗を明示するbool」と「値」を別フィールドに持つ結果型(
CheckResultのようなdataclass)でラップし、例外を一切外に漏らさないヘルパー関数の戻り値としてのみ扱う - 問題3・4:
asyncio.gatherではなくasyncio.TaskGroupを使うと、1つのタスクで捕捉されない例外が起きた場合に他の実行中タスクが自動的にキャンセルされる(構造化並行性)。ただしヘルパー関数側で例外を握りつぶしCheckResultとして返す設計にすれば、「本当に想定外のバグ」の場合だけキャンセル・ExceptionGroup送出が発動するようにできる - 問題5・6:
TimeoutErrorと業務側で定義したTransientServiceErrorだけをリトライ対象にし、それ以外の例外は即座に失敗確定させる。失敗理由は必ずログに残す - 問題7: 3つ全部揃わないと判定できない設計をやめ、「1つでも失敗があれば
PENDING_RETRYとして保留」という第3の状態を明示的に持たせる
ヒント3 — コードの骨格
@dataclass(frozen=True, slots=True)
class CheckResult:
source: str
ok: bool
value: object | None
error: str | None
async def _run_check(source, call, *, timeout, max_attempts):
"""例外を外に漏らさず、必ずCheckResultとして返す。"""
for attempt in range(1, max_attempts + 1):
try:
async with asyncio.timeout(timeout):
value = await call()
return CheckResult(source, True, value, None)
except TimeoutError:
... # リトライ
except TransientServiceError:
... # リトライ
except Exception as exc:
return CheckResult(source, False, None, str(exc))
...
async def check_campaign_eligibility(user_id, campaign_id):
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(_run_check("coupon", ...))
t2 = tg.create_task(_run_check("inventory", ...))
t3 = tg.create_task(_run_check("member_rank", ...))
# ここに来た時点でt1〜t3は全て正常完了(CheckResultを返した)している
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 例外オブジェクトを結果配列にそのまま混入 | 型設計 | CheckResultで成功/失敗と値を分離 |
| 2 | 例外オブジェクトをtruthy評価し誤判定(事故の直接原因) | Result型パターン | ok:boolフィールドで明示判定 |
| 3 | 個別タイムアウト未設定でバッチが無期限ハング | タイムアウト設計 | asyncio.timeout()を個別付与 |
| 4 | 1タスク失敗時に他タスクを自動キャンセルできない | 構造化並行性 | asyncio.TaskGroupへ移行 |
| 5 | 失敗理由がログに出ず原因追跡不能 | 可観測性 | logger.warning/errorで構造化ログ |
| 6 | 一時的エラーへのリトライが皆無 | 耐障害性 | TransientServiceErrorのみ線形バックオフ |
| 7 | 部分的失敗を区別する状態がない | フェイルセーフ | PENDING_RETRYステータスで保留 |
構成図 — Bad vs Good(SVG)
模範解答
coupon_ok, stock_ok, member_rank = await asyncio.gather(
coupon_client.check_coupon_eligibility(user_id, campaign_id),
inventory_client.check_stock(campaign_id),
member_client.get_member_rank(user_id),
return_exceptions=True,
)
is_eligible = (
bool(coupon_ok) and bool(stock_ok)
and member_rank in ("gold", "platinum")
)
"""campaign_eligibility.py — キャンペーン適用可否判定(構造化並行性版)。
Python 3.12+。asyncio.TaskGroup(3.11+) + asyncio.timeout(3.11+)
+ ExceptionGroup(PEP 654)を使用。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Awaitable, Callable, Literal
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT_SECONDS: float = 3.0
MAX_RETRY_ATTEMPTS: int = 2
RETRY_BACKOFF_BASE_SECONDS: float = 0.5
ELIGIBLE_MEMBER_RANKS: frozenset[str] = frozenset({"gold", "platinum"})
type EligibilityStatus = Literal["DECIDED", "PENDING_RETRY"]
class TransientServiceError(Exception):
"""一時的なネットワーク/5xxエラー。リトライ対象を型で明示(修正6)。"""
@dataclass(frozen=True, slots=True)
class CheckResult:
"""1つの外部サービス呼び出しの結果。
成功/失敗を明示的なboolフィールドとして持つことで、
例外オブジェクトのtruthy評価に依存した誤判定(問題1・2)
を型レベルで排除する。
Attributes:
source: 呼び出し元サービス名(ログ・監視での識別子)。
ok: 呼び出しが成功したかどうか。
value: 成功時の戻り値。okがFalseならNone。
error: 失敗時のエラーメッセージ。okがTrueならNone。
"""
source: str
ok: bool
value: object | None
error: str | None
@dataclass(frozen=True, slots=True)
class EligibilityDecision:
"""キャンペーン適用可否の最終判定結果。
Attributes:
campaign_id: 判定対象のキャンペーンID。
status: "DECIDED"(全チェック成功) / "PENDING_RETRY"(保留、修正7)。
eligible: statusが"DECIDED"の場合のみ意味を持つ適用可否。
failed_sources: 失敗したチェック元の一覧。
"""
campaign_id: str
status: EligibilityStatus
eligible: bool
failed_sources: tuple[str, ...]
async def _run_check(
source: str,
call: Callable[[], Awaitable[object]],
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
max_attempts: int = MAX_RETRY_ATTEMPTS,
) -> CheckResult:
"""1つの外部サービス呼び出しをタイムアウト・リトライ付きで実行する。
このコルーチンは意図的に例外を外へ伝播させない。すべての失敗を
CheckResult(ok=False, ...)として返すことで、呼び出し元の
asyncio.TaskGroupがsibling taskを巻き込みキャンセルするのを
「真に予期しないバグ」の場合だけに限定する(修正3・4・5・6)。
"""
last_error: str | None = None
for attempt in range(1, max_attempts + 1):
try:
async with asyncio.timeout(timeout):
value = await call()
return CheckResult(source=source, ok=True, value=value, error=None)
except TimeoutError:
last_error = f"timeout after {timeout}s (attempt {attempt}/{max_attempts})"
logger.warning(
"eligibility_check_timeout",
extra={"source": source, "attempt": attempt},
) # 修正5: 失敗理由を必ずログに残す
except TransientServiceError as exc:
last_error = str(exc)
logger.warning(
"eligibility_check_transient_error",
extra={"source": source, "attempt": attempt, "error": str(exc)},
)
await asyncio.sleep(RETRY_BACKOFF_BASE_SECONDS * attempt) # 修正6
except Exception as exc:
logger.error(
"eligibility_check_failed",
extra={"source": source, "error": str(exc)},
)
return CheckResult(source=source, ok=False, value=None, error=str(exc))
return CheckResult(source=source, ok=False, value=None, error=last_error)
async def check_campaign_eligibility(
user_id: str, campaign_id: str
) -> EligibilityDecision:
"""3つの外部サービスを構造化並行性で呼び出し適用可否を判定する(修正4・7)。"""
try:
async with asyncio.TaskGroup() as tg: # 修正4: 構造化並行性
coupon_task = tg.create_task(
_run_check(
"coupon",
lambda: coupon_client.check_coupon_eligibility(user_id, campaign_id),
)
)
inventory_task = tg.create_task(
_run_check("inventory", lambda: inventory_client.check_stock(campaign_id))
)
member_task = tg.create_task(
_run_check("member_rank", lambda: member_client.get_member_rank(user_id))
)
except* TimeoutError as eg:
# _run_check内でタイムアウトは既に捕捉済み。ここに来るのは想定外のバグのみ
logger.critical(
"eligibility_unexpected_timeout_group",
extra={"errors": [str(e) for e in eg.exceptions]},
)
raise
except* Exception as eg:
logger.critical(
"eligibility_unexpected_failure_group",
extra={"errors": [str(e) for e in eg.exceptions]},
)
raise
results = (coupon_task.result(), inventory_task.result(), member_task.result())
failed_sources = tuple(r.source for r in results if not r.ok)
if failed_sources:
# 修正1・2・7: 部分的失敗は「不適用」と即断せず保留にする
logger.error(
"eligibility_check_partial_failure",
extra={"campaign_id": campaign_id, "failed_sources": failed_sources},
)
return EligibilityDecision(
campaign_id=campaign_id,
status="PENDING_RETRY",
eligible=False,
failed_sources=failed_sources,
)
coupon_result, inventory_result, member_result = results
is_eligible = (
bool(coupon_result.value)
and bool(inventory_result.value)
and member_result.value in ELIGIBLE_MEMBER_RANKS
)
return EligibilityDecision(
campaign_id=campaign_id, status="DECIDED", eligible=is_eligible, failed_sources=()
)
# ケース1: 全サービス正常応答 → 確定判定
result = await check_campaign_eligibility("u-001", "camp-2026-09")
# → EligibilityDecision(campaign_id='camp-2026-09', status='DECIDED',
# eligible=True, failed_sources=())
# ケース2: inventory_clientが3秒×2回ともタイムアウト(coupon/member_rankは正常)
result = await check_campaign_eligibility("u-002", "camp-2026-09")
# → EligibilityDecision(campaign_id='camp-2026-09', status='PENDING_RETRY',
# eligible=False, failed_sources=('inventory',))
#
# Bad版では同じ状況で bool(TimeoutErrorインスタンス) == True と評価され、
# is_eligible=True が返り「在庫チェック未完了のまま適用可能」という
# 誤判定が起きていた。これが先週の事故の再現条件そのもの。
| 問題 | 修正内容 | 効果 |
|---|---|---|
| 1・2 例外のtruthy誤判定 | CheckResult(ok, value, error)で成功/失敗を分離 | 失敗が成功として扱われる経路を型で断つ |
| 3・4 タイムアウト・キャンセル未実装 | asyncio.timeout() + asyncio.TaskGroup | 個別タイムアウト+想定外バグ時のみ全体キャンセル |
| 5 ログ欠如 | logger.warning/error/criticalで構造化ログ | どのサービスがなぜ失敗したか即座に追跡可能 |
| 6 リトライ欠如 | TimeoutError/TransientServiceErrorのみ線形バックオフ | 瞬間的障害による誤判定を吸収 |
| 7 部分的失敗の未区別 | failed_sourcesありでPENDING_RETRYを返す | 安全側(保留)に倒しサイレント誤判定を根絶 |
ポイント解説
return_exceptions=Trueは「例外を握りつぶす」機能であって「失敗を安全に扱う」機能ではない — 戻り値の型が「成功値 or 例外オブジェクト」のUnionになるだけで、呼び出し側が明示的にisinstance(x, BaseException)をチェックしない限り、Pythonの「ほぼ全てのオブジェクトはtruthy」という性質によって失敗が成功として扱われる事故が起きる。asyncio.TaskGroupの本質的な価値は「1タスクの異常終了時に他タスクを自動キャンセルする」構造化並行性にある — ただし部分的な失敗を保留扱いにしたいケースでは、_run_check内で例外を最後まで捕捉しCheckResultとして返すことで、TaskGroupの自動キャンセルが発動するのを「本当に予期しないバグ」の場合だけに限定する設計判断が重要。except*(ExceptionGroup handling, PEP 654)は、TaskGroup配下で複数タスクが同時に異なる例外で失敗した場合でも、例外の型ごとに一括で処理を分岐できる — 通常のexceptでは最初の1つの例外しか捕まえられないが、except*は同じ型の例外を全てまとめてeg.exceptionsとして受け取れる。実務への応用
クーポン発行・在庫予約・決済確定など、複数の外部サービスを非同期で束ねて1つの業務判断をする箇所は全て同じ罠を抱えやすい。「asyncio.gather(return_exceptions=True)の戻り値を型チェックせずそのまま使っていないか」を既存コードの監査観点にできる。
MOpsチームのdiscount_calculator・price-promotion-calculationバッチ(9/12実施分)のような他の非同期集約処理にも、CheckResult型 + TaskGroup + except*の組み合わせをそのまま横展開できる。
PENDING_RETRY状態に入った件数をメトリクス化しておくと、「外部サービスの部分障害がどれだけ業務判定に影響しているか」を可観測性の指標として持てる。
今日のまとめ
asyncio.gather(return_exceptions=True)は「例外を止めずに全タスクを走らせる」ことしかしてくれず、成功と失敗を区別する責任は呼び出し側に残る。Pythonでは例外オブジェクトもtruthyであるため、その区別を怠ると「失敗が成功として扱われる」サイレントな誤動作を生む。CheckResult型で成功/失敗を明示し、asyncio.TaskGroupで構造化並行性を、except*でExceptionGroupを型別に扱うことで、「想定内の部分的失敗は保留に倒し、想定外のバグだけを本当の異常として伝播させる」設計に組み替えるのが今回の核心である。
次のステップ
- 発展問題:
PENDING_RETRYになったキャンペーン判定を、Argo WorkflowsのretryStrategy(バックオフ・最大リトライ回数)でバッチ全体として再試行する設計に落とし込め。 - 発展問題:
_run_checkのリトライを手動のfor文ではなくtenacityライブラリで置き換えた場合、asyncio.TaskGroupとの組み合わせで注意すべき点(キャンセル伝播とtenacityの内部sleepの相互作用)を検討せよ。 - 参考: Python公式ドキュメント「asyncio-task」(TaskGroup / timeout)、PEP 654(Exception Groups and except*)、PEP 695(型エイリアス構文)