概要
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・インラインコメント・型ヒントを含めること
悪いコード (Before) — カテゴリ A
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)
ヒント 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
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | except Exceptionで性質の違う例外を一律に握りつぶす | 例外握り潰し Ch10 | 原因ごとに専用の例外クラスへ変換 |
| 2 | 失敗をNoneで表現、呼び出し元のチェック漏れで本番障害 | null問題 Ch10 | 例外を型として表現し発生を強制 |
| 3 | confidence閾値0.5がマジックナンバー | Ch10 | 名前付き定数化 |
| 4 | 恒久的/一時的エラーを区別できない | 責務分離 Ch10 | 例外階層で型分離 |
| 5 | 元の例外情報がログに残らない | 可観測性 | raise ... from excで連鎖保持 |
| 6 | process_orderがNoneチェックなしで属性アクセス | 実装バグ | except節での型別分岐 |
| 7 | 戻り値の型ヒントがなくOptionalすらない | 型安全性 | 明示的な戻り値型と例外契約 |
模範解答 A
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なら例外発生
)
"""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の障害」なのかをダッシュボードから即座に判断できない
要件
| # | 要件 |
|---|---|
| 1 | HTTPクライアント(httpx等)に接続タイムアウト・読み取りタイムアウトを明示的に設定すること |
| 2 | リトライはGeocodingServiceUnavailableError(一時的エラー)のみを対象とし、InvalidAddressError(恒久的エラー)は即座に呼び出し元へ伝播させること |
| 3 | リトライに指数バックオフ + ジッターを設定すること |
| 4 | 外部Geocoding APIのAPIキーをSecret Managerに保存し、Cloud RunサービスアカウントへSecret Manager Secret Accessorロールを付与して実行時に取得すること |
| 5 | GitHub ActionsからのCloud Runデプロイ認証をWorkload Identity Federationに切り替え、サービスアカウントの秘密鍵ファイルを廃止すること |
| 6 | 外部APIのQPS上限(10 req/秒)に対し、Cloud Tasksでキューイング + レート制御し、バッチ処理からの一斉リクエストを平滑化すること |
| 7 | OpenTelemetryでGeocoding API呼び出しのspanを計装し、リトライ可能エラー/不可能エラーの発生比率をDataDogで監視できるようにすること |
| 8 | 上記変更をTerraform(Secret Manager + Workload Identity Federation + Cloud Tasksキュー)とPython(リトライ判定付きGeocodingクライアント + OTel計装)で実装すること |
ヒント B(段階的開示)
ヒント1 — 方向性
ヒント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 — リソースの骨格
@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):
...
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管理 × レート制御
模範解答 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
except Exceptionのような広範囲キャッチは「原因を握りつぶす」ことと同義(Ch10)例外は種類ごとに専用のクラスへ変換して
raiseし、原因情報(from exc)を保持したまま呼び出し元へ伝えるべきである。
Noneで表現すると、呼び出し元が「Noneかもしれない」ことを忘れた瞬間に本番障害になる(Ch10)Pythonの型システムは
Optionalを無視して実行できてしまうため、「例外をraiseする」方が「呼び出し元にNoneチェックを強制できない」という弱点を構造的に解消できる。
except InvalidAddressError / except GeocodingServiceUnavailableErrorと書くだけで、リトライすべきか差し戻すべきかが型レベルで強制される。
カテゴリ B
住所が存在しないという恒久的な問題を何度リトライしても結果は変わらず、無駄なコンピュートとAPIクォータを消費するだけである。
Cloud Runの同時実行枠は有限であり、応答のないリクエストが枠を埋め続けると無関係な他のリクエストまで巻き添えになる。
Secret Managerはアクセスログ・ローテーション・IAMベースの権限管理が可能であり、Workload Identity Federationは長期的な鍵ファイルそのものを排除できる。
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など)全般に適用でき、バッチ処理の一斉実行によるクォータ超過を構造的に防止できる
今日のまとめ
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 のレート制限」