楽天APIの429エラーと戦った記録 — 5並列キュー&適応型レートリミッターで2,100件以上のホテル価格を安定取得する

に公開

https://apps.apple.com/jp/app/id6759241772

この記事について

個人開発で、全国2,100件以上のビジネスホテルの最安値をリアルタイム表示するiOSアプリ「ビジホ検索!」を開発しています。

このアプリのバックエンドで一番苦労したのが、楽天トラベルAPIのレート制限(1アプリケーションIDあたり1リクエスト/秒)との闘いでした。

素朴に1つのAPIキーでリクエストすると、ユーザーが地図を操作するたびに429エラーが頻発。かといって無秩序に並列度を上げると、利用制限(APIキー停止など)のリスクが上がる可能性がある。

「速度を出しつつ、レート制限超過(429)を最小化して安定運用する」——この相反する要求をどう解決したか、試行錯誤の記録をコード付きで紹介します。

前提:なぜ2,100件以上のリアルタイム価格が必要なのか

ビジホ検索!は、APA・東横イン・ドーミーインなど15チェーン・2,100件以上のビジネスホテルを地図上にマッピングし、リアルタイムの最安値を表示するアプリです。

ユーザーが地図をドラッグするたびに、表示範囲内のホテル価格を取得する必要があります。さらに夜間バッチで全ホテルの翌日料金を取得し、価格統計や高騰分析にも使います。

つまり:

  • ユーザー操作時: マップ表示範囲のホテル(数件〜数十件)の価格を即座に取得
  • 夜間バッチ: 全2,100件以上の価格を一括取得(統計・高騰分析用)
  • イベント高騰分析: 特定会場周辺のホテル価格を事前取得

これを 1 req/sec でやるのは、普通に考えて厳しい。

Phase 1:素朴な実装(そして地獄)

最初の実装は恐ろしくシンプルでした。

// 最初の実装(v1)
const axios = require("axios");

async function getHotelPrice(hotelId, checkIn, checkOut) {
  const response = await axios.get(
    "https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426",
    {
      params: {
        applicationId: process.env.RAKUTEN_API_KEY, // 1つだけ
        hotelNo: hotelId,
        checkinDate: checkIn,
        checkoutDate: checkOut,
        format: "json",
      },
    }
  );
  return response.data;
}

何が起きたか

  • ユーザーが地図を動かすたびに、表示範囲のホテル十数件に対して同時リクエストが飛ぶ
  • 楽天API「429 Too Many Requests」の嵐
  • ユーザーには「価格取得に失敗しました」が延々と表示される
  • 夜間バッチは 2,100件 ÷ 1 req/sec = 約35分(実際はオーバーヘッドやリトライでさらに伸びる)
  • 429が続くとAPIキー側で一時的な利用制限が発生する可能性がある。これはまずい。

Phase 2:シングルキュー + リトライ(焼け石に水)

429エラー対策として、まずリトライと指数バックオフを導入しました。

// Phase 2: リトライ付き
async function getHotelPriceWithRetry(hotelId, checkIn, checkOut, retries = 3) {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await getHotelPrice(hotelId, checkIn, checkOut);
    } catch (error) {
      if (error.response?.status === 429 && attempt < retries - 1) {
        const waitTime = 3000 * (attempt + 1); // 3秒, 6秒, 9秒
        await new Promise((r) => setTimeout(r, waitTime));
        continue;
      }
      throw error;
    }
  }
}

結果

  • 429エラーは減ったが、リトライ待ちの間ユーザーは何も見えない
  • 結局「遅い」が「エラー」に変わっただけ
  • 夜間バッチもリトライ待ちで さらに伸びやすい

根本的に、1 req/secという帯域そのものを前提に"安定させる"設計が必要だと気づきました。

Phase 3:優先度キューの導入(まだ1キー)

次に考えたのが、全リクエストが等価値ではないという点です。

  • ユーザーが今タップしたホテル → 最優先で取得すべき
  • マップ上に見えている周辺ホテル → 少し遅れてもOK
  • バックグラウンド更新 → 後回しでいい

