A+B 複合 — 例外の握り潰し × null問題 × マジックナンバー(配送先住所検証処理 Bad→Good 第10章)× address-validation-service Geocoding API呼び出しの信頼性設計(リトライ判定 × Secret Manager × Workload Identity Federation × Cloud Tasksレート制御)

2026-09-05 (Day 152) 土曜複合問題 ★★★★☆ Python 3.12 / 例外階層 / 第10章 GCP Secret Manager / Workload Identity Federation / Cloud Tasks

概要

🕳️

except Exceptionは原因を握りつぶす

タイムアウト・住所不正・クォータ超過という性質の違う問題を一律Noneに変換すると、呼び出し元は「なぜ失敗したか」も「リトライすべきか」も判断できない(第10章)。

❓

nullは「呼び出し元の忘却」を前提にできない

Noneを返す設計は、呼び出し元がNoneチェックを1箇所でも忘れれば本番障害になる。失敗は例外という「型」で表現する(第10章)。

🔁

恒久的エラーはリトライしても解決しない

住所が本当に存在しない場合、何度リトライしても結果は変わらず、無駄なコンピュートとAPIクォータを消費するだけ。型で区別してリトライ対象を絞り込む。

🔐

秘密情報は「漏れたらどうなるか」で管理方式を選ぶ

APIキーの平文環境変数・サービスアカウント鍵ファイルは漏洩時の被害が大きい。Secret Manager・Workload Identity Federationで鍵そのものを持ち回らない設計にする。

問題 A: コーディング — 例外の握り潰し × null問題 × マジックナンバー(配送先住所検証処理 Bad→Good)

以下の「悪いコード」は、ECサイト MOps チームが運用する配送先住所検証サービス(address-validation-service)の一部です。注文確定時に、外部Geocoding API(住所→緯度経度変換サービス)を呼び出して配送先住所の妥当性を検証しますが、タイムアウト・住所不正・APIクォータ超過など、性質の異なるあらゆる例外をexcept Exceptionで一律に握りつぶし、すべてNoneとして返しています。この設計が原因で、先月「配送距離0kmとして扱われた注文が実在しない住所へ配送指示され、配送業者から差し戻しが発生する」インシデントが本番で起きました。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第10章(設計の悪魔: マジックナンバー、null問題、例外の握り潰し、YAGNI)を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+
  • Noneを「失敗」の表現として使わないこと — 「住所自体が不正(恒久的エラー、リトライ不要)」と「Geocoding APIが一時的に利用不可(一時的エラー、リトライ可能)」を型として区別できる例外階層を設計すること
  • except Exceptionで広範囲の例外を握りつぶさず、原因ごとに専用の例外クラスをraiseすること(例外の握り潰し禁止)
  • confidence閾値0.5は名前付き定数化すること
  • GeocodedAddressを@dataclass(frozen=True, slots=True)で表現すること
  • 呼び出し元(OrderFulfillmentService)が例外の種類に応じて異なる処理(恒久的エラーなら注文を差し戻し、一時的エラーなら後続のリトライ機構に委ねる)を選択できる設計にすること
  • Google スタイル docstring・インラインコメント・型ヒントを含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before) — カテゴリ A

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_address_validation.py — 例外の握り潰し × null問題
def validate_shipping_address(address_dict):
    try:
        result = geocode_client.geocode(
            postal_code=address_dict["postal_code"],
            prefecture=address_dict["prefecture"],
            city=address_dict["city"],
            street=address_dict["street"],
        )
        if result.confidence < 0.5:  # 問題1: マジックナンバー0.5
            return None
        return {
            "lat": result.lat,
            "lng": result.lng,
            "normalized": result.normalized_address,
        }
    except Exception:  # 問題2: あらゆる例外を一律で握りつぶす
        return None


class OrderFulfillmentService:
    def process_order(self, order):
        geo = validate_shipping_address(order["shipping_address"])
        # 問題3: geoがNoneの場合を一切考慮していない
        distance_km = self._calc_distance_from_warehouse(
            geo["lat"], geo["lng"]
        )
        if distance_km > 500:
            self._flag_for_manual_review(order)
        return self._create_shipment(order, distance_km)
