◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-05-22Intermediate

Designing a Model-Selection Fallback That Survives this model is currently unavailable on Claude API

When Claude API answers 'this model is currently unavailable', treating it like a 529 Overloaded or a rate limit sends you the long way round. Here is how I tell the rejections apart by SDK exception class, switch model IDs per request, and the assumptions I had to fix along the way.

claude-api83model-availabilityfallback9error-handling11troubleshooting92

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.

SituationTypical stateDirection
Just after a new model shipsYou named the new ID, but your route or region doesn't have it yetSwitch to the previous generation
Retired or soon-to-retire IDAn old ID is hard-codedCheck the retirement date and migrate; keep a fallback as insurance meanwhile
Via a cloud providerBedrock or Vertex AI offer different models per regionKeep a candidate list per region
Typos and alias mix-upsDated IDs and aliases get confusedVerify the ID exists once at startup
Permission or plan limitsYour account isn't allowed to use that modelSwitching 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-05-22
Why tool_result could not be submitted Keeps Coming Back, and How to Build a Recovery Handler That Actually Holds
Run a Claude agent long enough and one day it starts: 'tool_result could not be submitted', back to back, and retries change nothing. The error message hides four completely different root causes. Here is what I learned debugging it on the always-on agent jobs I run as an indie developer, with the TypeScript recovery handler I now ship in production.
⬡ API & SDK2026-05-09
A Five-Layer Preflight Design for Claude API — How I Cut Hundreds of 400/422/529 Errors to Zero
A production-tested five-layer preflight design that catches Claude API failures before the network call — schema, token budget, model capability, content policy, and spend cap — with full TypeScript implementation and one month of operational numbers.
⬡ API & SDK2026-04-23
High-Availability Patterns for the Claude API — Making Sonnet/Haiku/Opus Fallback Work in Production
A single-model Claude API integration will fall over the first time rate limits or a regional hiccup land at peak hours. This is the production pattern for a Sonnet → Opus → Haiku fallback chain, with circuit breakers, streaming coverage, and the pitfalls you only learn the hard way.