Google Cloud Tasksを使って、優先度付きのキュー管理を実装しました。

// priority-queue-manager.js(Phase 3)
const PRIORITY_LEVELS = {
  SELECTED_HOTEL: 100, // ユーザーが選択したホテル → 即実行
  CACHE_MISS: 85,      // キャッシュミスの再取得
  NORMAL: 50,          // 通常リクエスト
  NEARBY_HOTEL: 30,    // 周辺ホテル → 遅延実行
  BACKGROUND: 10,      // バックグラウンド更新 → 最後
};

const DELAY_SETTINGS = {
  HIGH_PRIORITY_DELAY_MS: 0,      // 即座に
  NORMAL_DELAY_MS: 2000,          // 2秒後
  LOW_PRIORITY_DELAY_MS: 5000,    // 5秒後
  BACKGROUND_DELAY_MS: 10000,     // 10秒後
};

さらに、ユーザーが地図を動かして新しい範囲を見始めたとき、前の低優先度タスクを自動キャンセルする仕組みも入れました。

// 新しいコンテキスト(地図操作)が開始されたら、古い低優先度タスクをキャンセル
async cancelLowPriorityTasks(contextId) {
    const tasksSnapshot = await this.pendingTasksRef
        .where('contextId', '==', contextId)
        .where('status', '==', 'pending')
        .get();

    const batch = this.db.batch();
    const taskNames = [];

    tasksSnapshot.forEach(doc => {
        taskNames.push(doc.data().taskName);
        batch.update(doc.ref, {
            status: 'cancelled',
            cancelledAt: admin.firestore.FieldValue.serverTimestamp(),
        });
    });

    await batch.commit();

    // Cloud Tasksからも実際に削除
    for (const taskName of taskNames) {
        try {
            await this.tasksClient.deleteTask({ name: taskName });
        } catch (error) {
            if (error.code !== 5) throw error; // NOT_FOUND以外はthrow
        }
    }
}

結果

  • UXは改善。「ユーザーが見ているホテル」だけは素早く表示される
  • しかし帯域は依然 1 req/sec。優先度を変えても絶対的な速度は変わらない
  • 夜間バッチの所要時間も大きくは変わらない

Phase 4:5並列キュー&マルチAPIキーシステム(現在の設計)

ここでようやく、現在のV2マルチキュー・マルチAPIキーシステムに到達しました。

基本コンセプト

楽天のAPIにはアプリケーションIDごとのリクエスト頻度制限があります。
そこで本システムでは、複数のキーを使う前提で、1キーあたりの制限を確実に超えないように、キューを分離して制御しています。

ただし注意点があります。

  • "キー単位で制限を守っていても"、全体の挙動によっては429が発生する
  • 各キーが確実に 1 req/sec以下 を守る必要がある
  • 429が出たキーはすぐに引っ込めないと、全体が不安定になる

これらを解決するために、3つのレイヤーで構成するアーキテクチャを設計しました。

┌──────────────────────────────────────────────────────────┐
│  Layer 1: MultiQueueManager(キュー振り分け)               │
│    ├── 優先度ベースのキュー選択                              │
│    ├── ラウンドロビンによるロードバランシング           │
│    └── コンテキスト管理(不要タスクの自動キャンセル)       │
├──────────────────────────────────────────────────────────┤
│  Layer 2: ApiKeyManager(キー状態管理)                     │
│    ├── 複数キーの個別レート制限追跡                           │
│    ├── 429ペナルティ管理(指数バックオフ)             │
│    ├── Firestoreによる状態永続化(インスタンス間共有)          │
│    └── ラウンドロビン + 最短待ち時間選択              │
├──────────────────────────────────────────────────────────┤
│  Layer 3: AdaptiveRateLimiter(適応型レート制御)            │
│    ├── リアルタイム応答時間追跡(P95算出)                     │
│    ├── 連続成功で間隔短縮(-60ms / 8回成功ごと)               │
│    ├── 429エラーで間隔延長(+200ms × 1.5^n)                 │
│    └── 統計データをFirestoreに記録(7日間保持)                │
└──────────────────────────────────────────────────────────-┘

