OpenAI・Claude・Gemini APIを自動で切り替える方法|障害時のフォールバック設計

OpenAI・Claude・Gemini APIを自動で切り替える方法|障害時のフォールバック設計 AI開発

OpenAI APIを使ったサービスを運用していると、レート制限や一時的なサーバー障害によって回答を生成できないことがあります。

この問題への対策として、OpenAIが失敗したらClaude、Claudeも失敗したらGeminiへ自動的に切り替えるフォールバック構成があります。

ただし、エラーが発生するたびに別のAI APIへ切り替えるだけでは安全ではありません。

入力形式が間違っている400エラーを別のプロバイダーへ送っても、同様に失敗する可能性があります。安全上の理由で拒否されたリクエストを別のAIへ送ると、拒否を回避する仕組みになってしまいます。

ストリーミングの途中でプロバイダーを切り替えると、文章が重複したり、前半と後半で主張が変わったりすることもあります。

Function Callingで注文確定やメール送信を行っている場合は、フォールバック先で同じツールを再実行し、処理が重複する危険があります。

そのため、AI APIのフォールバックでは、再試行できるエラー、別プロバイダーへ切り替えられるエラー、直ちに処理を終了すべきエラーを区別する必要があります。

この記事では、OpenAI、Claude、GeminiのAPIをTypeScriptから共通の形式で呼び出し、障害時に安全に切り替える方法を解説します。OpenAI APIの429エラー対処はOpenAI APIの429エラーを直す方法、ストリーミング切断はOpenAI APIのストリーミングが途中で切れる原因、Function Callingの終了条件設計はFunction Callingが無限ループする原因もあわせてご覧ください。

スポンサーリンク
  1. AI APIのフォールバックとは
  2. 再試行とフォールバックの違い
  3. フォールバックしてよいエラー
  4. フォールバックすべきでないエラー
  5. エラーを共通形式へ変換する
  6. エラー情報を安全に読み取る
  7. プロバイダーエラーを分類する
  8. API間で共通のリクエスト形式を作る
  9. OpenAI用のアダプター
  10. Claude用のアダプター
  11. Gemini用のアダプター
  12. 環境変数からプロバイダーを作成する
  13. 指数バックオフで同じプロバイダーへ再試行する
  14. サーキットブレーカーを実装する
  15. 自動フォールバックを実装する
  16. 呼び出し側の実装
  17. 全体のタイムアウトを設定する
  18. ストリーミング開始後は自動で切り替えない
  19. Structured Outputsは共通仕様ではない
  20. Function Callingのフォールバックはさらに注意が必要
  21. 会話状態をプロバイダーへ依存させない
  22. プロバイダーごとの能力を登録する
  23. 品質によるフォールバック条件も設定する
  24. 常に最も安いモデルへ切り替えるとは限らない
  25. 料金上限を設定する
  26. フォールバック率を監視する
  27. ヘルスチェックだけで障害を判断しない
  28. 認証エラーをフォールバックで隠さない
  29. フォールバックのテスト方法
  30. プロバイダーごとに評価データを用意する
  31. フォールバックの優先順位を固定しすぎない
  32. OpenAI互換APIだけに統一する場合の注意点
  33. OpenAI・Claude・Geminiのフォールバックに関するよくある質問
  34. まとめ

AI APIのフォールバックとは

フォールバックは、優先して使用するAIプロバイダーが利用できない場合に、別のプロバイダーへ処理を引き継ぐ仕組みです。

通常時はOpenAIを使用し、一時障害時はClaudeへ切り替える構成であれば、処理の流れは次のようになります。

処理の流れ
ユーザーのリクエスト
        ↓
OpenAI API
        ↓ 失敗
同じOpenAIへ短時間の再試行
        ↓ 失敗
Claude API
        ↓ 失敗
Gemini API
        ↓
回答を返す

重要なのは、最初の失敗ですぐ別プロバイダーへ移動しないことです。

一時的なネットワークエラーや短時間のレート制限であれば、同じプロバイダーへ一度再試行するだけで成功する可能性があります。

一方、APIキーの誤りやリクエスト形式の不備は、待っても改善しません。

再試行とフォールバックを別の処理として設計する必要があります。

再試行とフォールバックの違い

再試行は、同じプロバイダーへ同じ処理を送り直すことです。

フォールバックは、別のプロバイダーや別のモデルへ処理を切り替えることです。

OpenAIの公式JavaScript SDKは、接続エラー、408、409、429、500番台のエラーを標準で2回再試行します。Claudeの公式SDKも、接続エラー、レート制限、500番台などの一時的な障害を指数バックオフで標準2回再試行します。

SDKの自動再試行に加えて、アプリケーション側でも3回再試行すると、実際のリクエスト回数が想定以上に増える可能性があります。

たとえば、アプリケーションが3回呼び出し、その各呼び出しをSDKが最大3回試行すると、合計で最大9回のAPIリクエストが発生します。

複数プロバイダーを統合する場合は、SDK側の再試行を無効化し、アプリケーション側で一元管理する方法が分かりやすくなります。

リトライを一元化する例
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  maxRetries: 0,
});

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
  maxRetries: 0,
});

フォールバックしてよいエラー

一時的な通信障害、リクエストタイムアウト、レート制限、サーバー障害、サービス過負荷は、再試行やフォールバックの対象にできます。

OpenAIでは、408、409、429、500番台が公式SDKの自動再試行対象です。429には短時間のレート制限だけでなく、クレジット残高不足やプロジェクトの支出上限到達なども含まれるため、エラーコードまで確認する必要があります。

Claude APIでは、429がレート制限、500番台がサーバー側の問題を示します。Anthropicの公式SDKは、一時的な失敗に対してretry-afterヘッダーを尊重しながら再試行します。

Gemini APIは、429のrate_limit_exceededRESOURCE_EXHAUSTED、503のservice_unavailableUNAVAILABLEなどについて、指数バックオフによる再試行を推奨しています。Googleは408、429、500番台を一時エラーとして扱い、400や403などのクライアントエラーは再試行しないよう案内しています。