問題点サマリー(7点)
1except Exceptionで性質の異なる例外を一律に握りつぶす(Ch10) — タイムアウト・住所不正・クォータ超過・認証エラーが区別不能になる
2失敗をNoneで表現している(Ch10) — 呼び出し元がNoneチェックを怠るとTypeError/AttributeErrorを引き起こす
3confidence閾値0.5がマジックナンバー(Ch10) — 意味が名前から読み取れない
4「住所不正」と「API一時不調」を区別できない — リトライすべきか差し戻すべきか呼び出し元が判断不能
5元の例外・スタックトレースがログに残らない — 握りつぶした時点で障害調査の手がかりを喪失
6process_orderがgeoのNoneチェックをせず属性アクセス — 本番でインシデントが実際に発生した
7戻り値の型ヒントがなくOptionalですら書かれていない — 静的解析でも検出不能な契約

ヒント A(段階的開示)

ヒント1 — 方向性
except Exception: return Noneという書き方は、一見「エラーを安全に処理している」ように見えるが、実際には「タイムアウト」「住所そのものが存在しない」「APIキーが無効」「クォータ超過」という性質も対処法もまったく異なる4種類の問題を、区別不能な1つのNoneに押し潰している。呼び出し元はNoneを受け取っても「なぜ失敗したか」も「リトライすべきか」も判断できず、geo["lat"]のようにNoneチェックを怠ると本番でTypeErrorやAttributeErrorを引き起こす。第10章の核心は、「例外は握りつぶさず、意味のある型として呼び出し元に伝える」「nullを返すくらいなら、失敗の理由を表現する専用の型を用意する」という設計判断にある。
ヒント2 — アプローチ
  • class AddressValidationError(Exception)を基底クラスとし、InvalidAddressError(恒久的・リトライ不要)とGeocodingServiceUnavailableError(一時的・リトライ可能)を継承させる
  • GeocodedAddressを@dataclass(frozen=True, slots=True)で定義し、lat: float, lng: float, normalized_address: strを持たせる
  • _MIN_CONFIDENCE_THRESHOLD: Final[float] = 0.5として定数化する
  • 元の例外はraise GeocodingServiceUnavailableError(...) from original_excのように原因を保持したまま再送出する
  • 呼び出し元はtry: ... except InvalidAddressError: ... except GeocodingServiceUnavailableError: ...のように例外の型で分岐する
ヒント3 — コードの骨格
class AddressValidationError(Exception):
    """住所検証処理における基底例外。"""

class InvalidAddressError(AddressValidationError):
    """住所そのものが不正(恒久的エラー、リトライ不要)。"""

class GeocodingServiceUnavailableError(AddressValidationError):
    """Geocoding APIが一時的に利用不可(一時的エラー、リトライ可能)。"""

@dataclass(frozen=True, slots=True)
class GeocodedAddress:
    lat: float
    lng: float
    normalized_address: str

def validate_shipping_address(address: ShippingAddress) -> GeocodedAddress:
    ...  # 失敗時はNoneではなく例外をraiseする

問題点分析 — カテゴリ A

#問題点分類改善方法
1except Exceptionで性質の違う例外を一律に握りつぶす例外握り潰し Ch10原因ごとに専用の例外クラスへ変換
2失敗をNoneで表現、呼び出し元のチェック漏れで本番障害null問題 Ch10例外を型として表現し発生を強制
3confidence閾値0.5がマジックナンバーCh10名前付き定数化
4恒久的/一時的エラーを区別できない責務分離 Ch10例外階層で型分離
5元の例外情報がログに残らない可観測性raise ... from excで連鎖保持
6process_orderがNoneチェックなしで属性アクセス実装バグexcept節での型別分岐
7戻り値の型ヒントがなくOptionalすらない型安全性明示的な戻り値型と例外契約

模範解答 A

Before — 例外握り潰し・null返却
def validate_shipping_address(address_dict):
    try:
        result = geocode_client.geocode(...)
        if result.confidence < 0.5:  # マジックナンバー
            return None
        return {"lat": result.lat, ...}
    except Exception:  # 全例外を握りつぶす
        return None

class OrderFulfillmentService:
    def process_order(self, order):
        geo = validate_shipping_address(order["shipping_address"])
        distance_km = self._calc_distance_from_warehouse(
            geo["lat"], geo["lng"]  # geoがNoneなら例外発生
        )
