PDF API レート制限ガイド|429エラー・指数バックオフ・スロットリング実装
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エラーハンドリングガイドでも解説しています。
関連リンク
- PDF API本番運用ガイド — 本番環境での安定運用全般
- PDF APIエラーハンドリングガイド — エラーコード別の対処法
- PDF API バッチ処理ガイド — 大量PDF生成の設計パターン
- APIドキュメント — レート制限の詳細仕様
- Playground — APIの動作確認