一時エラーを同じプロバイダーへ少数回再試行し、それでも失敗した場合に別プロバイダーへ切り替える構成が基本です。

フォールバックすべきでないエラー

400エラーは、リクエスト本文、パラメータ、画像形式、ツール定義などに問題があることを示します。

同じ不正な入力を別プロバイダーへ送っても、成功する保証はありません。

401はAPIキーの不備、403は権限不足、422は処理できないリクエストを示すため、通常は自動再試行を行いません。

APIキーが壊れている場合に、可用性を維持する目的で別プロバイダーへ切り替える設計は可能です。しかし、設定ミスが長期間隠れるため、重大アラートを同時に発生させる必要があります。

モデルによる安全上の拒否やコンテンツブロックも、通常の障害として扱ってはいけません。

GeminiのInteractions APIでは、安全性、著作物の再現、禁止コンテンツなどによる生成ブロックが、通常のHTTPエラーとは別のコードとして返されます。

あるプロバイダーが安全上の理由で拒否した入力を、回答させるためだけに別プロバイダーへ自動送信すると、安全機構を迂回する構成になります。

拒否や安全性ブロックを受け取った場合は、フォールバックせず、アプリケーション共通の安全ポリシーに従って処理します。

エラーを共通形式へ変換する

OpenAI、Claude、Geminiでは、エラーオブジェクトの構造やコード名が異なります。

フォールバック処理の中でプロバイダー固有のエラーを直接判定すると、条件分岐が複雑になります。

最初に共通形式へ変換します。

src/normalized-error.ts
type ProviderName =
  | "openai"
  | "anthropic"
  | "gemini";

type ErrorKind =
  | "transient"
  | "rate_limit"
  | "quota"
  | "model_unavailable"
  | "configuration"
  | "invalid_request"
  | "policy"
  | "cancelled"
  | "unknown";

type NormalizedProviderError = {
  provider: ProviderName;
  kind: ErrorKind;
  message: string;
  status: number | null;
  code: string | null;
  requestId: string | null;
  retryAfterMs: number | null;
  retryable: boolean;
  fallbackAllowed: boolean;
  originalError: unknown;
};

retryableは同じプロバイダーへ再試行できるかを示します。

fallbackAllowedは、別のプロバイダーへ切り替えてよいかを示します。

レート制限は両方がtrueになります。

入力不備は両方がfalseになります。

利用枠の完全な枯渇は、同じプロバイダーへすぐ再試行しても直らないため、retryablefalsefallbackAllowedtrueにできます。

エラー情報を安全に読み取る

各SDKのエラーから、HTTPステータス、エラーコード、リクエストID、Retry-Afterを取得します。

src/read-error-fields.ts
function isRecord(
  value: unknown,
): value is Record<string, unknown> {
  return (
    typeof value === "object" &&
    value !== null
  );
}

function readNumber(
  value: unknown,
): number | null {
  return typeof value === "number"
    ? value
    : null;
}

function readString(
  value: unknown,
): string | null {
  return typeof value === "string"
    ? value
    : null;
}

function readHeader(
  headers: unknown,
  name: string,
): string | null {
  if (headers instanceof Headers) {
    return headers.get(name);
  }

  if (!isRecord(headers)) {
    return null;
  }

  const lowerName =
    name.toLowerCase();

  for (const [key, value] of Object.entries(
    headers,
  )) {
    if (
      key.toLowerCase() === lowerName &&
      typeof value === "string"
    ) {
      return value;
    }
  }

  return null;
}

function parseRetryAfter(
  value: string | null,
): number | null {
  if (!value) {
    return null;
  }

  const seconds = Number(value);

  if (Number.isFinite(seconds)) {
    return Math.max(
      0,
      seconds * 1_000,
    );
  }

  const date = Date.parse(value);

  if (Number.isNaN(date)) {
    return null;
  }

  return Math.max(
    0,
    date - Date.now(),
  );
}

OpenAIは本番環境でリクエストIDをログへ記録することを推奨しており、レスポンスにはx-request-id、公式SDKのトップレベルオブジェクトには_request_idが用意されています。独自のX-Client-Request-Idを送信することもできます。

Claude APIも、エラーレスポンスとレスポンスヘッダーからリクエストIDを取得できます。

プロバイダーエラーを分類する

次の関数では、SDKによって異なるエラー形式から値を読み取り、再試行とフォールバックの可否を決定します。

src/normalize-provider-error.ts
const QUOTA_CODES = new Set([
  "credit_balance_exhausted",
  "organization_spend_limit_exceeded",
  "project_spend_limit_exceeded",
  "quota_exceeded",
  "billing_error",
]);

const POLICY_CODES = new Set([
  "safety",
  "recitation",
  "prohibited_content",
  "image_safety",
  "image_prohibited_content",
  "spii",
  "blocklist",
  "refusal",
]);

