One evening, right after I deployed a routine batch job, the API started answering "this model is currently unavailable." I retried the same call about ten times and succeeded only in fattening the logs.
It took me a while to see that a retry means "try the same conditions again." If the thing being refused is the model ID itself, waiting changes nothing. What you need is a design that switches the request, not one that waits.
Below is how I separate this error from 529 and 429, and how I switch models per request. I've also left in the places where my earlier version was simply wrong, because those taught me the most.
What this error is actually telling you
A 529 Overloaded or a 429 Rate Limit is about congestion or your own usage. Waiting works, so the SDK's automatic retries are the right tool.
This error is different: "the model ID you named can't be accepted on this route right now." Sometimes it clears by itself, sometimes it doesn't. If you handle both families with one net, you either push load onto your fallback during a congested spell or keep retrying a permanent refusal.
The wording also varies by route (Anthropic direct, or a cloud provider) and over time. So the logic below leans on the status code and the SDK's exception class, not on exact message text. Please check what your own logs actually show at least once.
Five situations, grouped
From what I've seen, rejections fall into roughly the five groups below. I never kept numbers on how often each occurs, so I won't rank them.
| Situation | Typical state | Direction |
|---|---|---|
| Just after a new model ships | You named the new ID, but your route or region doesn't have it yet | Switch to the previous generation |
| Retired or soon-to-retire ID | An old ID is hard-coded | Check the retirement date and migrate; keep a fallback as insurance meanwhile |
| Via a cloud provider | Bedrock or Vertex AI offer different models per region | Keep a candidate list per region |
| Typos and alias mix-ups | Dated IDs and aliases get confused | Verify the ID exists once at startup |
| Permission or plan limits | Your account isn't allowed to use that model | Switching won't fix it; alert a human |
The last row matters most. Don't paper over a permission refusal with a fallback — if the switch works, you lose sight of a configuration mismatch you should have noticed.
Keep a fallback table
Start with a table of a preferred model and its stand-ins per use.
type ModelTier = "frontier" | "balanced" | "fast";
interface ModelFallbackChain {
primary: string;
fallbacks: string[];
}
// Only list IDs that exist. Check the official model list before editing
const FALLBACK_CHAINS: Record<ModelTier, ModelFallbackChain> = {
frontier: {
primary: "claude-opus-4-6",
fallbacks: ["claude-sonnet-4-6"],
},
balanced: {
primary: "claude-sonnet-4-6",
fallbacks: ["claude-sonnet-4-5", "claude-haiku-4-5-20251001"],
},
fast: {
primary: "claude-haiku-4-5-20251001",
fallbacks: ["claude-sonnet-4-6"],
},
};My earlier version listed IDs like claude-haiku-4 in the fallback column without checking they existed. A nonexistent ID is guaranteed to fail midway through the chain. The stand-in column is exactly where care pays off.
Tell the rejections apart by exception class
The axis is the exception type the SDK throws, not substring matching on the message. Wording drifts; the meaning of a status code drifts far less.
import Anthropic from "@anthropic-ai/sdk";
type Verdict = "fallback" | "alert" | "rethrow";
// 404: model not found -> switch
// 403: no permission -> don't switch, tell a human
// 429 / 529 / others: leave to SDK retries, never fall back
function classify(err: unknown): Verdict {
if (err instanceof Anthropic.NotFoundError) return "fallback";
if (err instanceof Anthropic.PermissionDeniedError) return "alert";
if (err instanceof Anthropic.BadRequestError) {
// 400 also fires for bad parameters; switch only when the text points at the model
const msg = (err.message ?? "").toLowerCase();
if (msg.includes("unavailable") || msg.includes("not available")) return "fallback";
}
return "rethrow";
}
async function invokeWithFallback(
client: Anthropic,
tier: ModelTier,
buildRequest: (modelId: string) => Anthropic.Messages.MessageCreateParamsNonStreaming
): Promise<Anthropic.Messages.Message> {
const chain = FALLBACK_CHAINS[tier];
const candidates = [chain.primary, ...chain.fallbacks];
let lastError: unknown;
for (const modelId of candidates) {
try {
return await client.messages.create(buildRequest(modelId));
} catch (err) {
const verdict = classify(err);
if (verdict === "fallback") {
console.warn(`model ${modelId} rejected, trying next`);
lastError = err;
continue;
}
if (verdict === "alert") {
await notifyOwner(`permission denied for ${modelId}`); // wire up your own channel
}
throw err; // anything not meant for fallback goes up, never swallowed
}
}
throw lastError;
}I keep 429 and 529 outside the fallback on purpose: moving the same load to a stand-in during congestion doesn't help. Retries belong to the SDK's max_retries; switching is only for "the model itself can't be accepted."
Keep per-model capabilities in a table
The other thing that trips a fallback is a capability mismatch. A parameter such as extended thinking that worked on the primary may be refused by a stand-in — and that comes back as a 400 for a bad parameter, not as "model unavailable."
So I list what each model can do and let the request builder branch on it.
interface ModelCapabilities {
extendedThinking: boolean;
}
// Values are examples. Confirm support in the official docs and replace them
const CAPABILITY_MATRIX: Record<string, ModelCapabilities> = {
"claude-opus-4-6": { extendedThinking: true },
"claude-sonnet-4-6": { extendedThinking: true },
"claude-sonnet-4-5": { extendedThinking: true },
"claude-haiku-4-5-20251001": { extendedThinking: false },
};
function buildRequestForModel(
modelId: string,
prompt: string,
tools: Anthropic.Messages.Tool[]
): Anthropic.Messages.MessageCreateParamsNonStreaming {
const caps = CAPABILITY_MATRIX[modelId] ?? { extendedThinking: false };
return {
model: modelId,
// with extended thinking, max_tokens must exceed budget_tokens
max_tokens: caps.extendedThinking ? 20000 : 4096,
messages: [{ role: "user", content: prompt }],
tools,
...(caps.extendedThinking
? { thinking: { type: "enabled" as const, budget_tokens: 16384 } }
: {}),
};
}My earlier code passed budget_tokens: 16384 alongside max_tokens: 4096. The thinking budget has to stay below max_tokens, so that request fails from the very first call — long before any fallback. It had "worked" on my machine only because I'd tested the path with thinking switched off.
IDs missing from the table get a default with no extra features. When I'm unsure, I lean toward fewer features and a response that definitely comes back.
Record that a switch happened
The better a fallback works, the harder it is to notice. If the primary stays down and a stand-in keeps answering, everything looks like success.
interface ModelInvocationLog {
timestamp: string;
intendedModel: string;
actualModel: string;
fallbackDepth: number; // 0 = succeeded on the primary
taskType: string;
outcome: "success" | "all_failed";
}Once a week, I glance at the share of calls with fallbackDepth of 1 or more. The right threshold for review depends on how critical the task is, so it's more comfortable to decide after a few weeks of real numbers. I started by collecting records, not by fixing a figure in advance.
Reproduce this path in development
An error that only shows up in production tends to go untested. Pass a nonexistent ID (say claude-opus-99-test) as model and you should get a 404 saying the model wasn't found. Save the response you actually get, use it as fixture data for classify, and CI can confirm that every step of the chain advances correctly.
Discovering "I never tested that" at the moment a model drops is the kind of thing that makes my stomach hurt. Since that night I've also tested the case where every step in the chain fails — the path that ends in throw lastError.
One thing to do today
Add one line to your current code: after the call, log the model ID that was actually used, taken from the response's model field. Before you build any fallback, you'll see how often switching already happens.
Then add classify, which separates 404 from 403, and you'll avoid the mistake of hiding a permission problem behind a stand-in model.