2026/05/12

PDF API レート制限ガイド|429エラー・指数バックオフ・スロットリング実装

APIレート制限429エラーバックオフPDF生成

PDF生成APIの利用量が増えてくると、429 Too Many Requestsエラーに直面することがあります。単純なリトライでは同じエラーを繰り返すだけです。この記事では、レート制限の仕組みを理解したうえで、正しいリトライ戦略・指数バックオフ・プリエンプティブスロットリングを実装する方法を解説します。

PDF API本番運用ガイドの「レート制限」セクションの詳細版として、より実装寄りの内容をまとめています。

レート制限の仕組み

429レスポンスのヘッダー

429 Too Many Requestsが返ったとき、レスポンスヘッダーに以下の情報が含まれます。

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1748736030
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Retry after 30 seconds.",
  "retry_after": 30
}
ヘッダー 説明
Retry-After 次のリクエストまで待機すべき秒数
X-RateLimit-Limit 時間窓あたりのリクエスト上限
X-RateLimit-Remaining 残りリクエスト数
X-RateLimit-Reset レート制限がリセットされるUNIXタイムスタンプ

時間窓の種類

FUNBREW PDFでは複数の時間窓でレート制限を管理しています。

時間窓 制限の種類
分単位 バースト(短時間の集中リクエスト)制限
時間単位 中期的な使用量制限
日単位 プランごとの総リクエスト上限

プランごとの具体的な数値はAPIドキュメントを参照してください。

基本的なリトライ実装

Retry-Afterを尊重したリトライ

429を受け取ったら、Retry-Afterヘッダーの値だけ待ってからリトライします。

async function callWithRetry(requestFn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await requestFn();

    if (response.status === 429) {
      const retryAfter = parseInt(response.headers.get('retry-after') || '10', 10);
      console.log(`Rate limited. Waiting ${retryAfter}s (attempt ${attempt}/${maxAttempts})`);

      if (attempt === maxAttempts) {
        throw new Error(`Rate limit exceeded after ${maxAttempts} attempts`);
      }

      await sleep(retryAfter * 1000);
      continue;
    }

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    return response;
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

指数バックオフの実装

429以外の一時的なエラー(500系、タイムアウト)にも対応できる汎用的な指数バックオフです。

const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);

async function fetchWithExponentialBackoff(url, options = {}, config = {}) {
  const {
    maxAttempts = 5,
    baseDelayMs = 500,
    maxDelayMs = 30000,
    backoffMultiplier = 2,
    jitterFactor = 0.2,  // ランダムジッターで同時リトライを分散
  } = config;

  let delay = baseDelayMs;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.status === 429) {
        // Retry-Afterを優先する
        const retryAfterHeader = response.headers.get('retry-after');
        const waitMs = retryAfterHeader
          ? parseInt(retryAfterHeader, 10) * 1000
          : delay;

        if (attempt === maxAttempts) throw new Error('Max attempts reached (429)');

        const jitter = waitMs * jitterFactor * Math.random();
        await sleep(waitMs + jitter);
        delay = Math.min(delay * backoffMultiplier, maxDelayMs);
        continue;
      }

      if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxAttempts) {
        const jitter = delay * jitterFactor * Math.random();
        await sleep(delay + jitter);
        delay = Math.min(delay * backoffMultiplier, maxDelayMs);
        continue;
      }

      return response;
    } catch (networkError) {
      if (attempt === maxAttempts) throw networkError;
      await sleep(delay);
      delay = Math.min(delay * backoffMultiplier, maxDelayMs);
    }
  }
}

バックオフのディレイ推移例(baseDelayMs=500, backoffMultiplier=2, jitter±20%):

試行 待機時間(理論値) ジッター込みの範囲
1回目 500ms 400〜600ms
2回目 1秒 800ms〜1.2秒
3回目 2秒 1.6〜2.4秒
4回目 4秒 3.2〜4.8秒
5回目(最終) 8秒 6.4〜9.6秒

プリエンプティブスロットリング

429を受け取る前にリクエスト速度を自分でコントロールすることで、エラーを根本的に防ぎます。

トークンバケットアルゴリズム

class RateLimiter {
  constructor({ requestsPerMinute }) {
    this.interval = (60 * 1000) / requestsPerMinute; // ms per request
    this.lastRequestTime = 0;
    this.queue = [];
    this.processing = false;
  }

  async schedule(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      this.processQueue();
    });
  }

  async processQueue() {
    if (this.processing) return;
    this.processing = true;

    while (this.queue.length > 0) {
      const now = Date.now();
      const elapsed = now - this.lastRequestTime;
      const waitTime = Math.max(0, this.interval - elapsed);

      if (waitTime > 0) {
        await sleep(waitTime);
      }

      const { fn, resolve, reject } = this.queue.shift();
      this.lastRequestTime = Date.now();

      try {
        const result = await fn();
        resolve(result);
      } catch (err) {
        reject(err);
      }
    }

    this.processing = false;
  }
}

// 使用例: 1分あたり50リクエストに制限
const limiter = new RateLimiter({ requestsPerMinute: 50 });