function normalizeProviderError(
  provider: ProviderName,
  error: unknown,
): NormalizedProviderError {
  if (
    error instanceof DOMException &&
    error.name === "AbortError"
  ) {
    return {
      provider,
      kind: "cancelled",
      message: "リクエストが中止されました。",
      status: null,
      code: null,
      requestId: null,
      retryAfterMs: null,
      retryable: false,
      fallbackAllowed: false,
      originalError: error,
    };
  }

  const root = isRecord(error)
    ? error
    : {};

  const nestedError = isRecord(root.error)
    ? root.error
    : {};

  const status =
    readNumber(root.status) ??
    readNumber(root.statusCode) ??
    readNumber(nestedError.status);

  const code =
    readString(root.code) ??
    readString(root.type) ??
    readString(nestedError.code) ??
    readString(nestedError.type);

  const requestId =
    readString(root.request_id) ??
    readString(root.requestId) ??
    readString(root._request_id);

  const message =
    error instanceof Error
      ? error.message
      : readString(nestedError.message) ??
        "AI APIで不明なエラーが発生しました。";

  const retryAfterMs =
    parseRetryAfter(
      readHeader(
        root.headers,
        "retry-after",
      ),
    );

  const normalizedCode =
    code?.toLowerCase() ?? "";

  if (
    POLICY_CODES.has(normalizedCode)
  ) {
    return {
      provider,
      kind: "policy",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: false,
      fallbackAllowed: false,
      originalError: error,
    };
  }

  if (
    QUOTA_CODES.has(normalizedCode)
  ) {
    return {
      provider,
      kind: "quota",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: false,
      fallbackAllowed: true,
      originalError: error,
    };
  }

  if (status === 429) {
    return {
      provider,
      kind: "rate_limit",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: true,
      fallbackAllowed: true,
      originalError: error,
    };
  }

  if (
    status === 408 ||
    status === 409 ||
    status === 529 ||
    (status !== null &&
      status >= 500)
  ) {
    return {
      provider,
      kind: "transient",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: true,
      fallbackAllowed: true,
      originalError: error,
    };
  }

  if (
    normalizedCode ===
      "model_not_found" ||
    normalizedCode ===
      "not_found_error" ||
    (
      status === 404 &&
      message
        .toLowerCase()
        .includes("model")
    )
  ) {
    return {
      provider,
      kind: "model_unavailable",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: false,
      fallbackAllowed: true,
      originalError: error,
    };
  }

  if (
    status === 401 ||
    status === 403
  ) {
    return {
      provider,
      kind: "configuration",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: false,
      fallbackAllowed: false,
      originalError: error,
    };
  }

  if (
    status === 400 ||
    status === 404 ||
    status === 422
  ) {
    return {
      provider,
      kind: "invalid_request",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: false,
      fallbackAllowed: false,
      originalError: error,
    };
  }

  if (
    error instanceof TypeError ||
    normalizedCode.includes(
      "connection",
    ) ||
    normalizedCode.includes(
      "timeout",
    )
  ) {
    return {
      provider,
      kind: "transient",
      message,
      status,
      code,
      requestId,
      retryAfterMs,
      retryable: true,
      fallbackAllowed: true,
      originalError: error,
    };
  }

  return {
    provider,
    kind: "unknown",
    message,
    status,
    code,
    requestId,
    retryAfterMs,
    retryable: false,
    fallbackAllowed: false,
    originalError: error,
  };
}

実際のエラーコードは、利用するAPI、SDK、モデルによって異なる可能性があります。

本番環境で受信したコードをログへ残し、自分のサービスに合わせて分類を追加します。

API間で共通のリクエスト形式を作る

OpenAI、Claude、Geminiのリクエスト形式を、アプリケーション全体へ直接露出させると、フォールバック処理が複雑になります。

アプリケーション内部では、共通の入力形式を使用します。

src/ai-provider-interface.ts
type AiGenerateRequest = {
  system: string;
  prompt: string;
  maxOutputTokens: number;
  traceId: string;
};

type AiGenerateResponse = {
  provider: ProviderName;
  model: string;
  text: string;
  requestId: string | null;
  latencyMs: number;
};

interface AiProvider {
  readonly name: ProviderName;
  readonly model: string;

  generate(
    request: AiGenerateRequest,
  ): Promise<AiGenerateResponse>;
}

この形式を各プロバイダー用のアダプターで変換します。

OpenAIではinstructions、Claudeではsystem、Geminiではsystem_instructionへ変換します。

出力上限も、OpenAIではmax_output_tokens、Claudeではmax_tokens、Geminiではgeneration_config.max_output_tokensへ変換します。

OpenAI用のアダプター

OpenAIではResponses APIを使用します。

src/openai-provider.ts
import OpenAI from "openai";

class OpenAIProvider
  implements AiProvider
{
  readonly name = "openai" as const;
  readonly model: string;

  private readonly client: OpenAI;

  constructor(
    apiKey: string,
    model: string,
  ) {
    this.model = model;

    this.client = new OpenAI({
      apiKey,
      maxRetries: 0,
      timeout: 20_000,
    });
  }

  async generate(
    request: AiGenerateRequest,
  ): Promise<AiGenerateResponse> {
    const startedAt =
      performance.now();

    const response =
      await this.client.responses.create(
        {
          model: this.model,
          instructions:
            request.system,
          input: request.prompt,
          max_output_tokens:
            request.maxOutputTokens,
        },
        {
          headers: {
            "X-Client-Request-Id":
              request.traceId,
          },
        },
      );

    const text =
      response.output_text.trim();

    if (!text) {
      throw new Error(
        "OpenAIが空の回答を返しました。",
      );
    }

    return {
      provider: this.name,
      model: this.model,
      text,
      requestId:
        response._request_id ??
        null,
      latencyMs:
        performance.now() -
        startedAt,
    };
  }
}

OpenAIは新規実装でResponses APIを使用する構成を案内しており、公式SDKはresponses.create()を提供しています。

Claude用のアダプター

ClaudeではMessages APIを使用します。

src/anthropic-provider.ts
import Anthropic from "@anthropic-ai/sdk";

class AnthropicProvider
  implements AiProvider
{
  readonly name =
    "anthropic" as const;

  readonly model: string;

  private readonly client: Anthropic;

  constructor(
    apiKey: string,
    model: string,
  ) {
    this.model = model;

    this.client = new Anthropic({
      apiKey,
      maxRetries: 0,
      timeout: 20_000,
    });
  }

  async generate(
    request: AiGenerateRequest,
  ): Promise<AiGenerateResponse> {
    const startedAt =
      performance.now();

    const message =
      await this.client.messages.create({
        model: this.model,
        system: request.system,
        max_tokens:
          request.maxOutputTokens,
        messages: [
          {
            role: "user",
            content:
              request.prompt,
          },
        ],
      });

    const text = message.content
      .filter(
        (
          block,
        ): block is Anthropic.TextBlock =>
          block.type === "text",
      )
      .map((block) => block.text)
      .join("")
      .trim();

    if (
      message.stop_reason ===
      "max_tokens"
    ) {
      throw new Error(
        "Claudeの回答が出力上限で切れました。",
      );
    }

    if (!text) {
      throw new Error(
        "Claudeが空の回答を返しました。",
      );
    }

    return {
      provider: this.name,
      model: this.model,
      text,
      requestId:
        message._request_id ??
        null,
      latencyMs:
        performance.now() -
        startedAt,
    };
  }
}