Layer 1:MultiQueueManager — キューの振り分け

5つのCloud Tasksキューを管理し、リクエストの優先度に応じて振り分けます。

// multi-queue-manager.js

const QUEUE_CONFIG = {
  QUEUES: [
    { id: 1, name: "rakuten-api-queue-1", priority: "high",   apiKeyId: "key_1" },
    { id: 2, name: "rakuten-api-queue-2", priority: "normal", apiKeyId: "key_2" },
    { id: 3, name: "rakuten-api-queue-3", priority: "normal", apiKeyId: "key_3" },
    { id: 4, name: "rakuten-api-queue-4", priority: "normal", apiKeyId: "key_4" },
    { id: 5, name: "rakuten-api-queue-5", priority: "normal", apiKeyId: "key_5" },
  ],
};

キュー1は高優先度専用です。ユーザーが今タップしたホテルの価格取得は、必ずキュー1に投入されます。キュー2〜5はラウンドロビンで均等に分散します。

selectQueue(priority) {
    // 高優先度(ユーザーが選択したホテル)→ キュー1固定
    if (priority >= PRIORITY_LEVELS.SELECTED_HOTEL) {
        return QUEUE_CONFIG.QUEUES[0];
    }

    // それ以外 → キュー2-5をラウンドロビン
    const availableQueues = QUEUE_CONFIG.QUEUES.slice(1);
    const selectedIndex = this.roundRobinIndex % availableQueues.length;
    this.roundRobinIndex = (this.roundRobinIndex + 1) % 1000;

    return availableQueues[selectedIndex];
}

Cloud Tasksキューの設定は以下のように、各キューを 1 req/sec、同時実行1 に制限しています。
これが**「意図せずレート制限を超えない」ための最後の砦**です。

# 各キューの設定(gcloudコマンド)
for i in 1 2 3 4 5; do
  gcloud tasks queues create rakuten-api-queue-$i \
    --location=us-central1 \
    --max-dispatches-per-second=1 \
    --max-concurrent-dispatches=1 \
    --max-attempts=3 \
    --min-backoff=5s \
    --max-backoff=30s
done

max-dispatches-per-second=1max-concurrent-dispatches=1 により、1キューあたり1秒に2リクエスト以上飛ぶことは構造的に起きません。
アプリケーション側のバグがあっても、インフラ側で抑えられます。

Layer 2:ApiKeyManager — キーごとの状態管理

複数のAPIキーそれぞれの状態(最終リクエスト時刻、連続成功回数、連続429回数、ペナルティ期限)を管理します。

// api-key-manager.js

const RATE_LIMIT_CONFIG = {
  MIN_INTERVAL_MS: 1100,            // 各キーの最小間隔: 1.1秒(安全マージン)
  PENALTY_INTERVAL_MS: 3000,        // 429エラー後のペナルティ: 3秒
  SUCCESS_THRESHOLD_FOR_RESET: 5,   // 5回連続成功でペナルティ解除
  SYNC_INTERVAL_MS: 500,            // Firestore同期間隔: 500ms
  JITTER_MS: 100,                   // ランダムジッター: 100ms
};

ここで重要なのが 1100ms という最小間隔です。楽天APIの制限は「1秒に1リクエスト」ですが、ネットワーク遅延やクロック誤差を考慮して100msの安全マージンを設けています。

利用可能なキーの選択ロジック

