ストア掲載文の多言語チェックを夜のバッチに任せていた頃、Claude API が 529 を返し続ける時間帯がありました。混雑時の 529 は仕様として織り込んでおり、他社モデルへ切り替えるフォールバックも用意してありました。翌朝ログを開くと、切り替わった先で TypeError が出ていて、結果は一件も残っていませんでした。
胃が重くなったのは、Claude 側の混雑ではなく、私が書いた保険のほうが先に破れていたと気づいたときです。半年ほど前に組んだフォールバックは、その夜まで一度も発火したことがなかったのです。
原因は、プロンプトキャッシュのために付けていた cache_control を、他社モデルへ渡す content block にそのまま残していたことでした。同じ形の報告は langchain-ai/langchain の Issue #33709 にもあり、with_fallbacks で Anthropic から別のモデルへ倒した瞬間に落ちる、という内容です。私のコードは LangChain を使っていませんでしたが、境界の扱いが同じだったのだと思います。
cache_control は Claude の手前で付ける飾りです
Anthropic の Messages API では、system や messages の content block に cache_control: {"type": "ephemeral"} を付けると、その位置までのプレフィックスがキャッシュされます。ブレークポイントは 1 リクエストに 4 か所まで置けます。用語集を system に載せて印を付けておくと、Opus 5.5 ではキャッシュ読み取りが $0.20 / Mtok まで下がるので、毎晩同じ用語集を何十回も送るバッチでは効き目が大きいのです。請求がどれだけ下がるかは構成しだいで、その勘所は キャッシュ読み取りが 75% 安くなっても、請求の下がり方は構成しだいで 10 倍違います に書いたとおりです。
一方で、このキーは Anthropic の API にしか意味がありません。他社の chat 形式へ content block を渡すとき、type と text 以外のキーが残っていれば、クライアント側の型検査で TypeError になるか、サーバー側で 400 として弾かれるか——どちらにしても届きません。
白状しますと、最初に疑ったのは相手側のクライアントの版でした。スタックトレースがメッセージ変換の関数を指していたので、ライブラリを上げれば直ると思い込み、上げても同じ行で落ちました。手が止まったのは、送る直前の payload を print してみたときです。用語集のブロックに、見慣れた "cache_control": {"type": "ephemeral"} がそのまま載っていました。
私が見落としていたのは、messages の配列を「会話の記録」として一つだけ持ち、Claude 向けにキャッシュ印を付けたその同じ配列を、フォールバック先へも渡していたことです。記録の形と、送信用の形を分けていなかったのです。
キャッシュ印は Claude の手前で付け、境界の向こうへは持ち出しません。 この一文を決めてから、コードは素直になりました。
境界で剥がす関数と、送る直前の検問
まず剥がす側です。元の配列を書き換えないこと、system にも同じ処理を通すこと。この二つだけ守れば短く済みます。
ANTHROPIC_ONLY_KEYS = {"cache_control"}
def strip_anthropic_extensions(messages, system):
"""他社モデルへ渡す直前に Claude 固有のキーを剥がす。元のオブジェクトは変更しない"""
def clean(content):
if isinstance(content, str):
return content
return [
{k: v for k, v in block.items() if k not in ANTHROPIC_ONLY_KEYS}
for block in content
]
return (
[{**m, "content": clean(m["content"])} for m in messages],
clean(system),
)剥がすだけでは、将来 Claude 固有のキーを増やしたときに同じ事故を繰り返します。そこで送る直前に検問を置き、残っていれば送らずに止めるようにしました。
def assert_portable(obj, path="$"):
"""境界の向こうへ送る直前の検問。Claude 固有キーが残っていれば送る前に止める"""
if isinstance(obj, dict):
for k, v in obj.items():
if k in ANTHROPIC_ONLY_KEYS:
raise ValueError(f"{path}.{k} が残っています")
assert_portable(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
assert_portable(v, f"{path}[{i}]")もう一つ、形の違いがあります。Anthropic では system がトップレベルの引数ですが、多くの chat 形式では role が system のメッセージとして先頭に置きます。content block の配列を文字列へ畳む処理も、ここでまとめて行います。
def to_chat_format(messages, system):
"""Anthropic の形(system は別引数・content は block 配列)を一般的な chat 形式へ"""
def flatten(content):
if isinstance(content, str):
return content
return "\n".join(b["text"] for b in content if b.get("type") == "text")
out = [{"role": "system", "content": flatten(system)}]
out += [{"role": m["role"], "content": flatten(m["content"])} for m in messages]
return outこの三つの関数は、手元で cache_control 付きの配列を通して、検問が止めること・剥がしたあとに通ること・元の配列が変わっていないことを確かめてあります。text 以外の block(tool_use や tool_result、画像)は畳めませんので、ツールを使う会話はフォールバックの対象から外すという線引きにしました。壊れた形で届くより、Claude の回復を待って再実行するほうが、翌朝の私には扱いやすいのです。
429 では切り替えず、529 と接続エラーだけで切り替える
呼び出し側は次のようになりました。Claude を呼ぶ関数は、切り替えるべき失敗だけを ClaudeUnavailable に包み直します。
import os
import time
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY=YOUR_API_KEY を環境変数で
MODEL = "claude-sonnet-5"
class ClaudeUnavailable(Exception):
pass
def call_claude(messages, system):
if os.environ.get("FORCE_FALLBACK") == "1":
raise ClaudeUnavailable("FORCE_FALLBACK=1 により強制")
try:
r = client.messages.create(
model=MODEL, max_tokens=1024, system=system, messages=messages
)
except (anthropic.InternalServerError, anthropic.APIConnectionError) as e:
# 529 overloaded を含む 5xx と接続断だけを切り替え対象にする
raise ClaudeUnavailable(str(e)) from e
return r.content[0].text
def call_other(chat_messages):
# 各自のクライアントに置き換える(OpenAI 互換の chat 形式を受け取る想定)
...
def ask(messages, system):
for attempt in range(2):
try:
return call_claude(messages, system), MODEL
except ClaudeUnavailable:
if attempt == 0:
time.sleep(30) # 短い混雑ならこの間に Claude へ戻れる
stripped_messages, stripped_system = strip_anthropic_extensions(messages, system)
payload = to_chat_format(stripped_messages, stripped_system)
assert_portable(payload) # 残っていればここで止まる
return call_other(payload), "fallback"except に anthropic.RateLimitError を入れていないのは意図的です。429 は私の側の速度超過であって、先方の混雑ではありません。他社へ逃げても翌日には同じ壁に当たりますし、しばらく待てば通ります。Python SDK は既定で 2 回まで再試行してから例外を投げてくれますので、それでも届いた 5xx と接続断——これだけを「Claude が今は使えない」と読むことにしました。
切り替えの前に何秒待つかも、この機会に決めました。SDK の再試行は指数バックオフで数秒のうちに終わりますので、そのあとに私の側で 30 秒だけ間を置き、それでも 5xx なら他社へ出る、という順番です。夜のバッチは翌朝までに終わればよいので、数十秒の待ちは失うものがなく、短い混雑ならその間に Claude へ戻れるのです。
戻り値にどのモデルが答えたかを添えているのも、この夜の教訓です。フォールバック先の訳文は用語の癖が違いますので、朝の目視確認で「今日はどの行を疑うか」が分かるようにしておきたかったのです。Claude の中で Sonnet と Haiku を行き来する構成は Claude API の高可用性設計 — Sonnet / Haiku / Opus マルチモデルフォールバックを本番で成立させる に書きましたが、他社へ出る経路は、あちらより一段慎重に扱っています。
発火しないフォールバックは、試されていないのと同じです
半年間、一度も通ったことのない経路を「保険」と呼んでいたことが、いちばんの見落としでした。いまは FORCE_FALLBACK=1 を付けて、週に一度、バッチの先頭 1 件だけをフォールバック経路で通しています。費用は 1 件分で、ログには model=fallback の行が 1 本残ります。その 1 本が朝に見当たらなければ、保険のほうが壊れていると分かります。
要するに、剥がす関数、送る前の検問、そして週に一度の強制発火——この三つを揃えて、ようやくフォールバックが保険になったのだと感じています。
まずはご自身のフォールバック経路を、Claude が元気な日に一度だけ強制的に発火させてみていただければと思います。落ちるなら、混雑の夜ではなく、その日に落ちてくれるほうがはるかにありがたいのです。私はこの一件以来、境界を越えるデータには必ず検問を置くようにしています。