API Rate Limitingの実装と対策 - 制限を超えないための実践テクニック
外部APIを使ったアプリ開発で必ず直面するのが「Rate Limiting(レート制限)」です。1時間に100回、1日に1000回といった制限を超えると、429エラー(Too Many Requests)が返され、アプリが正常に動作しなくなります。
この記事では、MechaToraで運用している日本酒検索(楽天API)や地震モニター(気象庁API)の開発で実際に遭遇したRate Limitingの問題と、その解決策を具体的なコード例とともに解説します。
Rate Limitingとは
なぜRate Limitingが存在するのか
API提供側がRate Limitingを設ける理由は主に3つ:
- サーバー負荷の軽減:過剰なリクエストからサーバーを保護
- 公平性の確保:一部のユーザーによる独占を防ぐ
- DoS攻撃の防止:悪意ある大量リクエストを遮断
主なRate Limitingの種類
私が実際に遭遇した制限パターン:
- 秒単位:1秒に10リクエストまで(気象庁API)
- 分単位:1分に60リクエストまで
- 時間単位:1時間に1000リクエストまで(楽天API無料枠)
- 日単位:1日に10,000リクエストまで
429エラーの典型例
楽天APIで初めて429エラーに遭遇したときのレスポンス:
{
"error": "rateLimitExceeded",
"error_description": "Rate limit quota exceeded. Retry after 3600 seconds."
}
この時、日本酒検索アプリは完全に停止し、ユーザーに「データ取得に失敗しました」と表示されてしまいました。
Rate Limitingの確認方法
レスポンスヘッダーで確認
多くのAPIは、レスポンスヘッダーで現在の使用状況を返します:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000 // 制限値
X-RateLimit-Remaining: 234 // 残り回数
X-RateLimit-Reset: 1698825600 // リセット時刻(Unixタイムスタンプ)
Retry-After: 3600 // リトライ可能になるまでの秒数(429の場合)
JavaScriptでの取得
fetchでヘッダーを確認する方法:
fetch('https://api.example.com/data')
.then(response => {
console.log('Limit:', response.headers.get('X-RateLimit-Limit'));
console.log('Remaining:', response.headers.get('X-RateLimit-Remaining'));
console.log('Reset:', response.headers.get('X-RateLimit-Reset'));
return response.json();
})
.then(data => {
console.log(data);
});
ドキュメントでの確認
各APIのドキュメントで制限を必ず確認します。私が使っている主なAPIの制限:
- 楽天API:1秒1リクエスト、1日5,000リクエスト(無料)
- 気象庁API:明記なし(1秒10リクエスト程度推奨)
- YouTube Data API:1日10,000クォータ(1検索=100クォータ)
- Twitter API v2:15分に15リクエスト(無料枠)
実装パターン1:シンプルなリトライロジック
基本的なリトライ
429エラーが返ってきたら、一定時間待ってリトライする基本パターン:
async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
// 成功したらそのまま返す
if (response.ok) {
return await response.json();
}
// 429エラーの場合
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
console.log(`Rate limit exceeded. Retrying after ${waitTime}ms...`);
await sleep(waitTime);
continue; // リトライ
}
// その他のエラー
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} catch (error) {
// 最後のリトライで失敗したら例外を投げる
if (i === retries - 1) {
throw error;
}
await sleep(delay);
}
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 使用例
fetchWithRetry('https://api.rakuten.co.jp/search?keyword=日本酒')
.then(data => console.log(data))
.catch(error => console.error('Failed after retries:', error));
Exponential Backoff(指数バックオフ)
リトライ間隔を指数関数的に増やす、より洗練された方法:
async function fetchWithExponentialBackoff(url, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
if (response.status === 429) {
// 2^i * 1000ms = 1秒、2秒、4秒、8秒、16秒...
const waitTime = Math.pow(2, i) * 1000;
console.log(`Retry ${i + 1}/${maxRetries} after ${waitTime}ms`);
await sleep(waitTime);
continue;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
// 使用例(最大5回、最長16秒待機)
fetchWithExponentialBackoff('https://api.example.com/data')
.then(data => console.log(data))
.catch(error => console.error(error));
実装パターン2:リクエストキューイング
キュー管理クラス
複数のリクエストをキューに入れて、Rate Limitに収まるように制御します:
class RateLimitedQueue {
constructor(requestsPerSecond = 1) {
this.queue = [];
this.processing = false;
this.interval = 1000 / requestsPerSecond; // 1リクエストあたりの間隔(ms)
}
async add(url, options = {}) {
return new Promise((resolve, reject) => {
this.queue.push({ url, options, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
this.processing = true;
while (this.queue.length > 0) {
const { url, options, resolve, reject } = this.queue.shift();
try {
const response = await fetch(url, options);
const data = await response.json();
resolve(data);
} catch (error) {
reject(error);
}
// 次のリクエストまで待機
if (this.queue.length > 0) {
await sleep(this.interval);
}
}
this.processing = false;
}
}
// 使用例:楽天API(1秒1リクエスト)
const rakutenQueue = new RateLimitedQueue(1);
// 10個のリクエストを追加(自動的に1秒間隔で実行される)
for (let i = 0; i < 10; i++) {
rakutenQueue.add(`https://api.rakuten.co.jp/search?page=${i}`)
.then(data => console.log(`Page ${i}:`, data))
.catch(error => console.error(`Page ${i} failed:`, error));
}
実際の活用例:日本酒検索
MechaToraの日本酒検索では、ユーザーが連続して検索しても制限に引っかからないよう、キューイングを実装しました:
// 楽天API専用キュー(1秒1リクエスト)
const rakutenAPI = new RateLimitedQueue(1);
document.getElementById('search-btn').addEventListener('click', async () => {
const keyword = document.getElementById('keyword').value;
const url = `https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601?
applicationId=YOUR_APP_ID&
keyword=${encodeURIComponent(keyword + ' 日本酒')}&
hits=30`;
try {
showLoading();
const data = await rakutenAPI.add(url);
displayResults(data.Items);
} catch (error) {
showError('検索に失敗しました。しばらく経ってから再度お試しください。');
} finally {
hideLoading();
}
});
実装パターン3:キャッシュ戦略
LocalStorageでのキャッシュ
同じリクエストを繰り返さないよう、結果をキャッシュします:
class CachedAPI {
constructor(cacheExpiry = 3600000) { // デフォルト1時間
this.cacheExpiry = cacheExpiry;
}
async fetch(url) {
const cacheKey = `cache_${url}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
const age = Date.now() - timestamp;
// キャッシュが有効期限内なら使用
if (age < this.cacheExpiry) {
console.log('Using cached data');
return data;
}
}
// キャッシュがないか期限切れの場合、APIから取得
console.log('Fetching from API');
const response = await fetch(url);
const data = await response.json();
// キャッシュに保存
localStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
}
clearCache() {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('cache_')) {
localStorage.removeItem(key);
}
});
}
}
// 使用例:気象庁API(10分キャッシュ)
const weatherAPI = new CachedAPI(600000);
async function getEarthquakeData() {
const url = 'https://www.jma.go.jp/bosai/quake/data/list.json';
return await weatherAPI.fetch(url);
}
// 最初の呼び出し:APIから取得
getEarthquakeData().then(data => console.log(data));
// 2回目の呼び出し(10分以内):キャッシュから取得
getEarthquakeData().then(data => console.log(data));
Cache-Control ヘッダーの活用
サーバー側が指定したキャッシュ期間を尊重する実装:
async function fetchWithCacheControl(url) {
const response = await fetch(url);
const cacheControl = response.headers.get('Cache-Control');
if (cacheControl) {
const maxAge = cacheControl.match(/max-age=(\d+)/);
if (maxAge) {
const expiryTime = parseInt(maxAge[1]) * 1000;
console.log(`Cache expires in ${expiryTime}ms`);
const data = await response.json();
localStorage.setItem(url, JSON.stringify({
data,
expiry: Date.now() + expiryTime
}));
return data;
}
}
return await response.json();
}
実装パターン4:ユーザーへのフィードバック
残り回数の表示
ユーザーに現在の使用状況を見せることで、意図しない制限超過を防ぎます:
async function fetchWithFeedback(url) {
const response = await fetch(url);
const remaining = response.headers.get('X-RateLimit-Remaining');
const limit = response.headers.get('X-RateLimit-Limit');
if (remaining && limit) {
const percentage = (remaining / limit) * 100;
updateRateLimitDisplay(remaining, limit, percentage);
// 残り20%を切ったら警告
if (percentage < 20) {
showWarning(`API制限の残りが${remaining}回です。ご注意ください。`);
}
}
return await response.json();
}
function updateRateLimitDisplay(remaining, limit, percentage) {
const display = document.getElementById('rate-limit-display');
display.innerHTML = `
API使用可能回数: ${remaining} / ${limit}
`;
}
エラー時の親切なメッセージ
429エラー時に、いつリトライ可能かをユーザーに伝えます:
async function fetchWithUserFriendlyError(url) {
try {
const response = await fetch(url);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
const minutes = Math.ceil(parseInt(retryAfter) / 60);
throw new Error(
`現在、一時的にAPIの利用制限に達しています。\n` +
`約${minutes}分後に再度お試しください。`
);
} else {
throw new Error(
'現在、一時的にAPIの利用制限に達しています。\n' +
'しばらく経ってから再度お試しください。'
);
}
}
return await response.json();
} catch (error) {
showErrorModal(error.message);
throw error;
}
}
実際の運用で学んだこと
ケース1:楽天API(日本酒検索)
問題:検索結果が複数ページにまたがる場合、連続リクエストで制限超過
解決策:
- キューイングで1秒1リクエストに制限
- 検索結果を30件に絞る(1リクエストで完結)
- 同一キーワードは1時間キャッシュ
ケース2:気象庁API(地震モニター)
問題:10秒ごとのポーリングで1日8,640リクエスト発生
解決策:
- ポーリング間隔を30秒に変更(2,880リクエストに削減)
- データ変更がない場合はスキップ(304 Not Modified)
- ユーザーが画面を離れたら停止(Visibility API使用)
// Visibility APIでタブ非表示時は停止
let pollingInterval;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// タブが非表示になったらポーリング停止
clearInterval(pollingInterval);
console.log('Polling stopped (tab hidden)');
} else {
// タブが再表示されたらポーリング再開
pollingInterval = setInterval(fetchEarthquakeData, 30000);
console.log('Polling resumed (tab visible)');
}
});
ケース3:複数APIの組み合わせ
日本酒検索では、楽天API(商品情報) + 独自DB(銘柄情報)を組み合わせています。
戦略:
- 独自DBで検索 → 該当なしの場合のみ楽天APIを呼ぶ
- 楽天APIの結果を独自DBに保存(次回からDB検索で済む)
- 結果:API呼び出しを70%削減
まとめ
Rate Limitingへの対策をまとめると:
開発時の対策
- リトライロジック:429エラー時のExponential Backoff
- キューイング:複数リクエストを制御して順次実行
- キャッシュ:同じリクエストを繰り返さない
- ポーリング最適化:必要最小限の頻度に抑える
UX向上の対策
- 残り回数表示:ユーザーに使用状況を見せる
- 親切なエラーメッセージ:いつリトライ可能かを伝える
- ローディング表示:リトライ中であることを明示
- オフライン対応:キャッシュで最低限の機能を提供
コスト削減の対策
- DB活用:APIから取得したデータを保存して再利用
- Visibility API:非アクティブ時はリクエスト停止
- デバウンス:ユーザー入力を適切に制御
- 無料枠の確認:有料化する前に代替API検討
Rate Limitingは避けられない制約ですが、適切な実装とキャッシュ戦略により、ユーザー体験を損なわず、無料枠内で運用できます。MechaToraでは、これらの対策により、15個のツール全てを完全無料で運用し続けています。
APIを使ったアプリ開発を始める方は、最初からRate Limitingを意識した設計をしておくことをおすすめします!