async getAvailableKey(options = {}) {
    const now = Date.now();
    let bestKey = null;
    let minWaitTime = Infinity;

    for (let i = 0; i < this.apiKeys.length; i++) {
        const idx = (this.currentKeyIndex + i) % this.apiKeys.length;
        const key = this.apiKeys[idx];
        const state = this.keyStates[key.id];

        // ペナルティ中はスキップ(でも最短待ちは記録)
        if (state.penaltyUntil > now) {
            const waitTime = state.penaltyUntil - now;
            if (waitTime < minWaitTime) {
                minWaitTime = waitTime;
                bestKey = { ...key, state, waitTime };
            }
            continue;
        }

        // 最小間隔チェック(1100ms)
        const elapsed = now - state.lastRequestAt;
        if (elapsed >= RATE_LIMIT_CONFIG.MIN_INTERVAL_MS) {
            // 即座に利用可能!
            this.currentKeyIndex = (idx + 1) % this.apiKeys.length;
            return { ...key, state, waitTime: 0 };
        } else {
            const waitTime = RATE_LIMIT_CONFIG.MIN_INTERVAL_MS - elapsed;
            if (waitTime < minWaitTime) {
                minWaitTime = waitTime;
                bestKey = { ...key, state, waitTime, index: idx };
            }
        }
    }

    return bestKey; // 待ち時間付きで最短のキーを返す
}

429エラー時のペナルティ

async record429Error(keyId) {
    const state = this.keyStates[keyId];
    state.consecutive429 += 1;
    state.consecutiveSuccess = 0;

    // 連続429でペナルティ増加(3秒 × 連続回数)
    const penaltyMs = RATE_LIMIT_CONFIG.PENALTY_INTERVAL_MS * state.consecutive429;
    state.penaltyUntil = Date.now() + penaltyMs;

    await this._syncToFirestore(); // 他のインスタンスにも共有
}

そして5回連続で成功すると、ペナルティは解除されます。

async recordSuccess(keyId) {
    state.consecutiveSuccess += 1;
    state.consecutive429 = 0;

    // 5回連続成功でペナルティ解除
    if (state.consecutiveSuccess >= RATE_LIMIT_CONFIG.SUCCESS_THRESHOLD_FOR_RESET) {
        state.penaltyUntil = 0;
        state.consecutiveSuccess = 0;
    }
}

Firestoreによる状態共有

Cloud Functionsは複数インスタンスで実行される可能性があるため、各キーの状態をFirestoreに永続化しています。

async _syncToFirestore() {
    await this.stateRef.set({
        keyStates: this.keyStates,
        currentKeyIndex: this.currentKeyIndex,
        updatedAt: admin.firestore.FieldValue.serverTimestamp(),
    }, { merge: true });
}

500msごとにFirestoreと同期し、インスタンスAが使ったキーの状態をインスタンスBも把握できるようにしています。これにより、別インスタンスが同じキーを同時に使って429が出る事態を抑えます。

Layer 3:AdaptiveRateLimiter — 適応型レート制御

APIの応答状況に応じて、リクエスト間隔を自動で調整します。

// adaptive_rate_limiter.js

class AdaptiveRateLimiter {
  constructor() {
    this.MIN_INTERVAL = 1100;   // 最小間隔 1.1秒(これ以下には絶対しない)
    this.MAX_INTERVAL = 2500;   // 最大間隔 2.5秒
    this.currentInterval = 1100; // 現在の間隔

    this.INCREASE_STEP = 200;   // 429時に+200ms
    this.DECREASE_STEP = 60;    // 成功時に-60ms
    this.SUCCESS_THRESHOLD = 8; // 8回連続成功で短縮

    this.consecutive429Count = 0;
    this.consecutiveSuccessCount = 0;
    this.responseTimeHistory = []; // 直近50件の応答時間
  }
}

成功時:慎重に間隔を短縮

recordResponseTime(durationMs) {
    this.consecutiveSuccessCount++;
    this.consecutive429Count = 0;

    // 8回連続成功したら、間隔を60ms短縮
    if (this.consecutiveSuccessCount >= this.SUCCESS_THRESHOLD) {
        const oldInterval = this.currentInterval;
        this.currentInterval = Math.max(
            this.MIN_INTERVAL,                        // 1100ms以下にはしない
            this.currentInterval - this.DECREASE_STEP // -60ms
        );
        this.consecutiveSuccessCount = 0; // リセット
    }
}

429エラー時:素早く間隔を延長