ClaudeのMessages APIでは、stop_reasonから出力上限、ツール使用、拒否、ターンの一時停止など、生成が終了した理由を確認できます。単にテキストが返ったかどうかだけで完了を判断しないことが重要です。

Gemini用のアダプター

Geminiでは、2026年8月時点で新規プロジェクトにInteractions APIが推奨されています。Interactions APIは単発のテキスト生成、構造化出力、ツール、エージェントなどを共通の形式で扱います。

src/gemini-provider.ts
import {
  GoogleGenAI,
} from "@google/genai";

class GeminiProvider
  implements AiProvider
{
  readonly name =
    "gemini" as const;

  readonly model: string;

  private readonly client:
    GoogleGenAI;

  constructor(
    apiKey: string,
    model: string,
  ) {
    this.model = model;

    this.client =
      new GoogleGenAI({
        apiKey,
        httpOptions: {
          timeout: 20_000,
        },
      });
  }

  async generate(
    request: AiGenerateRequest,
  ): Promise<AiGenerateResponse> {
    const startedAt =
      performance.now();

    const interaction =
      await this.client
        .interactions.create({
          model: this.model,
          system_instruction:
            request.system,
          input: request.prompt,
          generation_config: {
            max_output_tokens:
              request
                .maxOutputTokens,
          },
          store: false,
        });

    const text =
      interaction.output_text
        ?.trim() ?? "";

    if (!text) {
      throw new Error(
        "Geminiが空の回答を返しました。",
      );
    }

    return {
      provider: this.name,
      model: this.model,
      text,
      requestId:
        interaction.id ?? null,
      latencyMs:
        performance.now() -
        startedAt,
    };
  }
}

Gemini Interactions APIは標準ではInteractionを保存し、previous_interaction_idによる会話継続などに利用します。フォールバック用の単発処理では、プロバイダー間で状態管理を統一しやすいようstore: falseを指定できます。

環境変数からプロバイダーを作成する

モデル名はコードへ固定せず、環境変数で設定します。

.env
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=your_openai_model

ANTHROPIC_API_KEY=your_anthropic_key
ANTHROPIC_MODEL=your_anthropic_model

GEMINI_API_KEY=your_gemini_key
GEMINI_MODEL=your_gemini_model

プロバイダーを優先順に登録します。

src/providers.ts
function requiredEnv(
  name: string,
): string {
  const value =
    process.env[name]?.trim();

  if (!value) {
    throw new Error(
      `${name}が設定されていません。`,
    );
  }

  return value;
}

const providers: AiProvider[] = [
  new OpenAIProvider(
    requiredEnv(
      "OPENAI_API_KEY",
    ),
    requiredEnv(
      "OPENAI_MODEL",
    ),
  ),
  new AnthropicProvider(
    requiredEnv(
      "ANTHROPIC_API_KEY",
    ),
    requiredEnv(
      "ANTHROPIC_MODEL",
    ),
  ),
  new GeminiProvider(
    requiredEnv(
      "GEMINI_API_KEY",
    ),
    requiredEnv(
      "GEMINI_MODEL",
    ),
  ),
];

優先順位は品質だけでなく、料金、応答時間、レート制限、リージョン、利用可能な機能を考慮して決定します。

指数バックオフで同じプロバイダーへ再試行する

フォールバック前に、再試行可能なエラーだけを短時間再試行します。

src/call-with-retry.ts
function sleep(
  milliseconds: number,
): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(
      resolve,
      milliseconds,
    );
  });
}

function calculateBackoff(
  retryNumber: number,
  retryAfterMs: number | null,
): number {
  if (retryAfterMs !== null) {
    return retryAfterMs +
      Math.floor(
        Math.random() * 250,
      );
  }

  const base =
    Math.min(
      4_000,
      500 *
        2 ** retryNumber,
    );

  const jitter =
    Math.floor(
      Math.random() * 300,
    );

  return base + jitter;
}

async function callWithRetry(
  provider: AiProvider,
  request: AiGenerateRequest,
  maxRetries: number,
): Promise<AiGenerateResponse> {
  let lastError:
    NormalizedProviderError | null =
      null;

  for (
    let attempt = 0;
    attempt <= maxRetries;
    attempt += 1
  ) {
    try {
      return await provider.generate(
        request,
      );
    } catch (error) {
      const normalized =
        normalizeProviderError(
          provider.name,
          error,
        );

      lastError = normalized;

      console.warn({
        event:
          "ai_provider_attempt_failed",
        traceId:
          request.traceId,
        provider:
          provider.name,
        model:
          provider.model,
        attempt:
          attempt + 1,
        kind:
          normalized.kind,
        status:
          normalized.status,
        code:
          normalized.code,
        requestId:
          normalized.requestId,
        retryable:
          normalized.retryable,
        fallbackAllowed:
          normalized.fallbackAllowed,
      });

      const canRetry =
        normalized.retryable &&
        attempt < maxRetries;

      if (!canRetry) {
        throw normalized;
      }

      const delay =
        calculateBackoff(
          attempt,
          normalized.retryAfterMs,
        );

      await sleep(delay);
    }
  }

  throw (
    lastError ??
    new Error(
      "AI APIの呼び出しに失敗しました。",
    )
  );
}

GoogleもGemini APIの一時エラーについて、指数バックオフ、ランダムなジッター、最大試行回数の設定を推奨しています。

サーキットブレーカーを実装する

特定プロバイダーが継続的に障害を起こしているとき、すべてのユーザーリクエストで同じ失敗を繰り返すのは非効率です。

