今週、Gemini API の広範な障害で、毎晩動かしている集計バッチが3時間分の処理を取りこぼしました。見慣れない番号のエラーが立て続けに返り、リトライも全部同じ顔で落ちていく。朝にダッシュボードを見た瞬間、「これはこちら側では何もできない種類の障害」と分かりましたが、本当の問題はそこではありませんでした。復旧したあと、取りこぼした3時間分を安全に流し直す手段を、私は用意していなかったのです。
幸い実害は軽く済みました。ただ、個人開発のサービスは自分が寝ている間も動き続けます。この機会に夜間バッチの防御を三層構成に組み替えたので、設計の判断と実装をまとめて残しておきます。前提は Node.js と公式 SDK の構成ですが、考え方は他の言語でもそのまま通じるはずです。
失敗を4種類に分けるところから始める
障害対応のコードを書く前に、まず「失敗」を分類しました。すべてを同じリトライ処理に放り込むのが一番危険だからです。
- 一過性の失敗: 429 のレート超過、503 や今回のような広域障害。時間を置けば直る見込みがあります
- 恒久的な失敗: 400 系の入力不正や認証エラー。何度送っても直りません
- ネットワーク層の失敗: タイムアウトや接続断。リクエストが届いたかどうか不明な点が厄介です
- 品質の失敗: ステータスは 200 でも、出力が壊れていて後段で使えないケース
この分類で大事なのは、機械的に再試行してよいのは1番目と3番目だけという点です。400 を再送し続けるのはクォータの無駄遣いですし、品質の失敗をネットワークと同じ仕組みで再試行すると、同じ壊れた出力を高い確率でもう一度受け取るだけです。品質の失敗には、温度を下げる・プロンプトを変えるといった「条件を変えた再試行」が要ります。
第一層: 再試行してよい失敗だけをリトライする
分類が決まれば、リトライ処理は短く書けます。
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
type FailureKind = "transient" | "permanent" | "network";
function classifyError(err: unknown): FailureKind {
const status = (err as { status?: number }).status;
if (status === 429 || (status !== undefined && status >= 500)) return "transient";
if (status !== undefined && status >= 400) return "permanent";
return "network"; // fetch 失敗・タイムアウトなど status が取れないもの
}
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (classifyError(err) === "permanent") throw err; // 再送しても直らない
const base = 1000 * 2 ** attempt;
const jitter = Math.random() * base * 0.5;
await new Promise((resolve) => setTimeout(resolve, base + jitter));
}
}
throw lastError;
}ポイントは2つあります。まず、ジッター(待ち時間の乱数)を省かないこと。障害復旧の瞬間に全クライアントが同時に再送すると、それ自体が二次障害の引き金になります。次に、試行回数は4回程度で潔く諦めること。広域障害は数十分から数時間続くため、その場で粘るより、後述のキューに積んで撤退するほうが全体としては早く片づきます。
第二層: モデルのフォールバックチェーン
リトライで救えないとき、次に試すのはモデルの切り替えです。私の場合は gemini-3.5-flash を主にして、失敗時は gemini-3.1-flash へ落とす二段構えにしています。
const MODEL_CHAIN = ["gemini-3.5-flash", "gemini-3.1-flash"];
async function generateWithFallback(
prompt: string
): Promise<{ text: string; degraded: boolean }> {
for (const [index, model] of MODEL_CHAIN.entries()) {
try {
const res = await withRetry(() =>
ai.models.generateContent({ model, contents: prompt })
);
return { text: res.text ?? "", degraded: index > 0 };
} catch {
// チェーンの次のモデルへ
}
}
throw new Error("all models in chain failed");
}注意したいのは、フォールバック先の出力品質を平時に確認しておくことです。障害の真っ最中に「代替モデルの出力が後段の処理を通らない」と気づくのが最悪のパターンなので、私はこのチェーンを月に一度、わざと主モデルを外して通しています。degraded フラグを結果に残しておくと、あとから品質を点検する対象も絞れます。
ただ、正直に書いておくと、今回のような広域障害ではフォールバック先も一緒に落ちる時間帯がありました。同一基盤の障害に対して、同一ベンダー内の迂回は思ったほど効きません。設計時の想定より厳しい現実でしたが、だからこそ次の第三層が要ります。
第三層: 縮退運転 — 止めないことを優先する
最後の層は「AI なしでも壊れない」ことです。私のバッチで Gemini が担っているのは要約と分類で、それが無くても元データの表示は成立します。そこでチェーン全滅時は、前回実行時の結果をそのまま使い回し、画面には「最新の分析は一時的に更新を停止しています」と小さく出すだけにしました。
縮退の設計で効いたのは、「何が欠けても画面が成立するか」を機能ごとに書き出したことです。一覧にしてみると、絶対に止められない処理は思ったより少ないものでした。AI の出力は多くの場面で「あれば嬉しい付加情報」であって、土台ではありません。土台でない部分の障害でサービス全体を道連れにしない。当たり前のようでいて、書き出すまで私はこの線引きを曖昧にしたままでした。
障害をどう検知するか — アラートの閾値で迷った話
三層の防御を組んだあと、最後に残ったのが検知の問題でした。単発の失敗でアラートを鳴らすと、平時の散発的な 503 で通知が埋まり、本当の障害の日に通知を読まなくなります。私自身、この「アラート疲れ」で重要な通知を見落とした経験が一度あります。
落ち着いた先は、単発の失敗率ではなく「連続失敗数」と「5分窓の失敗率」の二段構えです。連続5件の失敗、または5分間で失敗率が半分を超えたときだけ通知する。この閾値にしてから、平時の誤報はなくなり、今回の障害では開始から数分で通知が届きました。
もうひとつの発見は、自前のエラー分類カウンタのほうが公式のステータスページより早いことです。ステータスページの更新には時間差があります。classifyError が transient を数え始めた時点で異常は確定しているので、検知は自分のメトリクスを一次情報にして、ステータスページは「裏取り」に使う運用へ変えました。
冪等性がないと再試行は事故になる
三層の防御より地味で、しかし今回いちばん反省したのがここです。私自身、リトライ処理は書き慣れたつもりでいましたが、書き込み側の冪等化を後回しにしていました。
タイムアウトした呼び出しは「届いていない」とは限りません。届いて、処理されて、応答だけが落ちた可能性があります。そのため、書き込みを伴う処理には冪等キーを付けました。バッチの各項目に「日付 + 項目ID」のキーを振り、結果テーブルへの書き込みを upsert に変える。これだけで「同じ項目を二度処理して二重に書き込む」事故は構造的に起きなくなります。リトライ機構を足すなら、その前に書き込みの冪等化を済ませる。順番としてはこちらが先だった、というのが今回の学びです。
復旧後の追い付き処理と、結局いちばん効いたもの
障害が明けたあとの再処理にも設計が要ります。今回の私の失敗は、処理に失敗した項目の記録が「エラーログの中」にしかなかったことでした。ログから対象を拾い直すのは、二度とやりたくない作業です。
組み替え後は、失敗した項目を pending_retry テーブルに積み、バッチの冒頭で「積み残しがあれば先に消化する」一手を入れました。これで復旧後は次の定時実行が自然に追い付き処理を兼ねます。専用の復旧スクリプトを書かずに済む構成にしておくと、障害のたびの手作業が消えます。
振り返ると、今回の障害で効いたのは高度な仕組みではなく、「失敗の分類」「冪等キー」「積み残しキュー」という地味な三点でした。夜間バッチを Gemini API に依存させているなら、次の障害が来る前に、失敗した項目がどこに記録されるかだけでも確認しておくと、復旧の朝がずいぶん楽になるはずです。