handle429Error() {
    this.consecutiveFailures++;
    this.consecutive429Count++;
    this.consecutiveSuccessCount = 0;

    // 429時は一気に延長(200ms × 1.5^(失敗回数-1))
    this.currentInterval = Math.min(
        this.MAX_INTERVAL, // 2500msを超えない
        this.currentInterval + this.INCREASE_STEP * Math.pow(1.5, this.consecutiveFailures - 1)
    );

    // 2回連続429で応答時間履歴をリセット
    if (this.consecutive429Count >= 2) {
        this.responseTimeHistory = [];
    }
}

P95応答時間の監視

calculateP95() {
    const sorted = [...this.responseTimeHistory].sort((a, b) => a - b);
    const p95Index = Math.floor(sorted.length * 0.95);
    const p95Value = sorted[p95Index] || this.currentInterval;

    // 楽天制限 + 10%マージン
    const safeP95 = Math.max(this.rakutenMinIntervalMs, p95Value);
    return Math.ceil(safeP95 * 1.1);
}

直近50件のAPI応答時間からP95値を算出し、Firestoreに一定期間保存しています。これにより、「最近APIが重くなっている」「今は快調」といったトレンドを把握し、間隔の調整に活かしています。

ユーザー数に応じた動的キュー割り当て

さらに、アクティブユーザー数に応じてキューの割り当て方を変える仕組みも実装しています。

// dynamic-queue-allocator.js

const SCALING_STRATEGY = {
  EXCLUSIVE: {
    // 1人: 全5キューを独占 → 最大5 req/sec
    maxUsers: 1,
    queuesPerUser: 5,
  },
  SHARED_HIGH: {
    // 2-3人: 1人あたり2キュー
    maxUsers: 3,
    queuesPerUser: 2,
  },
  SHARED_MEDIUM: {
    // 4-5人: 1人あたり1キュー
    maxUsers: 5,
    queuesPerUser: 1,
  },
  SHARED_LOW: {
    // 6-10人: 複数人で1キューを共有
    maxUsers: 10,
    queuesPerUser: 1,
    sharedQueue: true,
  },
  FAIR_QUEUE: {
    // 11人以上: ラウンドロビン共有
    maxUsers: Infinity,
  },
};

アプリは30秒ごとにハートビートを送信し、サーバー側でアクティブユーザー数を把握します。

// session-manager.js
const SESSION_CONFIG = {
  HEARTBEAT_INTERVAL_MS: 30000,  // 30秒ごとにハートビート
  ACTIVE_THRESHOLD_MS: 60000,    // 60秒以内 → アクティブ
  IDLE_THRESHOLD_MS: 180000,     // 3分以内 → アイドル
  SESSION_TTL_MS: 600000,        // 10分で期限切れ
};

全体のリクエストフロー

ユーザーがマップ上でホテルをタップしてから価格が表示されるまでの流れをまとめます。

[ユーザーがホテルをタップ]


[Flutter] priority = 100 で Cloud Functions を呼び出し


[MultiQueueManager.selectQueue(100)]
  → priority >= 100 なので Queue 1(高優先度専用)に振り分け


[Cloud Tasks Queue 1]
  → max-dispatches-per-second=1 を構造的に保証
  → scheduleTime = 即時(遅延なし)


[processHotelPriceTaskV2(タスクワーカー)]
  → ペイロードに含まれる applicationId (= key_1) を使用


[MultiKeyRakutenApiClient.makeRequestWithKey()]
  → ApiKeyManager.waitForRateLimit('key_1')
    → 1100ms経過を確認 → OK → リクエスト送信
    → 429なら → record429Error → ペナルティ設定 → リトライ


[楽天トラベルAPI] → 200 OK


[SmartCacheManager にキャッシュ保存(TTL 6時間)]


[Flutter に結果返却 → UI に最安値表示]

Phase 4の成果(目安)