一定回数連続して失敗したプロバイダーを、一時的に候補から外します。

この仕組みをサーキットブレーカーと呼びます。

src/circuit-breaker.ts
type CircuitState = {
  consecutiveFailures: number;
  openUntil: number;
};

class ProviderCircuitBreaker {
  private readonly states =
    new Map<
      ProviderName,
      CircuitState
    >();

  constructor(
    private readonly threshold = 3,
    private readonly openMs =
      60_000,
  ) {}

  canRequest(
    provider: ProviderName,
  ): boolean {
    const state =
      this.states.get(provider);

    if (!state) {
      return true;
    }

    return (
      state.openUntil <=
      Date.now()
    );
  }

  recordSuccess(
    provider: ProviderName,
  ): void {
    this.states.delete(provider);
  }

  recordFailure(
    provider: ProviderName,
  ): void {
    const previous =
      this.states.get(provider);

    const consecutiveFailures =
      (
        previous
          ?.consecutiveFailures ??
        0
      ) + 1;

    const openUntil =
      consecutiveFailures >=
      this.threshold
        ? Date.now() +
          this.openMs
        : 0;

    this.states.set(provider, {
      consecutiveFailures,
      openUntil,
    });
  }
}

サーキットが開いている間は、そのプロバイダーを呼び出さず、次の候補へ進みます。

一定時間が経過したら再び一件だけ試し、成功すれば通常状態へ戻します。

自動フォールバックを実装する

再試行、エラー分類、サーキットブレーカーを組み合わせます。

src/generate-with-fallback.ts
const circuitBreaker =
  new ProviderCircuitBreaker(
    3,
    60_000,
  );

type FallbackResult = {
  response:
    AiGenerateResponse;
  attemptedProviders:
    ProviderName[];
};

export async function generateWithFallback(
  request: AiGenerateRequest,
): Promise<FallbackResult> {
  const attemptedProviders:
    ProviderName[] = [];

  const failures:
    NormalizedProviderError[] = [];

  for (const provider of providers) {
    if (
      !circuitBreaker.canRequest(
        provider.name,
      )
    ) {
      console.warn({
        event:
          "ai_provider_skipped",
        traceId:
          request.traceId,
        provider:
          provider.name,
        reason:
          "circuit_open",
      });

      continue;
    }

    attemptedProviders.push(
      provider.name,
    );

    try {
      const response =
        await callWithRetry(
          provider,
          request,
          1,
        );

      circuitBreaker.recordSuccess(
        provider.name,
      );

      console.info({
        event:
          "ai_provider_succeeded",
        traceId:
          request.traceId,
        provider:
          response.provider,
        model:
          response.model,
        requestId:
          response.requestId,
        latencyMs:
          Math.round(
            response.latencyMs,
          ),
        fallbackUsed:
          attemptedProviders.length >
          1,
      });

      return {
        response,
        attemptedProviders,
      };
    } catch (error) {
      const normalized =
        isNormalizedProviderError(
          error,
        )
          ? error
          : normalizeProviderError(
              provider.name,
              error,
            );

      failures.push(normalized);

      if (
        normalized
          .fallbackAllowed
      ) {
        circuitBreaker.recordFailure(
          provider.name,
        );

        continue;
      }

      throw normalized;
    }
  }

  throw new AggregateError(
    failures,
    "利用可能なAIプロバイダーがありません。",
  );
}

function isNormalizedProviderError(
  value: unknown,
): value is NormalizedProviderError {
  return (
    isRecord(value) &&
    typeof value.provider ===
      "string" &&
    typeof value.kind ===
      "string" &&
    typeof value.retryable ===
      "boolean" &&
    typeof value.fallbackAllowed ===
      "boolean"
  );
}

この実装では、各プロバイダーを最大2回試します。

最初の呼び出しが失敗し、再試行可能であれば一度だけ再試行します。

それでも失敗し、フォールバック可能なエラーであれば次のプロバイダーへ進みます。

400、401、403、安全性ブロックなどは、自動的に次のプロバイダーへ送りません。

呼び出し側の実装

トレースIDを作成し、すべてのプロバイダー呼び出しへ共通して付けます。

src/main.ts
import {
  randomUUID,
} from "node:crypto";

async function main(): Promise<void> {
  const traceId =
    randomUUID();

  const result =
    await generateWithFallback({
      traceId,
      system: `
あなたは技術サポート担当です。
確認できない内容を推測してはいけません。
回答は日本語で作成してください。
      `.trim(),
      prompt:
        "Node.jsのイベントループを説明してください。",
      maxOutputTokens: 1_000,
    });

  console.log({
    provider:
      result.response.provider,
    model:
      result.response.model,
    attemptedProviders:
      result.attemptedProviders,
    text:
      result.response.text,
  });
}

void main();

レスポンスには、実際に回答したプロバイダーとモデルを含めます。

障害調査だけでなく、品質評価や料金分析にも利用できます。

全体のタイムアウトを設定する

各プロバイダーのタイムアウトを20秒にすると、3社すべてが失敗した場合に60秒以上かかる可能性があります。

ユーザー向けのリアルタイム処理では、フォールバック全体に時間予算を設定します。

src/generate-with-budget.ts
const TOTAL_BUDGET_MS =
  35_000;

export async function generateWithBudget(
  request: AiGenerateRequest,
): Promise<FallbackResult> {
  const startedAt =
    Date.now();

  for (const provider of providers) {
    const elapsed =
      Date.now() -
      startedAt;

    const remaining =
      TOTAL_BUDGET_MS -
      elapsed;

    if (remaining <= 0) {
      throw new Error(
        "AI生成の全体タイムアウトに達しました。",
      );
    }

    // remainingを基にプロバイダー単位の
    // タイムアウトを決定する
  }

  throw new Error(
    "回答を生成できませんでした。",
  );
}

優先プロバイダーへ30秒、次のプロバイダーへさらに30秒待つのではなく、全体で許容できる時間から各プロバイダーの時間を割り当てます。

