FastAPIで実践するサーキットブレーカとフォールバック設計入門:外部API障害を全体障害にしないための実務パターン
要約
- サーキットブレーカは、失敗が続く外部依存先への呼び出しを一時的に止めて、自分のAPI全体が巻き込まれて遅くなったり落ちたりするのを防ぐための設計パターンです。Martin Fowler は、一定回数の失敗でブレーカを開き、以後の呼び出しを即座に失敗させる基本形を説明しています。
- FastAPIは非同期I/Oに強く、外部API連携と相性が良い一方で、遅い依存先に引きずられるとイベントループやワーカー全体の体感性能が悪化しやすいため、タイムアウト・再試行・接続プール・障害分離をセットで考えることが大切です。
- HTTPX には
AsyncClientの共有、Timeout、Limits、接続リトライ向け transport などの機能があり、外部APIクライアントの基盤として扱いやすいです。タイムアウトはconnect、read、write、poolに分かれ、接続数も明示的に制御できます。 - Pythonでは PyBreaker のような実装があり、Circuit Breaker パターンをライブラリとして扱えます。PyBreaker は自らを「Circuit Breaker pattern の Python 実装」と説明しています。
- フォールバックは、障害時に「単に失敗する」のではなく、キャッシュ済みデータを返す、機能を縮退させる、後で再試行させるといった代替経路を用意する考え方です。サーキットブレーカ単体より、フォールバックと監視を組み合わせた方が、ユーザー体験と運用の両面で安定しやすくなります。
誰が読んで得をするか
個人開発・学習者さん
外部APIを1つか2つ使い始めて、「たまに遅い」「たまに失敗する」状態に困っている方に向いています。
特に、FastAPIの中で httpx.get() や AsyncClient.get() をそのまま呼んでいて、外部サービスの調子が悪いと自分のAPIまで重くなる経験をした方には役立ちます。FastAPIは非同期コードに適しており、外部I/Oに強い設計がしやすい一方で、外部依存の遅延を吸収する仕組みを入れないと、その強みが活かしきれません。
小規模チームのバックエンドエンジニアさん
配送、決済、通知、認証基盤など、複数の外部サービスをFastAPIから呼んでいるチームに向いています。
「HTTPクライアントは作ったが、どこまで再試行すべきか」「障害時に全部500で落とすしかなくてつらい」「タイムアウトと接続数が人によって違う」といった問題を、サーキットブレーカとフォールバックの観点から整理できます。HTTPXは AsyncClient、タイムアウト、接続数制御、transport の調整などを提供しており、クライアント層の共通化と相性が良いです。
SaaS開発チーム・スタートアップの皆さま
複数の外部APIの不調が、自社プロダクトのSLOや問い合わせ件数に直結しているチームに向いています。
「一部の外部APIが不調でも、全体障害にしたくない」「縮退運転やキャッシュ返却で生き延びたい」「障害時の可観測性も含めて整えたい」という段階では、サーキットブレーカとフォールバックを認可・監査・ジョブキューと同じくらい重要な基盤として扱う価値があります。Circuit Breaker は、失敗を局所化するための代表的パターンとして広く知られており、マイクロサービス文脈でも重要なパターンとして言及されています。
アクセシビリティ評価
- 最初に要約を置き、その後に「なぜ必要か」「どう設計するか」「どう実装するか」の順で段階的に説明しています。
- 専門用語は初出で短く意味を添え、その後は同じ言葉を使って流れを追いやすくしています。
- コード例は短めに分割し、1つのブロックで1つの責務だけを示しています。
- 章ごとに独立して読めるように、必要な前提をその場で補っています。
- 目標レベルはAA相当です。
1. サーキットブレーカとは何か
サーキットブレーカは、外部依存先の失敗が続いたときに、その依存先への呼び出しを一時的に止める仕組みです。
Martin Fowler は、保護対象の関数呼び出しをブレーカオブジェクトで包み、失敗回数がしきい値に達すると回路を開き、それ以降の呼び出しを実行せずに即座にエラーとして返す、という基本形を説明しています。通常は、その状態を監視・通知する仕組みも一緒に考えられます。
これを電気のブレーカにたとえると分かりやすいです。
どこかがショートしかけたときに、全部が燃えるまで我慢するのではなく、一度遮断して被害を広げないようにします。アプリケーションでも同じで、外部APIが数十秒遅い、5xxを返し続ける、接続が詰まる、といった状態で呼び出し続けると、自分のFastAPIアプリ全体が巻き込まれやすくなります。Circuit Breaker は、まさにその連鎖を断ち切るためのパターンです。
2. FastAPIで特に重要になる理由
FastAPIは async def と非同期I/Oを前提にしたフレームワークで、外部APIやDBのようなI/O待ちに強い構造です。
その一方で、外部APIの待ち時間が長かったり、失敗が連発したりすると、イベントループ上で待ち続けるタスクが増え、接続プールやアプリ全体の体感性能に影響しやすくなります。FastAPI公式は非同期処理がI/Oバウンドな作業に向いていることを説明しており、まさに外部API呼び出しはその代表です。
また、FastAPIの lifespan はアプリ起動時・終了時のリソース管理に向いており、共有のHTTPクライアントを作って閉じる設計と相性が良いです。依存関係システムも、外部APIクライアントを共通の依存として組み立てるのに向いています。つまりFastAPIは、サーキットブレーカやフォールバックを「きれいに置ける土台」がすでに揃っているのです。
3. 先に押さえたい全体像:タイムアウト、再試行、ブレーカ、フォールバック
サーキットブレーカだけを入れても、外部API障害への耐性は十分ではありません。
実務では、次の4つをセットで考えると整理しやすいです。
- タイムアウト
- そもそもどれくらい待つかを決める
- 再試行
- 一時的な失敗なら少しだけやり直す
- サーキットブレーカ
- 失敗が続く相手には一時的に呼ばない
- フォールバック
- 呼べないときに何を返すかを決める
HTTPXはタイムアウトや接続数制御、接続リトライ向け transport を提供しており、Tenacity は指数バックオフを含む再試行設計に向いています。PyBreaker は Circuit Breaker パターンの Python 実装として使えます。つまり、FastAPIの周辺ツールはかなり揃っています。
4. まず土台を作る:共有 AsyncClient を lifespan で持つ
サーキットブレーカ以前に、外部APIクライアントの基盤を共有化しておくと後から拡張しやすいです。
HTTPXは非同期環境で AsyncClient を使い、クライアントインスタンスを再利用することを勧めています。特に、ホットループ内で何度もクライアントを作らない方がよいと案内しています。
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=1.0)
limits = httpx.Limits(
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=5.0,
)
app.state.http_client = httpx.AsyncClient(
timeout=timeout,
limits=limits,
)
try:
yield
finally:
await app.state.http_client.aclose()
app = FastAPI(lifespan=lifespan)
ここで使っている Timeout と Limits は、どちらもHTTPXが公式に提供している機能です。タイムアウトは connect、read、write、pool に分かれ、接続制限は max_connections などで調整できます。
5. 依存関数で共通クライアントを注入する
FastAPIの依存関数を使うと、共有 AsyncClient を各クライアントクラスへ自然に渡せます。
FastAPI公式は依存関係システムを「強力で直感的」と説明しており、コンポーネントの再利用やテスト容易性とも相性が良いです。
import httpx
from fastapi import Request
def get_http_client(request: Request) -> httpx.AsyncClient:
return request.app.state.http_client
この上に、特定の外部サービス用クラスを作ります。
import httpx
class BillingClient:
def __init__(self, client: httpx.AsyncClient, base_url: str, api_key: str):
self.client = client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
こうしておくと、サーキットブレーカやフォールバックの実装をクライアント層へ閉じ込められます。
ルーターやサービス層に httpx の詳細を散らさずに済むので、後から設計を改善しやすくなります。
6. タイムアウトを明示する:ブレーカ以前の最低限の防御
HTTPXでは、デフォルトでもタイムアウトが有効ですが、実務では明示しておく方が安全です。
公式ドキュメントでは、connect、read、write、pool の4種類が説明されており、それぞれ意味が違います。たとえば pool タイムアウトは、接続プールから利用可能な接続を待つ時間です。
import httpx
DEFAULT_TIMEOUT = httpx.Timeout(
connect=1.5,
read=3.0,
write=3.0,
pool=0.5,
)
大切なのは、「外部APIごとに待てる時間は違う」と意識することです。
たとえば社内の高速APIならもっと短くてよいですし、生成AIや重い帳票APIなら少し長めに取ることもあります。ただ、何も決めずに長く待つ設計は、サーキットブレーカ以前に危険です。遅い相手を延々と待つと、自分のアプリまで遅くなってしまうからです。
7. HTTPXの例外を自前例外へ変換する
HTTPXには RequestError、HTTPStatusError、TimeoutException などの整理された例外階層があります。
これをそのまま上位へ漏らすより、アプリ独自の例外へ変換して扱いを揃える方が、サーキットブレーカやフォールバックの条件を書きやすくなります。HTTPXのクイックスタートや例外ドキュメントでも、raise_for_status() と例外の扱い方が示されています。
class ExternalAPIError(Exception):
pass
class ExternalAPITimeoutError(ExternalAPIError):
pass
class ExternalAPIUnavailableError(ExternalAPIError):
pass
class ExternalAPIBadResponseError(ExternalAPIError):
pass
import httpx
async def safe_request(client: httpx.AsyncClient, method: str, url: str, **kwargs) -> httpx.Response:
try:
response = await client.request(method, url, **kwargs)
response.raise_for_status()
return response
except httpx.TimeoutException as exc:
raise ExternalAPITimeoutError(str(exc)) from exc
except httpx.HTTPStatusError as exc:
raise ExternalAPIBadResponseError(str(exc)) from exc
except httpx.RequestError as exc:
raise ExternalAPIUnavailableError(str(exc)) from exc
この段階で例外が整理されていれば、「タイムアウト何回でブレーカを開くか」「4xxではブレーカを開かない」といった判断がかなり書きやすくなります。
8. サーキットブレーカの状態:Closed / Open / Half-Open を理解する
サーキットブレーカは、概ね次の3状態で考えると整理しやすいです。
これは多くの実装や解説で共有される基本形で、Martin Fowler の説明とも整合します。
- Closed
- 通常状態。外部APIを呼ぶ
- Open
- 失敗が続いたので遮断状態。呼ばずに即失敗させる
- Half-Open
- 復旧確認のため、少数の試行だけ許可する
この3状態があるおかげで、
「失敗したから永遠に止める」でもなく、
「毎回フルタイムアウトまで待つ」でもない、
ちょうどよいバランスが作れます。
9. PyBreaker を使った最小実装の考え方
PyBreaker は、自身を「Circuit Breaker pattern の Python 実装」と説明しています。
また、Redis を使った状態保存などにも対応することが、PyPIの説明や一般的な利用例から分かります。
概念的には、次のような形で危険な呼び出しをブレーカで包みます。
import pybreaker
billing_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=30,
)
ただし、ここで注意したいのは、PyBreakerはもともと同期的な呼び出しの文脈でよく使われることです。
FastAPI+HTTPXの非同期呼び出しにそのままどう載せるかは、採用ライブラリや周辺実装を含めて少し設計が必要です。そこで実務では、次のどちらかの方針が取りやすいです。
- 同期ラッパや別実装でブレーカ状態だけ管理する
- まずは「簡易ブレーカ」を自前で実装して、後から差し替えやすくする
この記事では、FastAPIで理解しやすいように、まずは後者の簡易ブレーカ設計を示します。
10. FastAPI向けの簡易サーキットブレーカを自前で考える
小さく始めるなら、状態管理を最低限にした簡易ブレーカでも十分役立ちます。
考え方は次のとおりです。
- 失敗回数を数える
- しきい値を超えたら一定時間 Open にする
- Open 中は即失敗
- Open 期間が終わったら試験的に1回だけ通す
- その試行が成功したら Closed に戻す
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass
class SimpleCircuitBreaker:
fail_max: int
reset_timeout_sec: int
failure_count: int = 0
opened_at: datetime | None = None
def is_open(self) -> bool:
if self.opened_at is None:
return False
now = datetime.now(timezone.utc)
return now < self.opened_at + timedelta(seconds=self.reset_timeout_sec)
def allow_request(self) -> bool:
return not self.is_open()
def record_success(self) -> None:
self.failure_count = 0
self.opened_at = None
def record_failure(self) -> None:
self.failure_count += 1
if self.failure_count >= self.fail_max:
self.opened_at = datetime.now(timezone.utc)
これは本番の完成形ではありませんが、サーキットブレーカの基本動作を理解するには十分です。
少なくとも、「失敗し続ける相手へ何度も本気でぶつからない」という発想が入るだけで、全体障害をかなり防ぎやすくなります。
11. クライアント層へブレーカを組み込む
次に、外部APIクライアントの中で簡易ブレーカを使います。
class CircuitOpenError(Exception):
pass
class ShippingClient:
def __init__(self, client, base_url: str, api_key: str, breaker: SimpleCircuitBreaker):
self.client = client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.breaker = breaker
async def get_quote(self, payload: dict) -> dict:
if not self.breaker.allow_request():
raise CircuitOpenError("shipping circuit is open")
try:
response = await safe_request(
self.client,
"POST",
f"{self.base_url}/quotes",
json=payload,
headers={"Authorization": f"Bearer {self.api_key}"},
)
self.breaker.record_success()
return response.json()
except (ExternalAPITimeoutError, ExternalAPIUnavailableError):
self.breaker.record_failure()
raise
この形にしておくと、
「どの例外を失敗として数えるか」
「HTTP 4xx は失敗に含めるか」
といった判断をクライアント層で閉じ込められます。
たとえば利用者の入力ミスで 400 が返るなら、それは依存先の障害ではありません。
ですので、HTTPStatusError を全部ブレーカ失敗扱いにしない方が自然なケースも多いです。
12. フォールバックとは何か:失敗時に“代わりに何を返すか”を決める
サーキットブレーカは「呼ばない」という防御ですが、フォールバックは「呼べないときにどう振る舞うか」を決める考え方です。
たとえば次のような選択肢があります。
- 最後に成功したキャッシュを返す
- 縮退版のレスポンスを返す
- 一部の情報を省略してでも画面を成立させる
- 「今は利用不可」と明示しつつ、後で再試行できる導線を出す
- 同期呼び出しをやめてジョブ投入へ切り替える
Martin Fowler が紹介する Circuit Breaker パターンも、単に遮断するだけでなく、監視や周辺の運用と組み合わせることが前提です。マイクロサービス文脈でも、Timeout や Bulkhead などと一緒に語られることが多く、フォールバックはその現場的な実装に近い発想です。
13. フォールバックの実装例1:キャッシュ済みレスポンスを返す
もっとも実務で使いやすいフォールバックの一つが、直近の成功結果を一定時間だけ返す方法です。
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass
class CacheEntry:
value: dict
expires_at: datetime
class SimpleResponseCache:
def __init__(self):
self.data: dict[str, CacheEntry] = {}
def get(self, key: str) -> dict | None:
entry = self.data.get(key)
if not entry:
return None
if datetime.now(timezone.utc) > entry.expires_at:
return None
return entry.value
def set(self, key: str, value: dict, ttl_sec: int) -> None:
self.data[key] = CacheEntry(
value=value,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl_sec),
)
class ShippingClient:
def __init__(self, client, base_url: str, api_key: str, breaker: SimpleCircuitBreaker, cache: SimpleResponseCache):
self.client = client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.breaker = breaker
self.cache = cache
async def get_quote_with_fallback(self, payload: dict) -> dict:
cache_key = f"quote:{payload.get('zip')}:{payload.get('weight')}"
if not self.breaker.allow_request():
cached = self.cache.get(cache_key)
if cached is not None:
return {"source": "cache", "data": cached}
raise CircuitOpenError("shipping circuit is open")
try:
response = await safe_request(
self.client,
"POST",
f"{self.base_url}/quotes",
json=payload,
headers={"Authorization": f"Bearer {self.api_key}"},
)
data = response.json()
self.cache.set(cache_key, data, ttl_sec=60)
self.breaker.record_success()
return {"source": "live", "data": data}
except (ExternalAPITimeoutError, ExternalAPIUnavailableError):
self.breaker.record_failure()
cached = self.cache.get(cache_key)
if cached is not None:
return {"source": "cache", "data": cached}
raise
この形だと、配送見積もりAPIが一時的に不調でも、直近の成功結果があれば画面を成立させやすくなります。
ただし、古い情報を返してよい場面かどうかは必ず見極める必要があります。
14. フォールバックの実装例2:同期処理をやめてジョブへ逃がす
キャッシュ返却ができない種類の外部APIもあります。
たとえば「帳票生成」「重い外部集計」「大きなファイル変換」などは、その場で完了しないことがあります。
そういうときは、フォールバックとして同期完了を諦め、非同期ジョブへ切り替えるという発想が使えます。
- 通常時
- リクエスト中に外部APIへ問い合わせて結果を返す
- 障害時
- 「処理を受け付けました」と返し、後でジョブで再実行する
これはUXとしても完全失敗より優しいですし、依存先の不調時に自分のAPIを守りやすくなります。
FastAPIには BackgroundTasks があり、レスポンス後に実行する軽い後処理に使えます。より重い処理はジョブキューへ渡すとよいです。
15. 再試行との関係:ブレーカを入れてもリトライはゼロにならない
サーキットブレーカを入れたからといって、再試行が不要になるわけではありません。
むしろ現実には、次のような役割分担になります。
- 再試行
- 一時的な揺らぎを吸収する
- サーキットブレーカ
- 失敗し続ける相手を呼ばない
- フォールバック
- 呼べないときに何を返すかを決める
HTTPXの transport レイヤーでは、接続リトライが ConnectError と ConnectTimeout に対して使えます。一方で、より広い再試行や指数バックオフには Tenacity が向いています。
たとえば設計の感覚としては、こんなふうに分けやすいです。
- 最初にHTTPXの接続リトライを1回だけ
- それでもダメなら Tenacity で限定的な再試行
- それでも失敗が続くならブレーカを開く
- ブレーカが開いている間はフォールバックへ流す
こうすると、いきなり極端にせず、少しずつ守りを積み上げられます。
16. 監視とメトリクス:ブレーカは“開いていること”が見えないと怖い
サーキットブレーカは便利ですが、開いていても誰も気づかないと逆に危険です。
最低限、次のような指標は見えるようにしたいところです。
- 外部APIごとの成功率
- タイムアウト率
- 再試行回数
- ブレーカのOpen回数
- フォールバック発生回数
- キャッシュ返却率
Martin Fowler の説明でも、ブレーカが開いたら監視や通知が欲しいとされています。つまり、サーキットブレーカは「入れたら終わり」ではなく、観測とセットの仕組みです。
FastAPI側では、以前の記事で扱った構造化ログやメトリクスを活用して、circuit_state="open" や fallback="cache" のような情報を残すと、後から非常に読みやすくなります。
17. ログ設計:ブレーカの状態遷移を残す
ログには、外部APIエラーそのものだけでなく、ブレーカ状態の変化も残せると便利です。
import logging
logger = logging.getLogger("circuit_breaker")
def log_breaker_open(name: str) -> None:
logger.warning("circuit opened", extra={"circuit": name})
def log_breaker_closed(name: str) -> None:
logger.info("circuit closed", extra={"circuit": name})
Open したこと、Half-Open から復帰したこと、フォールバックへ流したことが見えるだけで、
「なぜユーザーに縮退レスポンスが返ったのか」が追いやすくなります。
18. テスト戦略:最低限守りたいケース
サーキットブレーカやフォールバックは、通常系だけ見ていると壊れやすいです。
まずは次のようなケースをテストすると安心です。
- タイムアウトが続くとブレーカがOpenになる
- Open 中は外部APIを実際には呼ばずに即失敗またはフォールバックする
- フォールバック用キャッシュがあるときはそれを返す
- 復旧後の試験呼び出しが成功したらClosedに戻る
- 4xx のような利用者由来エラーではブレーカを開かない
ポリシー関数に近い部分、たとえば record_failure() や allow_request() のようなロジックは、純粋関数・小さなクラスとして切り出しておくとかなりテストしやすくなります。
19. よくある失敗パターン
19.1 タイムアウトなしでブレーカだけ入れる
そもそも一回の失敗判定が遅すぎると、ブレーカが効き始める前に全体が重くなります。HTTPXのタイムアウトは最初に整える方が安全です。
19.2 4xx も 5xx も全部“失敗”として数える
入力ミスや認可エラーまでブレーカ対象にすると、依存先が健全でもOpenしやすくなります。
「本当に依存先の不調と言えるエラーだけ」を数える方が自然です。
19.3 フォールバックが雑で、古い情報を出しすぎる
キャッシュ返却は強力ですが、何でも古い値でよいわけではありません。
決済状態や在庫のような鮮度が重要なデータでは慎重さが必要です。
19.4 ブレーカが開いても誰も気づかない
通知やメトリクスがないと、「なんとなく最近機能が縮退していた」状態が長引きやすくなります。Fowler もブレーカの監視を重要要素として挙げています。
19.5 クライアントごとにバラバラのルールを持つ
配送APIだけ、決済APIだけ、通知APIだけ、すべて違うやり方になると、チーム運用がつらくなります。
例外はあっても、「基本の型」は揃えておく方が後から楽です。
20. 読者別ロードマップ
個人開発・学習者さん
- まずは共有
AsyncClient、タイムアウト、接続数制御を入れる - 外部API例外を自前例外へ変換する
- 小さな簡易ブレーカを1つだけ導入する
- キャッシュフォールバックを1箇所だけ試す
- その結果をログに残す
小規模チームのエンジニアさん
- 外部APIごとの重要度と鮮度要件を棚卸しする
- 共通クライアント層を作る
- タイムアウト、再試行、ブレーカ条件をチームで揃える
- Open回数、フォールバック回数をメトリクス化する
- 書き込み系には冪等性やジョブ化も含めて見直す
SaaS開発チーム・スタートアップの皆さま
- 依存先ごとに「完全停止」「縮退」「キャッシュ返却」の方針を定義する
- クライアント層へサーキットブレーカとフォールバックを集約する
- 監査ログ・アラート・ダッシュボードを整える
- 障害訓練やカオステストに近い形で、依存先停止時の挙動を確認する
- 必要に応じて Redis などを使ったブレーカ状態共有や、より本格的な実装へ進める
参考リンク
-
FastAPI
-
HTTPX
-
Circuit Breaker
-
Retry
まとめ
- サーキットブレーカは、失敗し続ける外部APIを呼び続けて自分のFastAPIアプリ全体が巻き込まれるのを防ぐための、とても実務的なパターンです。Martin Fowler が説明するように、失敗しきい値で回路を開き、以後の呼び出しを止めることで、被害の連鎖を防げます。
- FastAPIでは、共有
AsyncClient、タイムアウト、接続数制御、例外変換を土台にして、その上に再試行・ブレーカ・フォールバックを少しずつ積み上げるのが現実的です。HTTPXはそのための機能をかなり揃えています。 - フォールバックは、単に「エラーを握りつぶす」ことではなく、キャッシュ返却や縮退運転、ジョブ化などを通じて、ユーザー体験と全体安定性を守る設計です。ブレーカ単体より、フォールバックと監視を一緒に考える方が実務では強いです。
- 最初から完璧な実装を目指さなくても大丈夫です。まずは1つの外部APIクライアントだけでも、タイムアウト・例外変換・簡易ブレーカ・キャッシュフォールバックを入れてみると、設計の手触りがかなり分かりやすくなります。
次の記事としては、この流れと相性が良い「FastAPIで作る社内管理画面APIの設計パターン」や、「FastAPIで実践するジョブキュー設計と縮退運転」が自然につながります。
