MechaToraのブログ

記事のアイキャッチ画像
技術・開発

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つ:

主なRate Limitingの種類

私が実際に遭遇した制限パターン:

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の制限:

実装パターン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秒1リクエストに制限
  2. 検索結果を30件に絞る(1リクエストで完結)
  3. 同一キーワードは1時間キャッシュ

ケース2:気象庁API(地震モニター)

問題:10秒ごとのポーリングで1日8,640リクエスト発生

解決策

  1. ポーリング間隔を30秒に変更(2,880リクエストに削減)
  2. データ変更がない場合はスキップ(304 Not Modified)
  3. ユーザーが画面を離れたら停止(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(銘柄情報)を組み合わせています。

戦略

まとめ

Rate Limitingへの対策をまとめると:

開発時の対策

  1. リトライロジック:429エラー時のExponential Backoff
  2. キューイング:複数リクエストを制御して順次実行
  3. キャッシュ:同じリクエストを繰り返さない
  4. ポーリング最適化:必要最小限の頻度に抑える

UX向上の対策

  1. 残り回数表示:ユーザーに使用状況を見せる
  2. 親切なエラーメッセージ:いつリトライ可能かを伝える
  3. ローディング表示:リトライ中であることを明示
  4. オフライン対応:キャッシュで最低限の機能を提供

コスト削減の対策

  1. DB活用:APIから取得したデータを保存して再利用
  2. Visibility API:非アクティブ時はリクエスト停止
  3. デバウンス:ユーザー入力を適切に制御
  4. 無料枠の確認:有料化する前に代替API検討

Rate Limitingは避けられない制約ですが、適切な実装とキャッシュ戦略により、ユーザー体験を損なわず、無料枠内で運用できます。MechaToraでは、これらの対策により、15個のツール全てを完全無料で運用し続けています。

APIを使ったアプリ開発を始める方は、最初からRate Limitingを意識した設計をしておくことをおすすめします!