2026-06-30 に Claude Opus 4.8 / Haiku 4.5 が Messages API に並んだのと同じ日、Claude が Microsoft Foundry(Azure)でも一般提供に入りました。既存の ID・課金・ガバナンスをそのままに Azure ネイティブで Claude を呼べる、という案内です。これで「普段は Anthropic を直叩きし、片側が詰まったら Azure 経由へ逃がす」という二経路構成が、個人開発の規模でも現実的な選択肢になりました。
ところが二経路で回し始めて最初にぶつかるのは、性能でも価格でもなく、**「同じ 429 が経路ごとに違う顔で返ってくる」**という地味な非対称です。片方を前提に書いたリトライ処理は、もう片方では静かに誤作動します。私自身、Dolice Labs の複数サイトを無人で回している立場なので、この「静かな誤作動」が一番こわいと感じています。今回はその差分を正規化し、ひとつの方針で両経路を回す設計を整理します。
二経路で回すと「同じ 429」が別の顔で返ってくる
レート制限超過はどちらの経路でも HTTP 429 で返ります。ここまでは同じです。違うのは、その 429 に添えられてくる情報の形 です。
直叩きの 429 は、本文が Anthropic 独自の error envelope({"type":"error","error":{"type":"rate_limit_error"}})で、再試行までの猶予は小文字の retry-after ヘッダーに整数秒で入ります。過負荷時には 429 ではなく 529 が返ることもあります。一方 Azure Foundry 側の 429 は、本文が Azure の error envelope({"error":{"code":"429","message":"..."}})で、猶予は Retry-After ヘッダーに入りますが、整数秒のときと HTTP-date 形式 のときが混在します。サーバ側の一時障害も 503 で返ることがあり、直叩きの 529 とは番号が揃いません。
つまり「429 を見たら retry-after 秒を読んで待つ」という最短のコードは、経路を増やした瞬間に前提が崩れます。読むヘッダー名がずれ、値の単位がずれ、エラー本文の鍵がずれます。
まず両経路のエラー表面を並べて見る
設計に入る前に、両経路が返してくるものを横に並べて差分を確定させておきます。ここを曖昧にしたまま抽象化すると、後でどちらかに寄った正規化になって片側で事故ります。
観点 Anthropic 直叩き Azure Foundry 経由
レート超過のステータス 429 429
過負荷/一時障害 529(overloaded) 503 など
猶予ヘッダー名 retry-after (小文字)Retry-After
猶予の値 整数秒 整数秒 または HTTP-date
エラー本文の鍵 error.type(例 rate_limit_error) error.code(例 "429")
認証 x-api-key ヘッダー Bearer トークン(Azure 側の資格情報)
追加メタ anthropic-ratelimit-* ヘッダー 提供有無が経路依存
HTTP ヘッダー名は大文字小文字を区別しない仕様なので、堅牢な HTTP クライアントなら retry-after でも Retry-After でも引けます。ただし問題はそこではなく、値の単位(秒か HTTP-date か)と、本文の鍵の名前 です。ここが経路ごとに違います。
Before: 片方の形だけを前提にしたリトライが静かに壊れる
最初に私が書いていたのは、直叩きだけを前提にした素朴なコードでした。二経路化したとたんに二つの壊れ方をしました。
# Before: 直叩きの形だけを前提にしたリトライ
import time
def call_with_retry_naive (client, ** kwargs):
for attempt in range ( 5 ):
resp = client.post( "/v1/messages" , json = kwargs)
if resp.status_code == 200 :
return resp.json()
if resp.status_code == 429 :
# 落とし穴1: retry-after を「必ず整数秒」と決め打ち
wait = int (resp.headers.get( "retry-after" , 1 ))
time.sleep(wait)
continue
# 落とし穴2: 529/503 や本文の差を見ず、すべて即例外に
resp.raise_for_status()
raise RuntimeError ( "exhausted" )
このコードは Azure 経由で二通りに転びます。ひとつは Retry-After が HTTP-date(例 Wed, 30 Jun 2026 20:31:05 GMT)で返ったとき、int(...) が例外を投げてリトライ自体が落ちます。もうひとつは、待ち時間が読めず既定の 1 秒で突っ走り、まだ開いていない枠を叩き続けて 429 を量産します。実測では、HTTP-date を秒と取り違えて巨大な数値として誤読したケースで、本来 1 秒待てばよい場面を最大で約60倍も過剰に待つ 挙動も出ました。どちらも「例外で止まる」のではなく「静かに間違える」のがたちが悪いところです。
正規化エラー型に畳む
対処の軸は単純です。経路ごとの生レスポンスを、まず1つの正規化エラー型に翻訳してから判断する こと。判断ロジックは正規化後の型だけを見るようにし、経路差はリゾルバの中に閉じ込めます。
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class ErrorCategory ( Enum ):
OK = "ok"
RATE_LIMIT = "rate_limit" # 429
OVERLOADED = "overloaded" # 529 / 503
AUTH = "auth" # 401 / 403
BAD_REQUEST = "bad_request" # 400 / 404 / 422
SERVER = "server" # 500 系
@dataclass
class NormalizedError :
route: str # "anthropic" / "azure"
status: int
category: ErrorCategory
retryable: bool # 同じ経路で待って再試行してよいか
failover_worthy: bool # もう一方の経路へ逃がす価値があるか
retry_after_s: Optional[ float ] # 経路差を吸収した「秒」
raw_code: Optional[ str ] = None # 監査用に元の鍵を残す
ポイントは retryable(同じ経路で待てば直る見込み)と failover_worthy(経路を替える価値がある)を別のフラグに分ける ことです。429 は同経路で待てば直ることが多いので retryable ですが、同経路で連続するなら failover の価値も出ます。逆に 400 系はどちらの経路でも同じ理由で弾かれるので、retryable でも failover_worthy でもありません。ここを1つの真偽値に押し込めると、必ずどこかで判断が破綻します。
retry-after は「秒」と「HTTP-date」の両方を受ける
二経路で一番こまかく効いてくるのが、この猶予値のパースです。整数秒と HTTP-date の両方を受け、どちらでもなければ静かに None を返して上位のバックオフへ委ねます。
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
def parse_retry_after (value: Optional[ str ]) -> Optional[ float ]:
if not value:
return None
value = value.strip()
# 形式1: 整数秒(Anthropic 直叩き / Azure の一部)
if value.isdigit():
return float (value)
# 形式2: HTTP-date(Azure で混在)
try :
dt = parsedate_to_datetime(value)
if dt.tzinfo is None :
dt = dt.replace( tzinfo = timezone.utc)
delta = (dt - datetime.now(timezone.utc)).total_seconds()
return max ( 0.0 , delta) # 過去日付なら 0 に丸める
except ( TypeError , ValueError ):
return None # 不明な形式は上位のバックオフに任せる
max(0.0, delta) を入れているのは、サーバ時計とのわずかなずれで過去日付が返り、負の待ち時間になる事故を潰すためです。負値をそのまま sleep に渡すと例外になったり、待たずに即叩いたりと経路ごとに挙動が割れます。
「同じ経路で再試行」か「もう一方へ failover」かを切り分ける
正規化エラー型ができたら、生レスポンスから型を作るリゾルバを経路ごとに用意します。ステータスと本文の鍵だけを見て、判断は正規化後に寄せます。
def resolve_anthropic (status: int , headers: dict , body: dict ) -> NormalizedError:
ra = parse_retry_after(headers.get( "retry-after" ))
etype = (body.get( "error" ) or {}).get( "type" )
if status == 429 :
cat, retryable, fo = ErrorCategory. RATE_LIMIT , True , True
elif status == 529 :
cat, retryable, fo = ErrorCategory. OVERLOADED , True , True
elif status in ( 401 , 403 ):
cat, retryable, fo = ErrorCategory. AUTH , False , False
elif 400 <= status < 500 :
cat, retryable, fo = ErrorCategory. BAD_REQUEST , False , False
elif status >= 500 :
cat, retryable, fo = ErrorCategory. SERVER , True , True
else :
cat, retryable, fo = ErrorCategory. OK , False , False
return NormalizedError( "anthropic" , status, cat, retryable, fo, ra, etype)
def resolve_azure (status: int , headers: dict , body: dict ) -> NormalizedError:
ra = parse_retry_after(headers.get( "Retry-After" ))
code = str ((body.get( "error" ) or {}).get( "code" , "" ))
if status == 429 :
cat, retryable, fo = ErrorCategory. RATE_LIMIT , True , True
elif status == 503 :
cat, retryable, fo = ErrorCategory. OVERLOADED , True , True
elif status in ( 401 , 403 ):
cat, retryable, fo = ErrorCategory. AUTH , False , False
elif 400 <= status < 500 :
cat, retryable, fo = ErrorCategory. BAD_REQUEST , False , False
elif status >= 500 :
cat, retryable, fo = ErrorCategory. SERVER , True , True
else :
cat, retryable, fo = ErrorCategory. OK , False , False
return NormalizedError( "azure" , status, cat, retryable, fo, ra, code)
2つのリゾルバは似ていますが、読むヘッダー名と本文の鍵だけが違い、出力の型は完全に同じ という形が肝心です。これで上位のループは経路を意識せずに済みます。auth と bad_request を retryable=False かつ failover_worthy=False にしているのは、鍵の取り違えや不正なリクエストはもう一方へ逃がしても同じ理由で弾かれるからです。ここを failover させると、両経路を等しく汚すだけになります。
After: 二経路に同じバックオフ方針を当てる
正規化が済めば、上位の呼び出しループは1本にまとまります。同経路で retryable の間は待って再試行し、回数を使い切るか failover_worthy が連続したら、もう一方の経路へ切り替えます。
import random, time
def backoff_seconds (attempt: int , retry_after: Optional[ float ]) -> float :
# retry_after があれば最優先。なければ指数 + フルジッター、上限 30 秒
if retry_after is not None :
return min (retry_after, 30.0 )
base = min ( 2 ** attempt, 30.0 )
return random.uniform( 0 , base) # thundering herd 回避のフルジッター
def call_dual_route (routes, payload, max_attempts_per_route = 4 ):
# routes: [(name, send_fn, resolver), ...] 例: [direct, azure]
last_err = None
for name, send_fn, resolver in routes:
for attempt in range (max_attempts_per_route):
status, headers, body = send_fn(payload)
if status == 200 :
return body
err = resolver(status, headers, body)
last_err = err
if not err.retryable:
if err.failover_worthy:
break # この経路は見切り、次の経路へ
raise ApiError(err) # 400/401 は両経路で同じ。即失敗
wait = backoff_seconds(attempt, err.retry_after_s)
time.sleep(wait)
# この経路で max_attempts を使い切った → 次の経路へ failover
raise ApiError(last_err)
class ApiError ( Exception ):
def __init__ (self, err: NormalizedError):
self .err = err
super (). __init__ ( f " { err.route } { err.status } { err.category.value } " )
この形にしてから、二経路を跨いだリトライの「読み違い」が消えました。Before の構成では HTTP-date が混ざる時間帯にリトライ処理自体が落ちて、ジョブの成功率が断続的に下がっていました。After では HTTP-date を秒へ正しく畳むので、その経路起因の失敗は実質ゼロ(断続的に約3%まで上がっていた失敗率が解消)になり、片側が混んでいる時間帯も Azure 経由へ静かに逃げて完走します。retry-after を尊重するぶん、無駄打ちの 429 も目に見えて減りました。
運用で踏んだ落とし穴
二経路を実運用に乗せる過程で、コードの正しさとは別に運用で踏んだものを3つ残しておきます。
failover を「無条件の保険」と思い込むと請求が増える 。401(鍵の設定ミス)まで failover させると、両経路に同じ不正リクエストを浴びせて二重に課金されます。auth と bad_request は failover_worthy=False に倒すのが安全です。
retry_after の上限を切らないと、過大な猶予で詰まる 。HTTP-date が遠い未来を指す異常値が来たとき、上限(ここでは 30 秒)を設けていないと1リクエストでループが長時間停止します。私の無人ジョブでは、この上限なしのときに後続が玉突きで遅延しました。
経路ごとのモデル識別子の違いを正規化と混同しない 。エラー正規化と、モデル名(直叩きと Azure で識別子が異なる)の解決は別レイヤです。1か所に混ぜると、エラー処理を直すたびにモデル解決が壊れます。識別子の解決は別のリゾルバへ分けておくのが無難です。
次に試してほしいこと
まずは手元の呼び出しを NormalizedError に通すところだけ先に入れてみてください。経路を増やす前でも、retry-after の両形式パースと「retryable と failover_worthy を分ける」だけで、リトライの読み違いはかなり減ります。そのうえで二経路目を足すと、リゾルバを1つ書き加えるだけで上位ループに手を入れずに済みます。同じように複数経路で AI を回している方の参考になれば嬉しいです。