朝のラッシュ帯にステータスページが青色なのに、自社アプリだけ 529 を浴び続ける、という時間帯があります。アーティスト・クリエイターの廣川政樹です。個人開発で 2014 年から続けているアプリ事業は累計 5,000 万ダウンロードを超えましたが、Claude API を本番に組み込んでから一番の難物だったのが、まさにこの「自分のクォータに余裕があるのに 529 だけが断続的に降ってくる」現象でした。429 と同じ感覚で指数バックオフを書いただけでは、ユーザー側の体感が「とにかく重い」「ボタンを押しても応答が来ない」という最悪の体験になります。
529 Overloaded は、回避ではなく吸収する設計が必要なエラーです。Anthropic 公式の挙動説明をベースに、私が iOS / Android で配信中のアプリ群(壁紙系・写真系・ライティング系)と社内ツールで採用している実装パターンを、コードと運用メトリクスを交えてまとめます。
529 の振る舞いは「アプリ側の混雑」とは別軸にある
429 と 529 はステータス番号が近いため混同されがちですが、性格はまったく違います。429 は自分のリクエスト頻度・トークン使用量がプランの上限に当たった結果なので、自分側で減らせば直ります。一方の 529 は Anthropic のキャパシティが一時的に逼迫したときに返るため、自分のトラフィックを減らしても回復しません。混雑が抜けるまで、待つか、別のリソースに逃がすか、ユーザー操作の流れから外すかのいずれかが必要になります。
私のアプリで実測した範囲では、529 はピーク帯(UTC 13〜16 時 / JST 22〜翌 1 時)に短時間で集中する傾向があります。直近 30 日のリトライログを集計したところ、529 の発生時間は全体の 4.2% に留まっていましたが、そのうち 78% が UTC 13〜16 時のわずか 3 時間に集中していました。逆に同じ時間帯の 429 比率は普段とほぼ変わらず、自分のレート制限とは独立した分布をしていました。この事実は、後述するモデル切替を「時間帯ヒューリスティクス」と組み合わせる根拠になります。
まず「リトライしてはいけない 529」を仕分ける
529 を見たら即座に指数バックオフでリトライ、と書きたくなりますが、それでは破綻するケースがあります。私が経験した中で危険度が高いのは次の三つです。
第一に、同期 UI のなかでブロッキングしてリトライしているケース。ユーザーは数秒待つ覚悟がないので、3 回も指数バックオフを回すと体感は完全にフリーズです。第二に、ストリーミングの途中で 529 が返って既に部分応答を返してしまっているケース。何も考えずに再呼び出しすると、ユーザーには「同じ文章の続きが二回流れた」ように見えます。第三に、ツール呼び出しの結果送信時に発生した 529 です。これは会話履歴を破壊する原因になるため、tool_result ブロックの形式整合性を保ったまま再送する専用パスが必要です。
実装では、まず「ユーザー操作の同期パス上か」「結果送信中か」「ストリーミング中か」をフラグで持ち、リトライ判定を分岐させます。
import anthropic
from anthropic import APIStatusError
import random
class RetryDecision :
def __init__ (self, should_retry: bool , delay: float , reason: str ):
self .should_retry = should_retry
self .delay = delay
self .reason = reason
def classify_529 (err: APIStatusError, ctx: dict ) -> RetryDecision:
# ストリーミング中で部分応答を既にユーザーへ見せた場合は再呼び出ししない
if ctx.get( "partial_streamed" ):
return RetryDecision( False , 0 , "partial response already delivered" )
# ツール結果の送信フェーズ中は専用の再送パスへ
if ctx.get( "phase" ) == "tool_result_submit" :
return RetryDecision( False , 0 , "tool result phase — repair path" )
# 同期 UI でユーザーが画面を見て待っている場合はリトライ回数を制限
if ctx.get( "sync_ui" ):
attempts = ctx.get( "attempts" , 0 )
if attempts >= 1 :
return RetryDecision( False , 0 , "sync UI — defer to async queue" )
return RetryDecision( True , 1.5 + random.random(), "sync UI — single retry" )
# 非同期パスでは指数バックオフ + ジッタ
attempts = ctx.get( "attempts" , 0 )
base = min ( 2 ** attempts, 32 )
return RetryDecision( True , base + random.random() * 2 , f "async backoff x { attempts } " )
sync_ui モードでは原則 1 回だけ短く待って、ダメだったら諦めて非同期キューへ流すのが要点です。後述しますが、ここでユーザーには「下書きを引き継いで処理を継続中です」というプレースホルダ UX を出せるように設計します。
非同期キュー+プレースホルダ UX で「待たせない待ち」を作る
529 を吸収するうえで一番効くのは、ユーザーの操作と Claude API の応答を切り離すことです。ボタンを押した瞬間に「処理を受け付けました」と返して操作を解放し、結果は push 通知や画面内の進捗バナーで届ける、という非同期パスを用意します。私のアプリでは Cloudflare Queues と Workers KV で軽量に組んでいますが、考え方はどのインフラでも同じです。
ユーザーが書いた下書きと操作リクエストをキューに積み、ワーカーが Claude API を叩いて結果を KV に書き戻し、フロントが結果を受け取った時点で UI を切り替える、という流れです。529 が出てもワーカー側で透過的にリトライするので、ユーザー側は「処理がちょっと長引いた」程度の体感で済みます。私の壁紙アプリの AI コメント生成機能では、この非同期パスに切り替えた結果、ユーザー側のエラー露出率が約 87% 減りました。
// Cloudflare Workers — キュー consumer 側の擬似コード
export default {
async queue ( batch : MessageBatch < AnthropicJob >, env : Env ) : Promise < void > {
for ( const msg of batch.messages) {
const job = msg.body;
try {
const text = await callClaudeWithFallback (job, env);
await env. RESULTS_KV . put (
`job:${ job . id }` ,
JSON . stringify ({ status: "done" , text, finishedAt: Date. now () }),
{ expirationTtl: 60 * 60 * 24 }
);
msg. ack ();
} catch (err) {
if (job.attempt < 5 ) {
await env. RESULTS_KV . put (
`job:${ job . id }` ,
JSON . stringify ({ status: "retrying" , attempt: job.attempt }),
{ expirationTtl: 60 * 10 }
);
msg. retry ({ delaySeconds: backoffSeconds (job.attempt) });
} else {
await env. RESULTS_KV . put (
`job:${ job . id }` ,
JSON . stringify ({ status: "failed" , reason: classifyError (err) }),
{ expirationTtl: 60 * 60 * 24 }
);
msg. ack ();
}
}
}
} ,
} ;
プレースホルダ UX 側で気をつけたいのは、進捗の見せ方が「いま動いている」という確信を持てるかどうかです。私のアプリでは、3 段階のステップ(受付済み → 生成中 → 推敲中)を実際の処理フェーズに対応させ、フェーズが進むたびにステップを進めるようにしました。常にスピナーだけが回っている UI と比べて、ユーザーからの「壊れた」「動かない」という問い合わせが目に見えて減りました。
Sonnet → Haiku の温度感あるフォールバックを設計する
非同期キューでも 529 が長引く時間帯は、モデルを Sonnet から Haiku に切り替えることで吸収できる場合があります。Haiku は Sonnet と比べて待ち行列が独立しているケースが多く、私の計測でも Sonnet が断続的に 529 を返している時間帯に Haiku は安定していることが 6 割以上の頻度でありました。
ただし、闇雲に切り替えると品質が落ちる用途もあるため、私は次の三段階のフォールバックを推奨します。
第一段は、Sonnet で 1 回だけ短い指数バックオフを試す。第二段は、ジョブの種類に応じて Haiku に切り替えるかを判定します。第三段は、Haiku でも詰まった場合は処理を翌日に持ち越せるかをユーザーに尋ねる。Haiku に切り替えると品質が顕著に落ちる用途(例: 長文の構成提案・コード生成)は最初から Haiku の選択肢を除外し、品質差が許容できる用途(例: 短いサマリ・タグ付け・分類)は積極的に Haiku を使います。
PRIMARY_MODEL = "claude-sonnet-4-6"
FALLBACK_MODEL = "claude-haiku-4-5-20251001"
# ジョブ種別ごとに「Haiku への降格を許すか」を表で持つ
HAIKU_FALLBACK_ALLOWED = {
"short_summary" : True ,
"tagging" : True ,
"classification" : True ,
"long_form_writing" : False ,
"code_generation" : False ,
"structured_extract" : True ,
}
def call_with_fallback (job, client):
try :
return client.messages.create(
model = PRIMARY_MODEL ,
max_tokens = job[ "max_tokens" ],
messages = job[ "messages" ],
)
except APIStatusError as e:
if e.status_code != 529 :
raise
if not HAIKU_FALLBACK_ALLOWED .get(job[ "kind" ], False ):
raise # 品質維持優先のジョブは Sonnet を待つ
return client.messages.create(
model = FALLBACK_MODEL ,
max_tokens = job[ "max_tokens" ],
messages = job[ "messages" ],
)
実運用で大事なのは、ジョブの種類ごとに「Haiku に落とすメリット」と「品質低下のリスク」を秤にかけることです。私の場合、写真アプリの「アルバムタイトル候補」や「壁紙のキーワード自動付与」は Haiku で十分なクオリティが出るので、29% のジョブを Haiku 側で消費させています。一方で、ライティングアプリの「下書きから記事構成案を作る」処理は Sonnet 限定で、529 が抜けるまで非同期キューで待たせる方針です。
サーキットブレーカで「諦める時間」を決める
529 が長く続く時間帯にリトライを続けると、自分側のキューが肥大化して結局ユーザーへの遅延が膨らみます。これを防ぐためにサーキットブレーカを入れて、一定の失敗率を超えたら一時的に Claude API への呼び出しを止める仕組みを置いています。
import time
from dataclasses import dataclass, field
@dataclass
class CircuitBreaker :
failure_threshold: float = 0.5
window_seconds: int = 60
cooldown_seconds: int = 90
samples: list = field( default_factory = list )
opened_at: float | None = None
def allow (self) -> bool :
now = time.time()
if self .opened_at is not None :
if now - self .opened_at < self .cooldown_seconds:
return False
self .opened_at = None
self .samples.clear()
return True
def record (self, success: bool ):
now = time.time()
self .samples = [(t, s) for (t, s) in self .samples if now - t < self .window_seconds]
self .samples.append((now, success))
if len ( self .samples) < 10 :
return
failures = sum ( 1 for (_, s) in self .samples if not s)
if failures / len ( self .samples) >= self .failure_threshold:
self .opened_at = now
ブレーカが開いている間は、リクエストを即座にエラーにせず「キュー内で 90 秒待ってから再試行する」というセマンティクスにします。私の運用では、ブレーカが開いた時点で同時に Slack へ通知し、人間がステータスを確認できる導線を作っています。Anthropic の Status ページと、Cloudflare の Workers Analytics を並べて見ることで、原因がプラットフォーム側かネットワーク側かを 30 秒で切り分けられるようになりました。
ユーザーへの伝え方が継続率を左右する
技術的な吸収パターンと同じくらい大事なのが、429 / 529 が起きた時にユーザーへ何を見せるかです。エラーメッセージ次第で、翌日の再起動率や課金継続率は目に見えて変わります。実体験で言うと、初期は「サービスが混雑しています。後ほどお試しください」とだけ表示していて、翌日 7 日継続率が一時的に 4 ポイント落ちた経験があります。
メッセージを次の三つに書き分けてから、同じ事象でも継続率の毀損が約 1.8 ポイント以内に収まるようになりました。
ひとつ目は、同期 UI で 529 を即座に返してしまった時の「下書きを保管しました。あとで通知が届きます」というメッセージ。下書きが消えていないことを最初に伝えるのが要です。ふたつ目は、非同期キューで処理待ちが長引いた時の「いつもより少し時間がかかっています。完成したら通知でお知らせします」というメッセージ。具体的な秒数は出さず、通知での到達を約束します。みっつ目は、Haiku に降格した時の「軽量モデルで生成しました。気に入らない場合は再生成してください」というメッセージ。降格を隠さず、ユーザー側に再選択の余地を残すのが信頼の鍵です。
何をモニタリングし、何を SLO に置くか
最後に、運用側で見るべき指標を整理しておきます。私のアプリでは次の四つを Cloudflare Analytics と内製ダッシュボードで毎時表示しています。
ひとつ目は 529 発生率で、過去 60 分のリクエスト数に対する 529 のパーセンテージです。これは Anthropic 側の指標なので、自分のチューニングでは動きません。SLO ではなく状況確認用です。ふたつ目はユーザー露出率で、529 のうち実際にユーザーへエラー文言を出してしまった割合です。私の SLO は 0.5% 以下で、これを超えると非同期キューの容量とブレーカの閾値を見直します。みっつ目は Haiku 切替率で、Haiku に降格したジョブの割合です。これが 35% を超えると品質低下リスクなので、ジョブ種別ごとに切替可否を再評価します。よっつ目はキュー滞留時間の p95 で、95 パーセンタイルでキューに滞留した秒数です。私のアプリでは 25 秒以下を目標にしており、これを超えるとプレースホルダ UX のコピーを変えるトリガにしています。
この四指標を持っておくと、Anthropic 側で何かあった時に「自分が何を変えるべきか」が即座に決まります。逆に、これらの指標を持たないまま 529 を踏むと、リトライ回数を増やすしか打ち手がなくなり、結局ユーザー体験を悪化させてしまいます。
次に試すこと
529 を完璧に消し去ることはできませんが、ユーザーから見えない場所に押し込めることは設計でほぼ達成できます。まずは、自分のアプリで 529 が同期 UI 上でユーザーに直接届いていないかをログから確認してみてください。届いていたら、その経路だけでも非同期キューに分離する小さなリファクタリングが、今週中に効くはずです。同じ問題に取り組んでいる方の参考になれば嬉しいです。