対話型の画面では短くし、バッチ処理では長くするなど、用途ごとに設定を分けます。

ストリーミング開始後は自動で切り替えない

ストリーミングでは、最初のプロバイダーから一部の文章をユーザーへ送信したあとに通信が切れることがあります。詳しい原因と対策はOpenAI APIのストリーミングが途中で切れる原因で解説しています。

その時点で別プロバイダーへ同じプロンプトを送ると、新しい回答は最初から生成されます。

すでに表示した文章へ新しい回答を追加すると、冒頭が重複したり、文体や結論が変化したりします。

安全な設計では、最初のトークンをユーザーへ送る前までだけ自動フォールバックを許可します。

一文字でも送信したあとに切断した場合は、「生成が途中で停止しました」と表示し、ユーザーに再生成を依頼する方法が分かりやすくなります。

続きを生成させる場合は、表示済みの文章をフォールバック先へ渡し、重複させず続きを書くよう指示できます。

ただし、同じ文章を正確に継続できる保証はありません。

完全性が重要なJSON、コード、契約文書などでは、途中の結果を破棄して最初から再生成します。

Structured Outputsは共通仕様ではない

OpenAI、Claude、Geminiはいずれも構造化出力に対応していますが、対応するJSON Schema、指定方法、制約は完全には同じではありません。

OpenAI用のresponse_formatやResponses APIのtext.formatを、そのままClaudeやGeminiへ渡すことはできません。

AnthropicはOpenAI SDK互換レイヤーも提供していますが、一部フィールドは無視され、Claude固有機能をすべて利用するにはネイティブAPIが推奨されています。たとえば互換レイヤーではresponse_formatが無視されるため、構造化出力にはClaudeのネイティブAPIを使用する必要があります。

フォールバックでJSONを必要とする場合は、共通のZodスキーマを用意し、各プロバイダーの形式へ個別に変換します。

取得後は必ずZodで再検証します。

src/validate-fallback-json.ts
import { z } from "zod";

const AnswerSchema = z.object({
  answer: z.string(),
  confidence: z.number()
    .min(0)
    .max(1),
});

const parsed =
  AnswerSchema.safeParse(
    providerOutput,
  );

if (!parsed.success) {
  throw new Error(
    "フォールバック先のJSONがスキーマに一致しません。",
  );
}

プロバイダーが正常に応答しても、必要な形式を満たさなければ成功として扱わない設計が必要です。

Function Callingのフォールバックはさらに注意が必要

Function Callingのツール定義も、各APIでメッセージ形式や結果の返し方が異なります。ツールの終了条件設計はFunction Callingが無限ループする原因で詳しく解説しています。

単純な天気検索など、読み取り専用のツールは別プロバイダーへ再実行しやすい処理です。

一方、メール送信、注文確定、返金、ファイル削除、データ更新などのツールは、フォールバック先で再実行してはいけません。

最初のプロバイダーがツール実行を要求し、アプリケーションが注文を確定した直後にモデルとの通信が切れた場合を考えます。

別プロバイダーへ最初から処理を送ると、同じ注文確定ツールが再び呼ばれる可能性があります。

副作用を伴うツールには、アプリケーション側で冪等性キーを設定します。

src/create-order-input.ts
type CreateOrderInput = {
  operationId: string;
  userId: string;
  productId: string;
  quantity: number;
};

同じoperationIdで処理済みの場合は、新しい注文を作らず、以前の結果を返します。

src/idempotent-order.ts
const existing =
  await findOrderByOperationId(
    input.operationId,
  );

if (existing) {
  return {
    status: "already_completed",
    orderId: existing.id,
  };
}

ツール実行後のモデル応答だけが失敗した場合は、別プロバイダーへツールを再実行させるのではなく、確定済みのツール結果だけを渡して最終回答を生成させます。

会話状態をプロバイダーへ依存させない

OpenAI、Claude、Geminiは、複数ターンの会話を管理する方法が異なります。

OpenAIのレスポンスIDやGeminiのprevious_interaction_idだけをデータベースへ保存すると、別プロバイダーへ切り替えたときに会話履歴を再現できません。

フォールバックを行う場合は、アプリケーション側に正規化した会話履歴を保存します。

src/canonical-message.ts
type CanonicalMessage = {
  role:
    | "system"
    | "user"
    | "assistant"
    | "tool";
  content: string;
  provider?: ProviderName;
};

各プロバイダーを呼び出す直前に、そのAPIの形式へ変換します。

Gemini Interactions APIのサーバー側状態管理は便利ですが、別プロバイダーへ切り替える可能性がある会話では、必要な履歴を自分のデータベースにも保持します。

プロバイダーごとの能力を登録する

すべてのモデルが同じ機能を持っているとは限りません。

テキストだけの処理は多くのモデルへ切り替えられますが、画像、PDF、音声、特定のツール、長いコンテキスト、JSON Schemaなどは対応状況が異なります。

プロバイダー設定へ能力を登録します。

src/provider-capabilities.ts
type ProviderCapabilities = {
  text: boolean;
  vision: boolean;
  structuredOutput: boolean;
  functionCalling: boolean;
  streaming: boolean;
  requiredFeatures:
    Set<string>;
};

type ProviderDefinition = {
  provider: AiProvider;
  capabilities:
    ProviderCapabilities;
};

リクエストに必要な機能を満たさないプロバイダーは、フォールバック候補から除外します。

src/supports-request.ts
function supportsRequest(
  capabilities:
    ProviderCapabilities,
  required:
    readonly string[],
): boolean {
  return required.every(
    (feature) =>
      capabilities
        .requiredFeatures
        .has(feature),
  );
}

画像入力をテキスト専用モデルへ送ったり、Structured Outputsが必要な処理を非対応モデルへ切り替えたりする問題を防げます。

品質によるフォールバック条件も設定する

APIがHTTP 200を返しても、回答が利用できない場合があります。

