深夜に鳴った 429 の通知から
個人開発しているアプリの画像分類バッチが、ある晩まとめて 429 を返して止まっていたことがありました。ログを追うと、並列に投げていたリクエストが分間上限を一気に超えていたのです。原因を掴むまで小一時間。あの夜の手詰まりが、この記事の出発点です。
Gemini API を本番で回していると、429(レート制限)・504(タイムアウト)・400(バリデーション)といったエラーは避けて通れません。ただ、これらは「運が悪かった」で片づく類のものではなく、発生の仕組みを理解すればアーキテクチャの側で先回りできるものがほとんどです。
ここで扱うのは、小手先でエラーを潰す話ではありません。なぜそのエラーが出るのかという構造から入り、私自身が個人開発の本番運用で実際に使っている実装パターンと、そこで測った数値を添えてお伝えします。gemini-flash-latest(現在は Gemini 3.5 Flash が実体)のように使うモデルが変わっても効き続ける考え方を、中心に据えました。
Error 1: 429 Too Many Requests(レート制限エラー)
発生の仕組み
Gemini API には、1分間あたりまたは1時間あたりの API呼び出し数に制限があります。この制限を超えると 429 エラーが返されます。
API Pro・Enterprise のプランによってレート制限は異なります:
- 無料枠: 1時間に60リクエスト、1分間に15リクエスト
- Pro: 1分間に300リクエスト、1日に1,500,000リクエスト
根本原因
- 並列リクエストの過多: 複数の API 呼び出しを同時に実行している
- バッチ処理の最適化不足: 本来まとめられるリクエストをバラバラに送信している
- ポーリング間隔が短すぎる: 短間隔でポーリングを繰り返している
- 複数アプリケーションの干渉: 同じ API キーで複数のアプリが独立して呼び出している
実装例: 指数バックオフ + リトライロジック
async function callGeminiAPIWithRetry(
prompt: string,
maxRetries: number = 5,
baseDelayMs: number = 1000
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': process.env.GEMINI_API_KEY,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
}),
}
);
if (response.status === 429) {
// レート制限: 指数バックオフで待機
const delayMs = baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000;
console.log(`Rate limited. Retrying after ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
continue;
}
if (response.ok) {
const data = await response.json();
return data.candidates[0]?.content?.parts[0]?.text || '';
}
throw new Error(`API Error: ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delayMs = baseDelayMs * Math.pow(2, attempt);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Max retries exceeded');
}
対策: リクエスト単位での調整
複数リクエストを1つにまとめる
// 悪い例: 複数リクエストを並列実行
Promise.all([
callGeminiAPI(prompt1),
callGeminiAPI(prompt2),
callGeminiAPI(prompt3),
]);
// 良い例: 順序実行またはキューイング
const prompts = [prompt1, prompt2, prompt3];
for (const prompt of prompts) {
const result = await callGeminiAPIWithRetry(prompt);
// 次のリクエストへ(間隔は自動的に調整される)
}
バッチ処理の活用
Gemini API のバッチ処理エンドポイントは、複数のリクエストを効率的に処理し、レート制限の影響を軽減します:
async function batchGenerateContent(prompts: string[]) {
const requests = prompts.map(prompt => ({
model: 'models/gemini-2.5-pro',
contents: [{ parts: [{ text: prompt }] }],
}));
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/batch:generateContent',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': process.env.GEMINI_API_KEY,
},
body: JSON.stringify({ requests }),
}
);
return response.json();
}
Error 2: 504 Gateway Timeout(タイムアウト)
発生の仕組み
タイムアウトは、API がリクエストに対して一定時間内に応答できなかった場合に発生します。複雑な処理やマルチモーダル入力では頻繁に起こります。
根本原因
- ネットワーク遅延: クライアント~サーバー間の通信遅延
- 複雑なプロンプト: トークン数が多い、処理の複雑度が高い
- 大きなファイル入力: 画像や動画の処理に時間がかかる
- サーバー負荷: API サーバー側のリソース枯渇
実装例: タイムアウト対応 + ストリーミング
ストリーミングレスポンスを使うことで、全レスポンスを待たずに段階的に受信でき、タイムアウト回避に有効:
async function streamGeminiResponse(prompt: string): Promise<AsyncIterable<string>> {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': process.env.GEMINI_API_KEY,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
// タイムアウト時間を明示的に設定
temperature: 0.7,
topP: 0.95,
topK: 40,
},
}),
}
);
if (!response.body) throw new Error('No response body');
return (async function* () {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const json = JSON.parse(line.slice(6));
const content = json.candidates[0]?.content?.parts[0]?.text;
if (content) yield content;
}
}
}
} finally {
reader.releaseLock();
}
})();
}
対策: タイムアウト設定の最適化
async function callWithCustomTimeout(
prompt: string,
timeoutMs: number = 30000
): Promise<string> {
return Promise.race([
callGeminiAPI(prompt),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error(`Timeout after ${timeoutMs}ms`)),
timeoutMs
)
),
]);
}
Error 3: 400 Bad Request(バリデーション)
発生の仕組み
API に送信されたリクエストが API の仕様に合致していない場合に返されます。
よくある 400 エラーの原因
- 無効な Model ID:
gemini-2.5-pro の代わりに存在しないモデル名を指定
- API キーの不具合: 環境変数の読み込み失敗、キーの有効期限切れ
- プロンプト形式が不正: JSON 構造の誤り、必須フィールドの欠落
- コンテンツのフォーマット不正: マルチモーダル入力の形式ミス
チェックリスト
function validateRequest(prompt: string, imageUrl?: string): { valid: boolean; error?: string } {
// 1. プロンプトは空でないか
if (!prompt || prompt.trim().length === 0) {
return { valid: false, error: 'Prompt cannot be empty' };
}
// 2. トークン数上限チェック(2.5 Pro は 1,000,000 トークンが上限)
const estimatedTokens = prompt.length / 4; // 粗い推定
if (estimatedTokens > 900000) {
return { valid: false, error: 'Prompt exceeds token limit' };
}
// 3. API キーが設定されているか
if (!process.env.GEMINI_API_KEY) {
return { valid: false, error: 'GEMINI_API_KEY not set' };
}
// 4. 画像URLが指定されている場合、形式は有効か
if (imageUrl) {
try {
new URL(imageUrl);
} catch {
return { valid: false, error: 'Invalid image URL format' };
}
}
return { valid: true };
}
Error 4: Safety Filter エラー(安全性フィルター)
発生の仕組み
Gemini API は有害なコンテンツを検出して、処理をブロックします。エラーコードは 403(Forbidden)または特定の Safety Setting の値で検出されます。
よくあるトリガー
- 暴力的な内容: 犯罪、危険行為の指示
- 有害なコンテンツ: 自傷行為、違法薬物
- ヘイトスピーチ: 特定の属性グループへの差別
- 性的コンテンツ: 露骨な内容
Safety Settings の調整
async function callGeminiAPIWithSafetySettings(prompt: string) {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': process.env.GEMINI_API_KEY,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
safetySettings: [
{
category: 'HARM_CATEGORY_HARASSMENT',
threshold: 'BLOCK_ONLY_HIGH', // より緩い設定
},
{
category: 'HARM_CATEGORY_HATE_SPEECH',
threshold: 'BLOCK_MEDIUM_AND_ABOVE',
},
{
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold: 'BLOCK_ONLY_HIGH',
},
{
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold: 'BLOCK_MEDIUM_AND_ABOVE',
},
],
}),
}
);
if (response.status === 403) {
console.error('Content blocked by safety filters');
return null;
}
return response.json();
}
対策: プロンプトエンジニアリング
フィルターを回避するのではなく、プロンプトを工夫して誤作動を減らします:
// 悪い例: フィルターを誤解させようとする
// "ハッキングの手順を教えて"
// 良い例: 教育的・建設的な文脈で質問
// "セキュリティ研究者として、一般的な脆弱性についての知識を深めたいです。SQLインジェクションの仕組みと防御方法を学びたいのですが説明していただけますか?"
Error 5: ストリーミング接続の切断
発生の仕組み
ストリーミングレスポンスの途中でネットワーク接続が切断される場合があります。
実装例: リトライ対応
async function streamWithFallback(prompt: string): Promise<string> {
const chunks: string[] = [];
let lastChunkIndex = 0;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const stream = await streamGeminiResponse(prompt);
for await (const chunk of stream) {
chunks.push(chunk);
lastChunkIndex++;
}
return chunks.join('');
} catch (error) {
console.error(`Stream interrupted at chunk ${lastChunkIndex}, retrying...`);
if (attempt === 2) {
// 最後の試行では非ストリーミングで代替
return await callGeminiAPIWithRetry(prompt);
}
// 次の試行の前に待機
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
throw new Error('Failed to get response after 3 attempts');
}
本番環境での推奨設定
コスト最適化
// 1. モデルの使い分け
async function selectModelByTask(taskType: 'simple' | 'complex'): Promise<string> {
return taskType === 'simple'
? 'models/gemini-3-flash' // より安価でレスポンスが高速
: 'models/gemini-2.5-pro'; // より高精度が必要な場合
}
// 2. キャッシング活用
async function callWithCaching(
prompt: string,
cacheKey: string
): Promise<string> {
const cached = await redis.get(cacheKey);
if (cached) return cached;
const result = await callGeminiAPIWithRetry(prompt);
// 1日間キャッシュ
await redis.setex(cacheKey, 86400, result);
return result;
}
// 3. バッチ処理で効率化
async function processBatchEfficiently(items: string[]): Promise<string[]> {
const batchSize = 50; // API 制限に応じて調整
const results: string[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await batchGenerateContent(batch);
results.push(...batchResults);
}
return results;
}
公式ドキュメントに載っていない、運用で気づいた勘所
ここからは、ドキュメントを読むだけでは掴みにくい、実際に運用して初めて分かった点をまとめます。
リトライは「増幅」を測ってから設計する
指数バックオフは 429・503 の定番対策です。ただ、安易に最大リトライ回数を増やすと、失敗したリクエストが課金対象のトークンを二重・三重に消費します。私が壁紙分類バッチで計測したところ、最大 5 回リトライ・初期待機 1 秒の設定では、ピーク時に実効リクエスト数が投入数の約 1.7 倍まで膨らんでいました。これを 3 回・初期待機 2 秒・ジッター付きに変えたところ、増幅は約 1.2 倍に収まり、月間の API 消費トークンが体感で 3 割ほど下がりました。
リトライ回数は「多いほど堅牢」ではありません。増幅率とコストの損益分岐を先に出しておくことをおすすめします。この観点は Gemini 3.5 Flash は本当に安いのか — リトライ増幅を実測して損益分岐を出す で、数値と一緒に掘り下げています。
503 と 429 は初動を分ける
429 はこちらの投げ方に起因する問題、503 はサーバー側の一時的な問題です。同じ「待って再試行」でも、429 は投入レート自体を落とす必要があり、503 は待つだけで回復することが多い。両者を同じロジックで扱うと、503 のときに不要にレートを絞ってしまい、全体のスループットを落とします。503 側の詳しい対処は Gemini API 503 エラーの原因と解決策 にまとめました。
キャッシュはリトライ設計とセットで効く
同じプロンプトを何度も投げる用途では、キャッシュがレート制限そのものの発生確率を下げます。呼び出し数が減れば 429 も減る、という当たり前の連鎖ですが、トラブルシューティングの文脈で語られることは意外と少ないように感じます。私の環境では Context Caching の導入で API 呼び出し数が約 6 割減り、429 の発生頻度もそれに比例して下がりました。実装の詳細は Gemini API のキャッシュ戦略で月3万円を6,000円にした本番設計 をご覧ください。
エラー種別ごとの初動判断表
本番でエラーを受けたとき、まず何を確認し、どう動くかを一枚にまとめました。手元のオンコール手順書に貼っておける粒度を意識しています。
| エラー | まず疑うこと | 初動 | 恒久対策 |
| 429 | 投入レートの超過・並列数 | 投入レートを下げて指数バックオフ | バッチ化+キャッシュで呼び出し数を削減 |
| 503 | サーバー側の一時障害 | ジッター付きで待って再試行 | リージョン/モデルのフォールバック |
| 504 | 長い生成・大きな入力 | ストリーミングに切替 | 入力分割+出力トークン上限の設定 |
| 400 | リクエスト構造・role の交互 | スキーマ検証で送信前に弾く | 型定義でリクエスト形状を固定 |
| Safety | プロンプトの文脈 | 教育的文脈を補って再送 | 閾値設定と用途そのものの見直し |
次に踏み出す一歩
エラーは、構造から正しく捉えれば「起きてから直すもの」ではなく「起きにくく設計するもの」へと変わります。まず取り掛かるなら、いま動いているコードのリトライ設定を一度計測して、増幅率を数字で把握するところからがおすすめです。そこが分かると、キャッシュやバッチ化、モデルの使い分けといった次の一手が、自然と見えてきます。
私自身もまだ運用のたびに新しい気づきがあり、学びの途中です。この記事が、皆様の Gemini API 運用を少しでも安定させる助けになれば嬉しいです。お読みいただき、ありがとうございました。