async function generatePdf(html, filename) {
  return limiter.schedule(() =>
    fetch('https://pdf.funbrew.cloud/api/v1/pdf', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${process.env.API_KEY}` },
      body: JSON.stringify({ html, filename }),
    })
  );
}

X-RateLimit-Remaining を監視した動的スロットリング

レスポンスヘッダーから残りリクエスト数を読み取り、消費が速い場合は自動でペースを落とします。

class AdaptiveRateLimiter {
  constructor({ safetyMargin = 0.2 } = {}) {
    this.safetyMargin = safetyMargin; // 上限の20%手前でブレーキ
    this.remainingRequests = Infinity;
    this.resetTime = null;
  }

  updateFromHeaders(headers) {
    const remaining = headers.get('x-ratelimit-remaining');
    const reset = headers.get('x-ratelimit-reset');
    if (remaining) this.remainingRequests = parseInt(remaining, 10);
    if (reset) this.resetTime = parseInt(reset, 10) * 1000; // ms
  }

  async waitIfNeeded() {
    if (this.remainingRequests === Infinity) return;

    const limit = this.remainingRequests;
    if (limit <= 0 && this.resetTime) {
      // 残数が0になったらリセット時刻まで待機
      const waitMs = Math.max(0, this.resetTime - Date.now());
      console.log(`Rate limit near zero. Waiting ${Math.ceil(waitMs / 1000)}s for reset.`);
      await sleep(waitMs + 100); // 100msのバッファ
      return;
    }

    // 残数が上限の20%以下になったら自動で遅延を入れる
    if (limit < 10) {
      await sleep(500);
    }
  }
}

const adaptive = new AdaptiveRateLimiter();

async function generatePdfAdaptive(html, filename) {
  await adaptive.waitIfNeeded();

  const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.API_KEY}` },
    body: JSON.stringify({ html, filename }),
  });

  adaptive.updateFromHeaders(response.headers);
  return response;
}

バッチ処理でのレート制限管理

大量のPDFを生成する場合は、並行数を制限することでレート制限に当たりにくくします。

async function generateBatch(items, { concurrency = 5, requestsPerMinute = 50 } = {}) {
  const limiter = new RateLimiter({ requestsPerMinute });
  const results = [];
  const errors = [];

  // p-limitなどのライブラリを使って並行数を制限
  const chunks = chunkArray(items, concurrency);

  for (const chunk of chunks) {
    const chunkResults = await Promise.allSettled(
      chunk.map(item =>
        limiter.schedule(() => generatePdf(item.html, item.filename))
      )
    );

    for (const result of chunkResults) {
      if (result.status === 'fulfilled') {
        results.push(result.value);
      } else {
        errors.push(result.reason);
      }
    }
  }

  return { results, errors };
}

function chunkArray(arr, size) {
  const chunks = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}

Python での実装

import asyncio
import time
from aiohttp import ClientSession

class RateLimiter:
    def __init__(self, requests_per_minute: int):
        self.interval = 60.0 / requests_per_minute
        self.last_request_time = 0.0
        self._lock = asyncio.Lock()

    async def acquire(self):
        async with self._lock:
            now = time.monotonic()
            elapsed = now - self.last_request_time
            wait_time = max(0, self.interval - elapsed)
            if wait_time > 0:
                await asyncio.sleep(wait_time)
            self.last_request_time = time.monotonic()

async def generate_pdf_with_retry(
    session: ClientSession,
    limiter: RateLimiter,
    payload: dict,
    max_attempts: int = 5,
) -> dict:
    delay = 0.5

    for attempt in range(1, max_attempts + 1):
        await limiter.acquire()

        async with session.post(
            "https://pdf.funbrew.cloud/api/v1/pdf",
            json=payload,
        ) as resp:
            if resp.status == 429:
                retry_after = int(resp.headers.get("retry-after", delay))
                print(f"Rate limited. Waiting {retry_after}s (attempt {attempt}/{max_attempts})")
                if attempt == max_attempts:
                    raise RuntimeError("Max retry attempts reached")
                await asyncio.sleep(retry_after)
                delay = min(delay * 2, 30)
                continue

            resp.raise_for_status()
            return await resp.json()

    raise RuntimeError("Unexpected: exhausted retry loop")

モニタリングとアラート

レート制限エラーをモニタリングして、プランのアップグレードや設計の見直しのタイミングを把握します。

const metrics = {
  totalRequests: 0,
  rateLimitHits: 0,
  retries: 0,
};

async function generatePdfWithMetrics(html, filename) {
  metrics.totalRequests++;
  try {
    const result = await fetchWithExponentialBackoff(
      'https://pdf.funbrew.cloud/api/v1/pdf',
      { method: 'POST', body: JSON.stringify({ html, filename }) }
    );
    return result;
  } catch (err) {
    if (err.message.includes('429')) {
      metrics.rateLimitHits++;
    }
    throw err;
  }
}

// 1時間ごとにレート制限ヒット率をログ
setInterval(() => {
  const hitRate = metrics.totalRequests > 0
    ? (metrics.rateLimitHits / metrics.totalRequests * 100).toFixed(1)
    : 0;
  console.log(`Rate limit hit rate: ${hitRate}% (${metrics.rateLimitHits}/${metrics.totalRequests})`);

  if (parseFloat(hitRate) > 5) {
    // 5%を超えたらSlackやメールでアラート
    console.warn('High rate limit hit rate — consider upgrading plan or reducing concurrency');
  }
}, 60 * 60 * 1000);

まとめ

レート制限対策の要点:

  • Retry-Afterを必ず読む: 指定された秒数より短く待機してはならない
  • ジッターを加えたバックオフ: 複数ワーカーの同時リトライを分散させる
  • プリエンプティブスロットリング: 429を受け取る前に自分でペースを制御する
  • 並行数の制限: バッチ処理では concurrency を API の上限に合わせて調整する
  • X-RateLimit-Remaining の監視: 残数が少なくなったら自動的に遅延を入れる

本番環境でのその他の安定運用策についてはPDF API本番運用ガイドを参照してください。エラー全般のハンドリングはPDF APIエラーハンドリングガイドでも解説しています。

関連リンク

Powered by FUNBREW PDF