After — 例外階層で失敗理由を型として表現
"""address_validation.py — 例外の握り潰し × null問題の解消(Ch10)"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Final

_MIN_CONFIDENCE_THRESHOLD: Final[float] = 0.5


class AddressValidationError(Exception):
    """住所検証処理における基底例外。"""


class InvalidAddressError(AddressValidationError):
    """住所そのものが不正(恒久的エラー)。リトライしても結果は変わらない。"""

    def __init__(self, address: "ShippingAddress", confidence: float) -> None:
        super().__init__(
            f"住所の信頼度が閾値を下回りました: confidence={confidence:.2f} "
            f"< {_MIN_CONFIDENCE_THRESHOLD} (postal_code={address.postal_code})"
        )
        self.address = address
        self.confidence = confidence


class GeocodingServiceUnavailableError(AddressValidationError):
    """Geocoding APIが一時的に利用不可(一時的エラー、リトライ可能)。"""


@dataclass(frozen=True, slots=True)
class ShippingAddress:
    """検証対象の配送先住所(不変)。"""
    postal_code: str
    prefecture: str
    city: str
    street: str


@dataclass(frozen=True, slots=True)
class GeocodedAddress:
    """Geocoding APIで正常に解決できた住所情報(不変)。"""
    lat: float
    lng: float
    normalized_address: str


def validate_shipping_address(address: ShippingAddress) -> GeocodedAddress:
    """配送先住所をGeocoding APIで検証し、緯度経度へ変換する。

    Raises:
        InvalidAddressError: 信頼度が閾値未満(恒久的エラー)。
        GeocodingServiceUnavailableError: 通信失敗(一時的エラー)。
    """
    try:
        result = geocode_client.geocode(
            postal_code=address.postal_code, prefecture=address.prefecture,
            city=address.city, street=address.street,
        )
    except GeocodeTimeoutError as exc:  # 修正1: 原因ごとに専用例外へ変換
        raise GeocodingServiceUnavailableError(
            f"タイムアウトしました(postal_code={address.postal_code})"
        ) from exc  # 修正5: from句で原因を保持

    if result.confidence < _MIN_CONFIDENCE_THRESHOLD:  # 修正3: 定数化した閾値
        raise InvalidAddressError(address, confidence=result.confidence)

    return GeocodedAddress(
        lat=result.lat, lng=result.lng, normalized_address=result.normalized_address,
    )


class OrderFulfillmentService:
    """注文確定処理。失敗理由の型に応じて後続処理を分岐する(修正6)。"""

    def process_order(self, order: "Order") -> "ShipmentResult":
        try:
            geo = validate_shipping_address(order.shipping_address)
        except InvalidAddressError:
            return self._reject_for_invalid_address(order)  # 恒久的→差し戻し
        except GeocodingServiceUnavailableError:
            raise  # 一時的→リトライ機構に委ねる

        distance_km = self._calc_distance_from_warehouse(geo.lat, geo.lng)
        if distance_km > 500:
            return self._flag_for_manual_review(order)
        return self._create_shipment(order, distance_km)
address = ShippingAddress(
    postal_code="150-0002", prefecture="東京都", city="渋谷区", street="渋谷1-1-1"
)
geo = validate_shipping_address(address)
print(geo)
# → GeocodedAddress(lat=35.6595, lng=139.7005, normalized_address="東京都渋谷区渋谷1-1-1")

bad_address = ShippingAddress(
    postal_code="000-0000", prefecture="存在しない県", city="", street=""
)
try:
    validate_shipping_address(bad_address)
except InvalidAddressError as exc:
    print(f"差し戻し: {exc}")
# → 差し戻し: 住所の信頼度が閾値を下回りました: confidence=0.12 < 0.5 (postal_code=000-0000)
# (呼び出し元はNoneチェックではなくexcept節で処理を分岐できる)
ポイント適用した設計原則/パターン書籍対応章
InvalidAddressError/GeocodingServiceUnavailableErrorで失敗理由を型として表現例外の握り潰し排除、nullを返さない設計Ch10
raise ... from excで原因例外を保持例外連鎖による障害調査可能性の確保Ch10
_MIN_CONFIDENCE_THRESHOLDの定数化マジックナンバー排除Ch10
GeocodedAddress/ShippingAddressをfrozen=True, slots=True値オブジェクトとしての不変性Ch3
process_orderが例外の型で分岐呼び出し元が失敗理由に応じた対応を選べる設計Ch10/Ch13
# tests/test_address_validation.py
import pytest
from address_validation import (
    GeocodedAddress, InvalidAddressError, ShippingAddress, validate_shipping_address,
)


class TestValidateShippingAddress:
    def test_raises_invalid_address_error_below_threshold(self, monkeypatch):
        monkeypatch.setattr(
            "address_validation.geocode_client.geocode",
            lambda **kw: FakeResult(confidence=0.2, lat=0, lng=0, normalized_address=""),
        )
        address = ShippingAddress("000-0000", "存在しない県", "", "")
        with pytest.raises(InvalidAddressError):
            validate_shipping_address(address)

    def test_wraps_timeout_as_service_unavailable(self, monkeypatch):
        def raise_timeout(**kw):
            raise GeocodeTimeoutError("timeout")
        monkeypatch.setattr("address_validation.geocode_client.geocode", raise_timeout)
        address = ShippingAddress("150-0002", "東京都", "渋谷区", "渋谷1-1-1")
        with pytest.raises(GeocodingServiceUnavailableError):
            validate_shipping_address(address)

    def test_does_not_return_none_on_failure(self, monkeypatch):
        # Noneを返す旧仕様への回帰防止テスト
        monkeypatch.setattr(
            "address_validation.geocode_client.geocode",
            lambda **kw: FakeResult(confidence=0.1, lat=0, lng=0, normalized_address=""),
        )
        address = ShippingAddress("000-0000", "存在しない県", "", "")
        try:
            result = validate_shipping_address(address)
            assert result is not None  # 到達しないはず(例外がraiseされる)
        except InvalidAddressError:
            pass  # 期待通り

問題 B: インフラ — address-validation-service の外部Geocoding API呼び出しの信頼性設計(リトライ判定 × Secret管理 × レート制御)

問題Aで設計した例外階層(InvalidAddressError=恒久的・GeocodingServiceUnavailableError=一時的)を使って、address-validation-service(Cloud Run上で稼働)から外部Geocoding APIを呼び出す通信レイヤーを堅牢化してください。現状は以下の課題を抱えています。

現状の課題:
  • HTTPクライアントにタイムアウトが一切設定されておらず、Geocoding APIが応答しない場合、Cloud Runのリクエストが長時間ハングし続け同時実行枠を圧迫する
  • リトライ処理が恒久的エラー(住所不正=4xx)と一時的エラー(タイムアウト・5xx・クォータ超過=429)を区別せず、両方とも同じリトライループに入れているため、絶対に成功しない「住所不正」のリクエストまで無駄にリトライされている
  • リトライにバックオフ・ジッターが設定されておらず、Geocoding API側が一時的に不調になった際、即時リトライの集中で状況を悪化させている
  • 外部Geocoding APIのAPIキーがCloud Runの環境変数にプレーンテキストで直接設定されており、Cloud Runのサービス設定を閲覧できる人なら誰でもキーを閲覧できる状態になっている
  • CI/CD(GitHub Actions)からCloud Runへのデプロイにサービスアカウントの秘密鍵ファイル(JSON)をGitHub Secretsに保存して使っており、鍵の漏洩・失効管理のリスクを抱えている
  • 外部Geocoding APIにはQPS上限(10 req/秒)があるが、月末の注文集中バッチ処理から住所検証が一斉に呼び出されると上限を超えクォータ超過エラーが多発しており、リクエストを平滑化する仕組みがない
  • リトライ可能エラーと不可能エラーの発生比率を可観測性ツールで区別できておらず、障害発生時に「住所データの品質問題」なのか「Geocoding APIの障害」なのかをダッシュボードから即座に判断できない

要件

#要件
1HTTPクライアント(httpx等)に接続タイムアウト・読み取りタイムアウトを明示的に設定すること
2リトライはGeocodingServiceUnavailableError(一時的エラー)のみを対象とし、InvalidAddressError(恒久的エラー)は即座に呼び出し元へ伝播させること
3リトライに指数バックオフ + ジッターを設定すること
4外部Geocoding APIのAPIキーをSecret Managerに保存し、Cloud RunサービスアカウントへSecret Manager Secret Accessorロールを付与して実行時に取得すること
5GitHub ActionsからのCloud Runデプロイ認証をWorkload Identity Federationに切り替え、サービスアカウントの秘密鍵ファイルを廃止すること
6外部APIのQPS上限(10 req/秒)に対し、Cloud Tasksでキューイング + レート制御し、バッチ処理からの一斉リクエストを平滑化すること
7OpenTelemetryでGeocoding API呼び出しのspanを計装し、リトライ可能エラー/不可能エラーの発生比率をDataDogで監視できるようにすること
8上記変更をTerraform(Secret Manager + Workload Identity Federation + Cloud Tasksキュー)とPython(リトライ判定付きGeocodingクライアント + OTel計装)で実装すること
期待する回答形式: Terraform(Secret Manager・Workload Identity Federation・Cloud Tasksキュー設定一式) + Python(リトライ判定付きGeocodingクライアント + OTel計装) + Bad vs Good 比較表 + 確認コマンド

ヒント B(段階的開示)

ヒント1 — 方向性
外部APIへの依存には「呼び出しが失敗する」だけでなく「なぜ失敗したか」によって取るべき対応が全く違うという特性がある。住所が本当に存在しない(恒久的エラー)のにリトライし続けても永遠に成功しない——これは無駄なコンピュート消費であるだけでなく、Geocoding API側のクォータをさらに消費して他の正常なリクエストの妨げにもなる。カテゴリAで設計した例外階層を「リトライ判定のための型情報」としてそのまま活用できる設計にするのがポイント。加えて、APIキーやデプロイ用の認証情報のような機密情報は「漏れたらどうなるか」を起点に、Secret ManagerやWorkload Identity Federationのような「秘密を平文で持ち回らない」仕組みに寄せる。
ヒント2 — アプローチ
  • httpx.Client(timeout=httpx.Timeout(connect=2.0, read=5.0))のように接続/読み取りタイムアウトを分けて設定
  • tenacity.retry(retry=retry_if_exception_type(GeocodingServiceUnavailableError), wait=wait_exponential_jitter(...), stop=stop_after_attempt(3))で例外の型を条件にリトライ対象を絞り込む
  • google_secret_manager_secret + google_secret_manager_secret_version + google_secret_manager_secret_iam_member(roles/secretmanager.secretAccessor)をTerraformで定義
  • GitHub Actions側はgoogle-github-actions/authアクションでworkload_identity_providerを指定し、JSON鍵を使わない
  • google_cloud_tasks_queueにrate_limits { max_dispatches_per_second = 10 }を設定し、住所検証リクエストをキュー経由で送信する
  • OTelはtracer.start_as_current_span("geocode_api_call")内で例外の型をspan.set_attribute("error.type", ...)のように記録する
ヒント3 — リソースの骨格
リトライ判定付きクライアントの骨格(Python)
@retry(
    retry=retry_if_exception_type(
        GeocodingServiceUnavailableError
    ),  # 恒久的エラーはリトライしない
    wait=wait_exponential_jitter(initial=1, max=10),
    stop=stop_after_attempt(3),
)
def call_geocoding_api(address):
    ...
Cloud Tasksキューの骨格(Terraform)
resource "google_cloud_tasks_queue" "geocoding_queue" {
  name     = "geocoding-validation-queue"
  location = "asia-northeast1"
  rate_limits {
    max_dispatches_per_second = 10
    max_concurrent_dispatches = 20
  }
}

アーキテクチャ図 — 例外階層によるリトライ判定 × Secret管理 × レート制御

Cloud Tasks キュー 修正6: geocoding-validation-queue max_dispatches_per_second=10 バッチ処理の一斉呼び出しを平滑化 (order-fulfillment発の住所検証依頼) address-validation-service 修正1: httpx timeout(connect=2s,read=5s) 修正2: 型で恒久的/一時的エラーを分岐 修正3: wait_exponential_jitter validate_shipping_address() InvalidAddressError / GeocodingService UnavailableError を型で発生させる 外部Geocoding API QPS上限 10 req/秒 4xx=恒久的エラー / 429・5xx=一時的エラー タイムアウト=一時的エラー Secret Manager 修正4: geocoding-api-key roles/secretmanager .secretAccessor リトライ判定(例外の型で分岐) InvalidAddressError(恒久的) 修正2: リトライせず即座に呼び出し元へ GeocodingServiceUnavailableError(一時的) 修正3: 指数バックオフ+ジッターで最大3回 リトライ 修正7: OpenTelemetry計装 → DataDog span: geocode_api_call error.type = timeout / quota_exceeded / server_error / invalid_address / none ダッシュボードで「住所品質問題」か 「API障害」かを比率で即座に判別 修正5: GitHub Actions → Workload Identity Federation google-github-actions/auth (JSON鍵ファイル不要) attribute_condition: repository == 'mops-team/address-validation-service' ✓ 長期鍵ファイルの漏洩リスクを構造的に排除 Before → After Before: 区別なきリトライ・平文キー・レート制御なし タイムアウト未設定 → Cloud Run同時実行枠を圧迫 恒久的/一時的エラーを区別せず両方リトライ バックオフ未設定 → 即時リトライで状況悪化 APIキーが環境変数に平文設定 サービスアカウント鍵ファイルをGitHub Secretsに保存 QPS制御なし → バッチ処理でクォータ超過多発 リトライ可能/不可能エラーの比率が見えない After: 型で分岐するリトライ × 秘密を持ち回らない設計 connect=2s/read=5sのタイムアウト明示設定 InvalidAddressErrorはリトライせず即伝播 wait_exponential_jitterで指数バックオフ+ジッター Secret Manager + Secret Accessorロールで取得 Workload Identity Federationで鍵ファイル廃止 Cloud Tasksで10req/秒に平滑化 OTel spanのerror.type別にDataDogで監視

模範解答 B

# 修正4: Geocoding APIキーをSecret Managerで管理
resource "google_secret_manager_secret" "geocoding_api_key" {
  secret_id = "geocoding-api-key"
  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_version" "geocoding_api_key_v1" {
  secret      = google_secret_manager_secret.geocoding_api_key.id
  secret_data = var.geocoding_api_key  # tfvarsやCI/CDのSecretから注入、平文でコミットしない
}

resource "google_secret_manager_secret_iam_member" "geocoding_api_key_accessor" {
  secret_id = google_secret_manager_secret.geocoding_api_key.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.address_validation_sa.email}"
}

# 修正5: GitHub ActionsからのデプロイをWorkload Identity Federationに切り替え
resource "google_iam_workload_identity_pool" "github_pool" {
  workload_identity_pool_id = "github-actions-pool"
}

resource "google_iam_workload_identity_pool_provider" "github_provider" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-actions-provider"
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }
  attribute_condition = "assertion.repository == 'mops-team/address-validation-service'"
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

resource "google_service_account_iam_member" "github_deployer" {
  service_account_id = google_service_account.deployer_sa.name
  role                = "roles/iam.workloadIdentityUser"
  member              = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_pool.name}/attribute.repository/mops-team/address-validation-service"
}

# 修正6: 外部APIのQPS上限(10req/秒)に対しCloud Tasksでキューイング+レート制御
resource "google_cloud_tasks_queue" "geocoding_queue" {
  name     = "geocoding-validation-queue"
  location = "asia-northeast1"

  rate_limits {
    max_dispatches_per_second = 10   # Geocoding APIのQPS上限に合わせる
    max_concurrent_dispatches = 20
  }

  retry_config {
    max_attempts = 3
    min_backoff  = "1s"
    max_backoff  = "10s"
  }
}
"""geocoding_client.py — リトライ判定(修正2) × タイムアウト(修正1) × OTel計装(修正7)"""
from __future__ import annotations

import httpx
from opentelemetry import trace
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential_jitter,
)

from address_validation import (
    GeocodedAddress,
    GeocodingServiceUnavailableError,
    InvalidAddressError,
    ShippingAddress,
)

tracer = trace.get_tracer(__name__)

# 修正1: 接続/読み取りタイムアウトを明示的に分けて設定
_HTTP_TIMEOUT = httpx.Timeout(connect=2.0, read=5.0, write=2.0, pool=2.0)
_http_client = httpx.Client(timeout=_HTTP_TIMEOUT)


@retry(
    # 修正2: 一時的エラー(GeocodingServiceUnavailableError)のみリトライ対象にする。
    # InvalidAddressError(恒久的エラー)はリトライせず即座に呼び出し元へ伝播する。
    retry=retry_if_exception_type(GeocodingServiceUnavailableError),
    wait=wait_exponential_jitter(initial=1, max=10),  # 修正3: 指数バックオフ+ジッター
    stop=stop_after_attempt(3),
    reraise=True,
)
def call_geocoding_api(address: ShippingAddress, api_key: str) -> GeocodedAddress:
    """外部Geocoding APIを呼び出し、住所を緯度経度へ変換する。

    Raises:
        InvalidAddressError: 住所自体が不正な場合(リトライしない)。
        GeocodingServiceUnavailableError: 3回リトライしても解決しなかった場合。
    """
    with tracer.start_as_current_span("geocode_api_call") as span:  # 修正7
        span.set_attribute("address.postal_code", address.postal_code)
        try:
            response = _http_client.get(
                "https://geocoding.example.com/v1/lookup",
                params={
                    "postal_code": address.postal_code,
                    "prefecture": address.prefecture,
                    "city": address.city,
                    "street": address.street,
                },
                headers={"Authorization": f"Bearer {api_key}"},
            )
        except httpx.TimeoutException as exc:
            span.set_attribute("error.type", "timeout")  # 修正7: エラー種別を計装
            raise GeocodingServiceUnavailableError("タイムアウト") from exc

        if response.status_code == 429:
            span.set_attribute("error.type", "quota_exceeded")
            raise GeocodingServiceUnavailableError("クォータ超過") from None
        if response.status_code >= 500:
            span.set_attribute("error.type", "server_error")
            raise GeocodingServiceUnavailableError(
                f"Geocoding APIの内部エラー: {response.status_code}"
            ) from None
        if response.status_code >= 400:
            span.set_attribute("error.type", "invalid_address")
            raise InvalidAddressError(address, confidence=0.0) from None

        payload = response.json()
        span.set_attribute("error.type", "none")
        return GeocodedAddress(
            lat=payload["lat"],
            lng=payload["lng"],
            normalized_address=payload["normalized_address"],
        )

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
タイムアウト未設定、Cloud Run同時実行枠がハングし続けるconnect=2.0s/read=5.0s を明示設定
リトライ判定恒久的/一時的エラーを区別せず両方リトライGeocodingServiceUnavailableErrorのみリトライ、InvalidAddressErrorは即伝播
バックオフ未設定(即時リトライに近い)指数バックオフ+ジッター(1s〜10s)
APIキー管理環境変数にプレーンテキストSecret Manager + Secret Accessorロール
デプロイ認証サービスアカウント秘密鍵(JSON)をGitHub Secretsに保存Workload Identity Federation(鍵ファイルなし)
レート制御未設定、バッチ処理でQPS上限超過が多発Cloud Tasksでキューイング(10 req/秒に平滑化)
可観測性リトライ可能/不可能エラーの比率を区別できないOTel spanでerror.type別に計装、DataDogで比率監視

確認コマンド

# 1. Secret ManagerにAPIキーが登録されていることを確認
gcloud secrets versions list geocoding-api-key --format="value(name,state)"
# Expected: 1  ENABLED

# 2. Cloud RunサービスアカウントがSecret Accessorロールを持つことを確認
gcloud secrets get-iam-policy geocoding-api-key \
  --format="value(bindings.role)" --flatten="bindings[]"
# Expected: roles/secretmanager.secretAccessor が含まれる

# 3. Workload Identity Federationでの認証が機能することを確認(GitHub Actions実行ログ)
gh run view --log | grep "Successfully authenticated"
# Expected: Successfully authenticated via Workload Identity Federation

# 4. Cloud Tasksキューのレート制限を確認
gcloud tasks queues describe geocoding-validation-queue --location=asia-northeast1 \
  --format="value(rateLimits.maxDispatchesPerSecond)"
# Expected: 10.0

# 5. 恒久的エラーがリトライされないことを確認(ユニットテスト)
python -c "
from geocoding_client import call_geocoding_api
from address_validation import InvalidAddressError, ShippingAddress
try:
    call_geocoding_api(ShippingAddress('000-0000','存在しない県','',''), api_key='dummy')
except InvalidAddressError:
    print('リトライされず即座に伝播: OK')
"

ポイント解説

カテゴリ A

1 except Exceptionのような広範囲キャッチは「原因を握りつぶす」ことと同義(Ch10)
例外は種類ごとに専用のクラスへ変換してraiseし、原因情報(from exc)を保持したまま呼び出し元へ伝えるべきである。
2 失敗をNoneで表現すると、呼び出し元が「Noneかもしれない」ことを忘れた瞬間に本番障害になる(Ch10)
Pythonの型システムはOptionalを無視して実行できてしまうため、「例外をraiseする」方が「呼び出し元にNoneチェックを強制できない」という弱点を構造的に解消できる。
3 「恒久的エラー」と「一時的エラー」を型で区別すると、呼び出し元のコードが自然と正しい分岐になる(Ch10/Ch13)
except InvalidAddressError / except GeocodingServiceUnavailableErrorと書くだけで、リトライすべきか差し戻すべきかが型レベルで強制される。

カテゴリ B

4 リトライは「型で区別した一時的エラー」だけに絞り込む(修正2)
住所が存在しないという恒久的な問題を何度リトライしても結果は変わらず、無駄なコンピュートとAPIクォータを消費するだけである。
5 タイムアウトを明示的に設定しないと、外部依存の不調がそのまま自サービスのリソース枯渇に伝播する(修正1)
Cloud Runの同時実行枠は有限であり、応答のないリクエストが枠を埋め続けると無関係な他のリクエストまで巻き添えになる。
6 APIキーやデプロイ認証情報のような機密情報は「漏れた場合の被害」を起点に管理方式を選ぶ(修正4・修正5)
Secret Managerはアクセスログ・ローテーション・IAMベースの権限管理が可能であり、Workload Identity Federationは長期的な鍵ファイルそのものを排除できる。
7 外部APIのレート制限は「呼び出し側で制御する」設計にしないと、バッチ処理のようなバースト的な呼び出しパターンで容易に超過する(修正6)
Cloud Tasksのようなキューイング層を挟むことで、呼び出し元の実装を変えずにレート制御を一元化できる。

実務への応用

  • 例外階層による「恒久的エラー vs 一時的エラー」の型分離は、外部APIに依存する処理全般(決済ゲートウェイ、配送業者API、在庫連携API等)に横展開できる: 「このエラーはリトライしていいか」を型で判断できる設計にしておくと、呼び出し元ごとにリトライロジックを個別実装せずに済む
  • except Exception: return Noneのようなパターンをコードレビューの必須チェック項目に加えると、本番障害の予防に直結する: 特に外部API連携・ファイルI/O・DBアクセスのラッパー関数でこのパターンが紛れ込みやすい
  • Secret Manager + Workload Identity Federationの組み合わせは、外部SaaS APIキー・DB接続情報・CI/CDのデプロイ認証など、MOpsチームが扱う機密情報全般の標準パターンとして再利用できる: 「サービスアカウント鍵ファイルをリポジトリやCI/CDのSecretsに置かない」という方針を横展開する際の具体的な実装として使える
  • Cloud Tasksによるレート制御は、外部APIのクォータ制限がある連携(決済API、通知API、配送業者APIなど)全般に適用でき、バッチ処理の一斉実行によるクォータ超過を構造的に防止できる

今日のまとめ

カテゴリAでは、except Exception: return Noneによって性質の異なる例外を一律に握りつぶしていた住所検証処理を、「恒久的エラー(InvalidAddressError)」と「一時的エラー(GeocodingServiceUnavailableError)」を区別する例外階層に置き換え、nullではなく型で失敗を表現する設計にした(第10章)。

カテゴリBでは、その例外階層をそのままリトライ判定の根拠として使い、Geocoding API呼び出しをタイムアウト・バックオフ・Secret Manager・Workload Identity Federation・Cloud Tasksによるレート制御・OTel計装という7つの観点で堅牢化した。両者に共通するのは「失敗には複数の種類があり、それを一つに潰した瞬間、呼び出し元は正しい判断ができなくなる」という視点である。

次のステップ

  • 発展問題: GeocodedAddressのキャッシュ層を追加し、同一住所への再検証をGeocoding API呼び出しなしで返せるようにする設計を、キャッシュの有効期限(住所は稀に変わる可能性がある)を考慮しながら提案せよ
  • 発展問題: Cloud Tasksのキューが詰まり(バックログが一定件数を超えた場合)、注文処理全体のSLAに影響が出始めたとする。キューの優先度分離(通常注文 vs VIP顧客の即日配送)をどう設計するか検討せよ
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第10章(設計の悪魔)、Google Cloud公式ドキュメント「Secret Manager のベストプラクティス」「Workload Identity Federation for GitHub Actions」「Cloud Tasks のレート制限」

自己評価(あとで記入)

自分の回答

気づき・メモ