空の出力、途中で切れた回答、壊れたJSON、必須項目の欠落、禁止された形式などが該当します。

このような状態をアプリケーション側の検証エラーとして扱い、別プロバイダーへ切り替える方法があります。

ただし、「回答内容が気に入らない」という曖昧な理由で無制限に別プロバイダーへ送ると、料金と待ち時間が増えます。

検証条件は機械的に判定できるものへ限定します。

src/validate-text-output.ts
function validateTextOutput(
  text: string,
): void {
  if (!text.trim()) {
    throw new Error(
      "回答が空です。",
    );
  }

  if (text.length < 20) {
    throw new Error(
      "回答が短すぎます。",
    );
  }

  if (
    text.includes(
      "[INCOMPLETE]",
    )
  ) {
    throw new Error(
      "回答が未完了です。",
    );
  }
}

JSON出力ではZod、コード生成では構文解析やテスト、引用付き回答では出典IDの存在を検証します。

常に最も安いモデルへ切り替えるとは限らない

フォールバック先を料金だけで決めると、回答品質や必要な機能を維持できない可能性があります。

高精度なコードレビューを行っていたモデルから、軽量な分類用モデルへ切り替えれば、API自体は成功してもサービス品質が大きく変わります。

フォールバック先には、同等品質のモデル、品質を下げた縮退運転用モデル、最小限のエラーメッセージを返すモデルなど、役割を設定します。

フォールバックの層
type FallbackTier =
  | "equivalent"
  | "degraded"
  | "emergency";

同等モデルが利用できない場合は、ユーザーへ「現在は簡易回答モードです」と表示する方法もあります。

プロバイダーが変わったことを必ず一般ユーザーへ表示する必要はありませんが、品質や機能が変化する場合は明示したほうが安全です。

料金上限を設定する

3社すべてへ再試行を含めて送ると、一つのユーザー操作で複数回の課金が発生します。

フォールバック全体に回数と予算の上限を設定します。

src/fallback-budget.ts
type FallbackBudget = {
  maxProviderAttempts: number;
  maxEstimatedCostUsd: number;
  maxDurationMs: number;
};

高額な長文処理では、同じ長い入力を3社へ送るだけで大きな費用が発生する可能性があります。

対話処理では最大2社まで、重要なバッチ処理では最大3社までなど、用途によって上限を変えます。

フォールバック率を監視する

フォールバックが成功すると、ユーザーには障害が見えません。

しかし、優先プロバイダーが毎回失敗し、常に第2候補が応答している状態を放置すると、料金や品質が想定と異なるまま運用されます。

少なくとも、プロバイダー別の成功率、再試行率、フォールバック率、レート制限数、タイムアウト数、平均応答時間、入力・出力トークン数を記録します。

src/ai-call-log.ts
type AiCallLog = {
  traceId: string;
  provider: ProviderName;
  model: string;
  attempt: number;
  success: boolean;
  fallbackUsed: boolean;
  errorKind: ErrorKind | null;
  status: number | null;
  code: string | null;
  requestId: string | null;
  latencyMs: number;
  inputTokens: number | null;
  outputTokens: number | null;
  recordedAt: string;
};

優先プロバイダーの成功率が低下したら、自動的に順序を変更する前に原因を確認します。

レート制限であれば同時実行数を抑え、支出上限であれば予算設定を見直し、モデル廃止であればモデル名を更新します。

ヘルスチェックだけで障害を判断しない

数分ごとに短いプロンプトを送り、各AI APIの状態を確認する方法があります。

しかし、短いテストリクエストが成功しても、長い入力、画像、ツール実行、特定モデルだけが失敗している可能性があります。

反対に、一件のヘルスチェック失敗だけでプロバイダー全体を停止すると、偶発的なネットワークエラーによって不要なフォールバックが発生します。

通常のユーザーリクエストから取得した成功率やレイテンシを中心に判断し、一定時間内の連続失敗でサーキットを開く方法が現実的です。

プロバイダーの公式ステータス情報は補助的な判断材料として使用します。

認証エラーをフォールバックで隠さない

APIキーの期限切れや権限不足が発生したとき、別プロバイダーが回答できればサービスは継続します。

しかし、優先プロバイダーの認証エラーが何日も見つからない状態になる可能性があります。

401、403、課金設定エラー、存在しないモデルなどを検出した場合は、ユーザー向けのフォールバックとは別に運用アラートを発生させます。

src/operations-alert.ts
if (
  error.kind ===
    "configuration" ||
  error.kind ===
    "model_unavailable" ||
  error.kind ===
    "quota"
) {
  await sendOperationsAlert({
    provider:
      error.provider,
    status:
      error.status,
    code:
      error.code,
    requestId:
      error.requestId,
  });
}

秘密情報をログや通知へ含めてはいけません。

APIキー、ユーザーのプロンプト全文、機密文書などは除外し、トレースIDとリクエストIDを中心に記録します。

フォールバックのテスト方法

実際の障害が起きるまで待つのではなく、テスト環境でエラーを注入します。

OpenAIアダプターが429を返す状態、Claudeが503を返す状態、Geminiがタイムアウトする状態をモックします。

400や安全性ブロックでは次のプロバイダーへ進まないことも確認します。

src/failing-provider.ts
class FailingProvider
  implements AiProvider
{
  readonly name =
    "openai" as const;

  readonly model =
    "test-model";

  async generate():
    Promise<AiGenerateResponse> {
    const error =
      new Error(
        "Rate limit exceeded",
      ) as Error & {
        status: number;
        code: string;
      };

    error.status = 429;
    error.code =
      "rate_limit_exceeded";

    throw error;
  }
}

ストリーミングでは最初のチャンク送信前と送信後の両方をテストします。

Function Callingでは、ツール実行前の失敗、実行後の失敗、同じ冪等性キーによる再実行を確認します。

プロバイダーごとに評価データを用意する

同じプロンプトをOpenAI、Claude、Geminiへ送っても、文章の長さ、JSONの作り方、ツール選択、拒否の傾向などが異なります。

