弱点補強: asyncio構造化並行性(TaskGroup × ExceptionGroup × タイムアウト伝播)— gather(return_exceptions=True)の例外オブジェクトがtruthy評価され「在庫チェック失敗」が「適用可能」に化けた事故を、CheckResult型 × TaskGroup × except* × PENDING_RETRYの4点セットで作り直す(MOps 販促システムチーム campaign-eligibility-checker Bad→Good 7点)

2026-09-13 (Day 126) 日曜 弱点補強 ★★★★☆ Python 3.12 / asyncio.TaskGroup ExceptionGroup / PEP 654

概要

⚠️

例外オブジェクトも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つの外部サービスを非同期に呼び出し、結果を集約して適用可否を返している。

先週、在庫サービス側の一時的な高負荷でレスポンスが3秒→8秒に遅延した際、本来「保留」にすべきキャンペーンが大量に「適用可能」と誤判定され、在庫を上回るクーポンが発行される事故が発生した。原因調査で以下の実装が見つかった。

制約・前提条件

  • 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 を使うこと
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(結果型 + チェック実行ヘルパー + メイン判定関数)+ 実行例(input→output)+ 適用した設計パターン名

悪いコード (Before)

このコードには 7つの構造的な問題 が隠れています。見つけてみてください。
現状 — bad_campaign_eligibility.py
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}
問題点サマリー(7点)
1例外オブジェクトをそのまま結果配列に混入 — 成功値と失敗値が同じ型として扱われる
2例外オブジェクトをtruthy評価 — 失敗した在庫チェックが「成功」と誤判定される(事故の直接原因)
3個別タイムアウト未設定 — 1サービスの遅延がバッチ全体の応答時間に直結
4失敗時に他タスクを自動キャンセルできない — 無駄なAPI呼び出し・二重課金リスク
5失敗理由が一切ログに出ない — 事後調査でしか原因を追えない
6リトライ処理が皆無 — 瞬間的な障害でも即座に誤判定の原因になる
7部分的失敗を区別する状態がない — 「保留」がなく誤ったYES/NO判定の一本道

ヒント(段階的開示)

ヒント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()を個別付与
41タスク失敗時に他タスクを自動キャンセルできない構造化並行性asyncio.TaskGroupへ移行
5失敗理由がログに出ず原因追跡不能可観測性logger.warning/errorで構造化ログ
6一時的エラーへのリトライが皆無耐障害性TransientServiceErrorのみ線形バックオフ
7部分的失敗を区別する状態がないフェイルセーフPENDING_RETRYステータスで保留

構成図 — Bad vs Good(SVG)

Bad(変更前)— gather(return_exceptions=True) 3つの外部呼び出しをgather()で並行実行 問題3: 個別タイムアウトなし。在庫サービス遅延がバッチ全体に直結 問題6: 一時的エラーへのリトライが存在しない 問題4: 1つ失敗しても他タスクは止まらず走り続ける 在庫サービスがTimeoutErrorを送出 問題1: return_exceptions=Trueで例外インスタンスがstock_okに代入される 問題5: 失敗理由がログに出ない(気づけない) → stock_okの中身は「TimeoutErrorインスタンス」 is_eligible = bool(coupon_ok) and bool(stock_ok) and ... 問題2: bool(TimeoutErrorインスタンス) は Python では True → 在庫チェック「失敗」が eligible計算では「成功」として扱われる 問題7: 部分的失敗を区別する「保留」状態が存在しない eligible: True が返る 在庫チェック未完了のままクーポンが誤って大量発行される × 失敗と成功が同じ型で表現され区別不能 × 例外のtruthy評価がそのままビジネス判定に混入 × 「保留」という安全側の逃げ道が存在しない Good(変更後)— TaskGroup × CheckResult × except* asyncio.TaskGroupで3タスクをcreate_task 修正3: 各_run_checkにasyncio.timeout()を個別付与 修正6: TimeoutError/TransientServiceErrorのみ線形バックオフでリトライ 修正5: 各試行の失敗をlogger.warning/errorへ記録 _run_checkは例外を外に漏らさずCheckResultを返す 修正1・2: ok:bool / value / error を別フィールドで明示 → 在庫失敗時は CheckResult(source="inventory", ok=False, ...) 修正4: 想定内の失敗ではTaskGroupの自動キャンセルは発動しない except* TimeoutError / except* Exception 想定外のバグでTaskGroupがExceptionGroupを送出した場合のみ発動 eg.exceptionsで型ごとに一括ログ→raiseで再送出 failed_sources = [r for r in results if not r.ok] 修正7: 1件でも失敗があれば status="PENDING_RETRY" を返す → 在庫チェック失敗時は eligible=False かつ保留として明示 ✓ 成功/失敗/値が型で明確に分離される ✓ 想定内の失敗と想定外のバグを区別できる ✓ 部分的失敗は安全側(保留)に倒れる 修正

模範解答

Before — gather(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,
)
is_eligible = (
    bool(coupon_ok) and bool(stock_ok)
    and member_rank in ("gold", "platinum")
)
After — CheckResult型 + TaskGroup + except*
"""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を返す安全側(保留)に倒しサイレント誤判定を根絶

ポイント解説

1return_exceptions=Trueは「例外を握りつぶす」機能であって「失敗を安全に扱う」機能ではない — 戻り値の型が「成功値 or 例外オブジェクト」のUnionになるだけで、呼び出し側が明示的にisinstance(x, BaseException)をチェックしない限り、Pythonの「ほぼ全てのオブジェクトはtruthy」という性質によって失敗が成功として扱われる事故が起きる。
2asyncio.TaskGroupの本質的な価値は「1タスクの異常終了時に他タスクを自動キャンセルする」構造化並行性にある — ただし部分的な失敗を保留扱いにしたいケースでは、_run_check内で例外を最後まで捕捉しCheckResultとして返すことで、TaskGroupの自動キャンセルが発動するのを「本当に予期しないバグ」の場合だけに限定する設計判断が重要。
3except*(ExceptionGroup handling, PEP 654)は、TaskGroup配下で複数タスクが同時に異なる例外で失敗した場合でも、例外の型ごとに一括で処理を分岐できる — 通常のexceptでは最初の1つの例外しか捕まえられないが、except*は同じ型の例外を全てまとめてeg.exceptionsとして受け取れる。
4「失敗を握りつぶして適当な既定値で処理を継続する」設計は、障害を検知不能にする最悪のパターンである — 「保留(PENDING_RETRY)」という第3の状態を用意することで、「成功」でも「明確な不適用」でもない状態を安全側に倒せる。

実務への応用

クーポン発行・在庫予約・決済確定など、複数の外部サービスを非同期で束ねて1つの業務判断をする箇所は全て同じ罠を抱えやすい。「asyncio.gather(return_exceptions=True)の戻り値を型チェックせずそのまま使っていないか」を既存コードの監査観点にできる。

MOpsチームのdiscount_calculator・price-promotion-calculationバッチ(9/12実施分)のような他の非同期集約処理にも、CheckResult型 + TaskGroup + except*の組み合わせをそのまま横展開できる。

DataDog/OTelでの監視設計として、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(型エイリアス構文)

自己評価(あとで記入)

自分の回答

気づき・メモ