指標 Phase 1 Phase 4
最大スループット 1 req/sec 最大5 req/sec(設計上)
ユーザー操作時のレスポンス 数秒〜タイムアウト 約1〜2秒台(状況により変動)
429エラー率 30〜50% 1%未満を目標に制御
夜間バッチ所要時間 約35〜45分 約8〜12分
APIキー停止などのリスク 安全マージン+キュー制限で低減

※数値は「設計上の上限」および「運用の目安」です(ネットワーク・API側状況・同時ユーザー数で変動します)。

なぜレート制限超過(429)を起こしにくいのか — 安全設計のまとめ

429を避けつつ高速化するために、以下の設計原則を守っています。

1. Cloud Tasks側で構造的に制限

max-dispatches-per-second=1
max-concurrent-dispatches=1

これにより、アプリケーションコードにバグがあっても、1キューあたり1 req/secを超えることは構造的に起きにくくなります。

2. 安全マージン付きの最小間隔

  • 楽天API制限: 1000ms(1 req/sec)
  • 実装の最小間隔: 1100ms(+100msマージン)

100msのマージンがネットワーク遅延やクロック誤差を吸収します。

3. 「縮める時はゆっくり、伸ばす時は素早く」

  • 短縮: 8回連続成功で -60ms
  • 延長: 1回の429で +200ms × 1.5^n

攻めすぎない設計です。

4. キーごとの独立したペナルティ管理

1つのキーが429を食らっても、他のキーは独立して稼働し続けます。影響のあるキーだけを一時退避させ、成功が続けば復帰させます。

5. Firestoreによるインスタンス間共有

Cloud Functionsが複数インスタンスで動いても、Firestore経由で各キーの最終リクエスト時刻を共有。同一キーの同時使用を抑えます。

6. ジッターの追加

const waitTime =
  requiredInterval - elapsed + Math.random() * RATE_LIMIT_CONFIG.JITTER_MS;

100msのランダムなジッターを加えることで、複数リクエストが同時刻に集中する問題を緩和します。

試行錯誤で学んだこと

外部APIのレート制限は「コードの問題」ではなく「アーキテクチャの問題」

最初は「リトライを入れれば解決するだろう」と思っていましたが、1 req/secという物理的な制約はコードレベルのworkaroundでは根本解決しません。帯域を前提に安定させる設計(キュー・優先度・状態共有・制限の担保)が必要でした。

「安全マージン」はケチるな

最小間隔1000ms(制限ぴったり)で実装していた時期は、ネットワーク遅延やコールドスタートで429が出やすかった。1100msにした途端、429が出にくくなりました。たった100msの余裕で、安定性が大きく変わります。

状態の永続化は必須

Cloud Functionsはステートレスなので、メモリ上の変数はインスタンスが変わるとリセットされます。各キーの状態をFirestoreに永続化しなければ、異なるインスタンスが同じキーを同時に使ってしまう問題が発生します。

Cloud Tasks の max-dispatches-per-second が強い防御

自前のレートリミッターだけに頼ると、バグや例外処理の漏れで制限を超えるリスクがあります。Cloud Tasksのキュー設定でインフラレベルで制限を掛けることで、「最悪でも壊れにくい」保証が得られます。

技術スタック(参考)

レイヤー 技術
フロントエンド Flutter (Dart) / Riverpod / Google Maps
バックエンド Firebase Cloud Functions v2 (Node.js 20)
タスクキュー Google Cloud Tasks(複数キュー)
データベース Cloud Firestore
キャッシュ Hive(ローカル)+ メモリキャッシュ(LRU)
外部API 楽天トラベルAPI / Agoda API

おわりに

「たかが429エラー対策」と思われるかもしれませんが、複数キュー × 適応型レートリミッター × 優先度制御 × セッション管理 × 動的キュー割り当てと、最終的にはかなり大掛かりなシステムになりました。

でもこのおかげで、2,100件以上のホテル価格を、レート制限超過を起こしにくい形で安定取得でき、ユーザーにはストレスのないリアルタイム価格表示を提供できています。

外部APIのレート制限に悩んでいる方の参考になれば幸いです。

https://apps.apple.com/jp/app/id6759241772

Discussion