APIが利用可能であることだけでなく、サービスの要求品質を満たすかを評価します。

実際の入力に近いテストデータを用意し、正確性、形式遵守、レイテンシ、料金をプロバイダーとモデルごとに記録します。

フォールバック先のモデルを変更した場合も、同じ評価を実行します。

本番障害の最中に初めて利用するモデルへ切り替えるのではなく、平常時から少量のシャドー評価を行っておくと安全です。

ただし、シャドー評価では同じ入力を複数プロバイダーへ送るため、ユーザー同意、データ保持、機密情報の取り扱いを確認する必要があります。

フォールバックの優先順位を固定しすぎない

すべての処理でOpenAI、Claude、Geminiの順にする必要はありません。

コード生成ではプロバイダーA、長文要約ではプロバイダーB、画像理解ではプロバイダーCを優先するなど、処理種別ごとに順番を変えられます。

src/routing.ts
type TaskType =
  | "chat"
  | "coding"
  | "classification"
  | "document"
  | "vision";

const routing:
  Record<
    TaskType,
    ProviderName[]
  > = {
  chat: [
    "openai",
    "anthropic",
    "gemini",
  ],
  coding: [
    "anthropic",
    "openai",
    "gemini",
  ],
  classification: [
    "gemini",
    "openai",
    "anthropic",
  ],
  document: [
    "gemini",
    "anthropic",
    "openai",
  ],
  vision: [
    "openai",
    "gemini",
    "anthropic",
  ],
};

この順番は一例です。

実際のモデル名、料金、品質評価、リージョン、利用制限を基に決定します。

OpenAI互換APIだけに統一する場合の注意点

ClaudeやGeminiには、OpenAI形式との互換機能や互換レイヤーが用意される場合があります。

一つのSDKへ統一できるため、単純なテキスト生成では実装を減らせます。

一方、各社固有の機能やパラメータが失われる可能性があります。

AnthropicのOpenAI SDK互換機能では、対応していないフィールドが無視されたり、システムメッセージの扱いがClaudeのネイティブAPI向けに変換されたりします。Anthropicも、Claude APIの全機能を利用する場合はネイティブAPIを推奨しています。

本番のフォールバックでは、共通部分だけを使う簡易ルートと、プロバイダー固有機能を使うネイティブルートを分ける方法があります。

OpenAI・Claude・Geminiのフォールバックに関するよくある質問

Q429が出たらすぐ別プロバイダーへ切り替えるべきですか?

A短時間のレート制限であれば、Retry-Afterや指数バックオフに従って同じプロバイダーへ一度再試行してください。それでも失敗した場合は別プロバイダーへ切り替えます。クレジット残高不足や日次クォータ超過の場合は、短時間の再試行では直らないため、再試行せずフォールバックできます。

Q400エラーでも別プロバイダーなら動く可能性はありますか?

A可能性はありますが、自動フォールバックの標準条件にはしないほうが安全です。400は入力形式やパラメータの問題を示すため、プロバイダーごとの変換コードに不具合がある可能性があります。別プロバイダーへ送る前に、入力とアダプターを修正してください。

Q三つのAPIへ同時に送り、最初の回答を使ってもよいですか?

Aレイテンシを短縮できる可能性はありますが、毎回複数社へ課金されます。同じユーザーデータを複数プロバイダーへ送ることにもなるため、データ管理上の確認が必要です。通常は優先プロバイダーを先に呼び、障害時だけ次へ進む構成のほうがコストを抑えられます。

Qフォールバック先で回答内容が変わるのは問題ですか?

A生成AIは同じプロンプトでも回答が変わります。プロバイダーが変われば、文体、詳細度、判断、JSON形式も変化する可能性があります。重要な処理では、共通プロンプト、出力スキーマ、検証処理、評価データを用意し、許容範囲内に収まることを確認してください。

Q一つのプロバイダー内でモデルを切り替えてから別会社へ移るべきですか?

A同じプロバイダーの別モデルが利用可能であれば、先にモデル内フォールバックを行う方法があります。ただし、プロバイダー全体のレート制限や障害では、別モデルも失敗する可能性があります。同一プロバイダー内のモデル切り替え回数を制限し、その後に別プロバイダーへ進んでください。

まとめ

OpenAI、Claude、Geminiを自動で切り替える場合は、単純に例外をcatchして次のAPIを呼ぶだけでは不十分です。

最初に、再試行できる一時エラー、別プロバイダーへ切り替えられるエラー、直ちに終了すべき入力・認証・安全性エラーを分類します。

408、429、500番台、ネットワークエラー、タイムアウトなどは、同じプロバイダーへ少数回再試行したあと、別プロバイダーへフォールバックできます。

400、401、403、422、安全性ブロック、ユーザーによるキャンセルは、原則として自動フォールバックしません。

OpenAI、Claude、Geminiのリクエストを直接混在させず、アプリケーション内部に共通の入力・出力形式を作り、プロバイダーごとのアダプターで変換します。

SDKの自動再試行とアプリケーションの再試行を重ねないよう、OpenAIとClaudeではmaxRetries: 0を設定し、フォールバック層で回数を一元管理できます。

継続的な障害にはサーキットブレーカーを使用し、一定時間そのプロバイダーを呼び出さないようにします。

ストリーミング開始後は、文章の重複や不整合を防ぐため、別プロバイダーへの自動切り替えを避けます。

Function Callingでは、注文確定やメール送信などの副作用を伴うツールへ冪等性キーを設定し、フォールバックによる重複実行を防ぎます。

最終的には、フォールバック率、エラー種別、リクエストID、応答時間、料金、回答品質を記録し、障害を隠す仕組みではなく、障害から安全に回復できる仕組みとして運用することが重要です。OpenAI APIの429エラー対処はOpenAI APIの429エラーを直す方法、ストリーミング切断はOpenAI APIのストリーミングが途中で切れる原因、Function Callingの終了条件設計はFunction Callingが無限ループする原因もあわせてご覧ください。