APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
- 429 Too Many Requests は「しばらく待て」という協調シグナル。
- Retry-After と RateLimit ヘッダーを読んで待機時間を決め、429 前に Remaining でペースを落とすプロアクティブスロットリングを組み合わせる。
- 固定 1 秒リトライは避け、指数バックオフ+ジッターで同期再試行の雪崩を防ぐ。
- サーバー側のアルゴリズム選定は APIのレート制限(Rate Limiting)実装ガイド を参照。
429 Too Many Requests が返る典型シナリオ
外部 API 連携、Webhook 再送、バッチ同期、マイクロサービス間のポーリング——いずれも 429 は「異常」ではなく日常になりうる。429 が返る直接原因は、サーバーが設定した時間窓あたりのリクエスト上限を超えたことだ。
よくあるトリガーは次のとおり。
- フロントエンドの連打 — 検索の debounce なし、ページネーションの並列 prefetch
- バッチジョブの過剰並列 — ワーカー数 × リトライがクォータを一気に使い切る
- マルチテナント SaaS — 1 テナントの暴走が API キー単位の上限に達する
- サードパーティ API の共有クォータ — ステージングと本番で同じキーを使っている
429 の本体はステータスコードだけでは不十分で、Retry-After と RateLimit 系ヘッダーがクライアントの次の一手を決める。ここを読まずに fetch をループすると、自分のサービスが自分の API を落とす——典型的な「リトライの雪崩(retry storm)」になる。
サーバー側で Token Bucket や Sliding Window をどう実装するかは、APIのレート制限(Rate Limiting)実装ガイド で解説している。本記事は クライアントがヘッダーを正しく解釈し、429 前後で適切に待つことに焦点を当てる。
RateLimit ヘッダー:IETF Draft と事実上の慣習
かつて X-RateLimit-* はベンダー独自の慣習だったが、IETF の RateLimit header fields for HTTP 草案では RateLimit / RateLimit-Policy / RateLimit-Remaining など X- なしの名前が標準化に向けて整理されている。
実務では次の3層が混在する。
| 層 | 例 | 備考 |
|---|---|---|
| 草案準拠 | RateLimit-Remaining: 42 | 新規 API 設計で採用が増えている |
慣習的 X- 付き | X-RateLimit-Remaining: 42 | GitHub、多くの REST API |
| 429 専用 | Retry-After: 60 | RFC 9110 正式定義。429/503 で使用 |
クライアント実装では 大文字小文字を区別しない(HTTP 仕様)ことと、単位の確認(Reset が Unix 秒か、残り秒数か、ミリ秒か)が最重要だ。ドキュメントに書いていない API ほど、実レスポンスをキャプチャして確認する。
HTTP/1.1 200 OK
RateLimit-Limit: 1000;w=3600
RateLimit-Remaining: 847
RateLimit-Reset: 1718863200
GitHub と Stripe:ヘッダー仕様の比較
同じ「レート制限」でも、ベンダーごとに返すヘッダーと意味が異なる。代表的な GitHub REST API と Stripe API の差を整理する。
| 項目 | GitHub REST API / Stripe API |
|---|---|
| 429 時の Retry-After | GitHub: 返る場合あり(秒数)。Stripe: ほぼ必ず秒数で返す(例: Retry-After: 1) |
| Limit ヘッダー | GitHub: X-RateLimit-Limit(時間窓あたり上限)。Stripe: プラン依存、200 時は RateLimit 系より Request-Id が中心 |
| Remaining ヘッダー | GitHub: X-RateLimit-Remaining(残り回数)。Stripe: 200 時は明示的 Remaining が無いことも多く、429 時の Retry-After を主に参照 |
| Reset の意味 | GitHub: X-RateLimit-Reset は Unix 秒の絶対時刻。Stripe: Reset より Retry-After(相対秒数) を優先 |
| 403 vs 429 | GitHub: クォータ超過は 403(rate_limit)と 429 の両方があり得る——403 でも Remaining=0 なら待機。Stripe: 429 が標準 |
| Secondary rate limit | GitHub: 403 + X-RateLimit-Remaining: 0 で二次制限。Stripe: エンドポイント別の burst 制限はドキュメント参照 |
| クライアント実装の示唆 | GitHub: Reset から resetSec - nowSec を計算。Stripe: 429 時は Retry-After を最優先、指数バックオフはフォールバック |
GitHub では 403 Forbidden が返っても X-RateLimit-Remaining: 0 ならレート制限が原因のことがある。ステータスコードだけで分岐せず、ヘッダーを見て待機するのが安全だ。
Retry-After の仕様と HTTP-date パースのエッジケース
Retry-After は RFC 9110 で定義される。値は次の2形式のみが有効。
- delay-seconds(整数) —
Retry-After: 120→ 120 秒待つ - HTTP-date —
Retry-After: Wed, 21 Jun 2026 07:28:00 GMT→ その時刻まで待つ
秒数形式のエッジケース
| 入力 | 期待動作 | 理由 |
|---|---|---|
0 | 0 秒(即再試行可だがジッター推奨) | 仕様上有効 |
120 | 120 秒 | 通常ケース |
60 | 60 秒 | 前後空白は trim |
60.5 | 無効として null | 整数のみ有効。小数は HTTP-date 側に回さない |
-1 | 0 秒に clamp または無効 | 負数は仕様外 |
999999 | 上限キャップ(例: 300 秒) | 異常値・DoS 的待機の防止 |
HTTP-date 形式のエッジケース
| 入力 | 期待動作 | 落とし穴 |
|---|---|---|
Wed, 21 Jun 2026 07:28:00 GMT | その時刻までの秒数 | 標準形式 |
Wed, 21 Jun 2026 07:28:00 | パース可能なら同様 | タイムゾーン省略時は 実装依存(UTC 扱いを推奨) |
| 過去の日時 | 0 秒 | 既に Reset 済み。即再試行可 |
不正文字列 soon | null → バックオフへ | Date.parse が NaN |
Date.parse と new Date の差 | 統一して Date.parse | Safari 過去バグ対策でテスト必須 |
const MAX_RETRY_AFTER_SEC = 300;
export function parseRetryAfter(
header: string | null,
nowMs: number = Date.now(),
): number | null {
if (header == null) return null;
const trimmed = header.trim();
if (trimmed === "") return null;
// delay-seconds: 非負整数のみ("60.5" は HTTP-date ではない)
if (/^\d+$/.test(trimmed)) {
const seconds = Number(trimmed);
return Math.min(MAX_RETRY_AFTER_SEC, Math.max(0, seconds));
}
const targetMs = Date.parse(trimmed);
if (Number.isNaN(targetMs)) return null;
const diffSec = Math.ceil((targetMs - nowMs) / 1000);
return Math.min(MAX_RETRY_AFTER_SEC, Math.max(0, diffSec));
}
axios-retry や got のデフォルトが「429 を 5xx と同じ 1 秒待機」になっている例がある。429 専用ハンドラを必ず差し込む。サーバーが Retry-After: 120 と言っているのに 1 秒後に再送すると、120 秒分の 429 が返り続ける。
待機時間の優先順位と指数バックオフ
429 受信時の待機秒数は、次の優先順位で決めるのが実務的な定石だ。
1. Retry-After(429/503 時)—— サーバーが明示した待機を最優先
2. RateLimit-Reset / X-RateLimit-Reset から残り秒数を逆算
3. 指数バックオフ: base × 2^attempt に上限キャップ(例: 最大 60 秒)
4. ジッター: delay + random(0, 0.3 × delay) で同期再試行を散らす
X-RateLimit-Reset の解釈
Reset ヘッダーはベンダーによって 絶対 Unix 秒 と 残り秒数 のどちらかになる。
export type ResetSemantics = "absolute-unix-sec" | "relative-sec";
export function waitSecondsFromReset(
resetRaw: string | null,
semantics: ResetSemantics,
nowSec: number = Math.floor(Date.now() / 1000),
): number | null {
if (resetRaw == null) return null;
const reset = Number(resetRaw);
if (Number.isNaN(reset)) return null;
if (semantics === "relative-sec") {
return Math.max(0, Math.ceil(reset));
}
// absolute-unix-sec(GitHub 等)
return reset > nowSec ? reset - nowSec : 0;
}
指数バックオフ+ジッター
export function backoffWithJitterMs(
attempt: number,
baseMs = 500,
capMs = 60_000,
): number {
const exp = Math.min(capMs, baseMs * 2 ** attempt);
const jitter = Math.random() * exp * 0.3;
return Math.ceil(exp + jitter);
}
Full Jitter(AWS 推奨パターン)を使う場合は random(0, exp) も選択肢だ。429 では Retry-After が返っている間はバックオフより Retry-After を優先し、バックオフは「ヘッダーが無いときだけ」使う。
プロアクティブスロットリングのアルゴリズム
429 を受けてから待つのは リアクティブ。Remaining が十分返る API では、429 前にペースを落とす プロアクティブスロットリング の方が UX もサーバー負荷も改善する。
アルゴリズム概要
- 各レスポンスで
limitとremainingを状態に保存する remaining / limit < threshold(例: 10%)なら 送信間隔を線形に引き上げるremaining === 0なら Reset まで 新規リクエストをキューに溜め、Release まで dequeue しない- キューから出すときは トークン桶(Token Bucket)で平均レートを平準化する
export interface RateLimitSnapshot {
limit: number;
remaining: number;
resetAtSec: number; // 絶対 Unix 秒
}
export class ProactiveThrottler {
private snapshot: RateLimitSnapshot | null = null;
private lastSentAt = 0;
constructor(
private readonly lowWatermarkRatio = 0.1,
private readonly minIntervalMs = 100,
private readonly maxIntervalMs = 5_000,
) {}
updateFromHeaders(headers: Headers, semantics: ResetSemantics = "absolute-unix-sec"): void {
const limit = Number(headers.get("x-ratelimit-limit") ?? headers.get("ratelimit-limit"));
const remaining = Number(headers.get("x-ratelimit-remaining") ?? headers.get("ratelimit-remaining"));
const resetRaw = headers.get("x-ratelimit-reset") ?? headers.get("ratelimit-reset");
if ([limit, remaining].some(Number.isNaN) || resetRaw == null) return;
const nowSec = Math.floor(Date.now() / 1000);
const resetSec =
semantics === "absolute-unix-sec"
? Number(resetRaw)
: nowSec + Number(resetRaw);
this.snapshot = { limit, remaining, resetAtSec: resetSec };
}
/** 次のリクエストを送る前に await すべき毫秒 */
async waitBeforeNextSend(): Promise<void> {
if (!this.snapshot) return;
const { limit, remaining, resetAtSec } = this.snapshot;
const ratio = remaining / limit;
if (remaining <= 0) {
const waitSec = Math.max(0, resetAtSec - Math.floor(Date.now() / 1000));
await sleep(waitSec * 1000);
return;
}
if (ratio >= this.lowWatermarkRatio) return;
// 残量が少ないほど間隔を広げる(線形補間)
const pressure = 1 - ratio / this.lowWatermarkRatio; // 0..1
const interval =
this.minIntervalMs + pressure * (this.maxIntervalMs - this.minIntervalMs);
const elapsed = Date.now() - this.lastSentAt;
if (elapsed < interval) await sleep(interval - elapsed);
this.lastSentAt = Date.now();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
バッチ処理では ワーカー数を Remaining に合わせて動的に下げる(例: remaining < 50 なら concurrency 10 → 2)のも効果的だ。サーバー側の Token Bucket 設計とクライアント側のプロアクティブ制御を揃えると、429 率を桁違いに下げられる。
TypeScript クライアント:統合実装
以下は GitHub 向け(Reset = Unix 秒)を想定した 再利用可能なクライアントだ。Stripe 連携時は resetSemantics を変えず、429 時の Retry-After 優先ロジックがそのまま効く。
export type VendorProfile = {
resetSemantics: ResetSemantics;
/** 403 でも RateLimit を見る(GitHub 二次制限) */
treat403AsRateLimit: boolean;
};
export const GITHUB_PROFILE: VendorProfile = {
resetSemantics: "absolute-unix-sec",
treat403AsRateLimit: true,
};
export const STRIPE_PROFILE: VendorProfile = {
resetSemantics: "absolute-unix-sec",
treat403AsRateLimit: false,
};
export function computeWaitSeconds(
status: number,
headers: Headers,
attempt: number,
profile: VendorProfile,
nowMs: number = Date.now(),
): number | null {
const isRateLimited =
status === 429 || (profile.treat403AsRateLimit && status === 403);
if (isRateLimited) {
const retryAfter = parseRetryAfter(headers.get("retry-after"), nowMs);
if (retryAfter != null) return retryAfter;
const resetRaw =
headers.get("x-ratelimit-reset") ?? headers.get("ratelimit-reset");
const fromReset = waitSecondsFromReset(
resetRaw,
profile.resetSemantics,
Math.floor(nowMs / 1000),
);
if (fromReset != null && fromReset > 0) return fromReset;
}
if (status === 429) {
return Math.ceil(backoffWithJitterMs(attempt) / 1000);
}
return null;
}
export async function fetchWithRateLimitRetry(
input: RequestInfo | URL,
init: RequestInit | undefined,
options: {
maxAttempts?: number;
profile?: VendorProfile;
throttler?: ProactiveThrottler;
fetchFn?: typeof fetch;
} = {},
): Promise<Response> {
const {
maxAttempts = 5,
profile = GITHUB_PROFILE,
throttler = new ProactiveThrottler(),
fetchFn = fetch,
} = options;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await throttler.waitBeforeNextSend();
const res = await fetchFn(input, init);
throttler.updateFromHeaders(res.headers, profile.resetSemantics);
const isRateLimited =
res.status === 429 ||
(profile.treat403AsRateLimit && res.status === 403);
if (!isRateLimited) return res;
const waitSec = computeWaitSeconds(
res.status,
res.headers,
attempt,
profile,
);
if (waitSec == null || attempt === maxAttempts - 1) return res;
await sleep(waitSec * 1000);
}
throw new Error("fetchWithRateLimitRetry: unreachable");
}
GET / PUT / DELETE は自動リトライしやすい。POST は処理の重複が起きうるため、Idempotency-Key など別設計とセットにする。429 待機中に同じ POST を複数飛ばすバグは、決済系で特に危険だ。
Vitest によるテストケース
ヘッダーパースは 表形式のテストで網羅する。モック fetch で 429 → 200 のフローも検証する。
// rate-limit-client.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
parseRetryAfter,
waitSecondsFromReset,
backoffWithJitterMs,
computeWaitSeconds,
fetchWithRateLimitRetry,
GITHUB_PROFILE,
} from "./rate-limit-client";
describe("parseRetryAfter", () => {
const NOW = new Date("2026-06-21T07:00:00.000Z").getTime();
it("秒数形式: 正の整数", () => {
expect(parseRetryAfter("120", NOW)).toBe(120);
});
it("秒数形式: 0", () => {
expect(parseRetryAfter("0", NOW)).toBe(0);
});
it("秒数形式: 前後空白", () => {
expect(parseRetryAfter(" 60 ", NOW)).toBe(60);
});
it("秒数形式: 小数は無効", () => {
expect(parseRetryAfter("60.5", NOW)).toBeNull();
});
it("秒数形式: 上限キャップ", () => {
expect(parseRetryAfter("999999", NOW)).toBe(300);
});
it("HTTP-date: 未来の時刻", () => {
const header = "Wed, 21 Jun 2026 07:28:00 GMT";
expect(parseRetryAfter(header, NOW)).toBe(28 * 60); // 28分
});
it("HTTP-date: 過去の時刻は 0", () => {
const header = "Wed, 21 Jun 2026 06:00:00 GMT";
expect(parseRetryAfter(header, NOW)).toBe(0);
});
it("不正値: null / 空 / 文字列", () => {
expect(parseRetryAfter(null, NOW)).toBeNull();
expect(parseRetryAfter("", NOW)).toBeNull();
expect(parseRetryAfter("soon", NOW)).toBeNull();
});
});
describe("waitSecondsFromReset", () => {
it("絶対 Unix 秒: 未来", () => {
expect(waitSecondsFromReset("1718863200", "absolute-unix-sec", 1718860000)).toBe(3200);
});
it("絶対 Unix 秒: 過去は 0", () => {
expect(waitSecondsFromReset("1718860000", "absolute-unix-sec", 1718863200)).toBe(0);
});
it("相対秒数", () => {
expect(waitSecondsFromReset("45", "relative-sec", 1718860000)).toBe(45);
});
});
describe("backoffWithJitterMs", () => {
it("attempt 0 は base 以上 cap 以下", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
expect(backoffWithJitterMs(0, 500, 60_000)).toBe(500);
vi.restoreAllMocks();
});
it("attempt が増えると指数増加", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
expect(backoffWithJitterMs(3, 500, 60_000)).toBe(4000);
vi.restoreAllMocks();
});
});
describe("computeWaitSeconds", () => {
const NOW = new Date("2026-06-21T07:00:00.000Z").getTime();
it("429 + Retry-After を最優先", () => {
const h = new Headers({ "retry-after": "30" });
expect(computeWaitSeconds(429, h, 0, GITHUB_PROFILE, NOW)).toBe(30);
});
it("429 + Reset フォールバック", () => {
const h = new Headers({ "x-ratelimit-reset": "1718863200" });
expect(computeWaitSeconds(429, h, 0, GITHUB_PROFILE, NOW)).toBe(3200);
});
it("403 + Remaining=0 を GitHub プロファイルで処理", () => {
const h = new Headers({
"retry-after": "10",
"x-ratelimit-remaining": "0",
});
expect(computeWaitSeconds(403, h, 0, GITHUB_PROFILE, NOW)).toBe(10);
});
});
describe("fetchWithRateLimitRetry", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("429 後 Retry-After 待機して再試行し 200 を返す", async () => {
const fetchFn = vi
.fn()
.mockResolvedValueOnce(
new Response("limited", {
status: 429,
headers: { "retry-after": "1" },
}),
)
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const promise = fetchWithRateLimitRetry("/api", undefined, {
fetchFn,
maxAttempts: 3,
throttler: { waitBeforeNextSend: async () => {}, updateFromHeaders: () => {} } as never,
});
await vi.advanceTimersByTimeAsync(1000);
const res = await promise;
expect(res.status).toBe(200);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it("最大試行回数超過で最後の 429 を返す", async () => {
const fetchFn = vi.fn().mockResolvedValue(
new Response("limited", {
status: 429,
headers: { "retry-after": "0" },
}),
);
const promise = fetchWithRateLimitRetry("/api", undefined, {
fetchFn,
maxAttempts: 2,
throttler: { waitBeforeNextSend: async () => {}, updateFromHeaders: () => {} } as never,
});
await vi.advanceTimersByTimeAsync(0);
const res = await promise;
expect(res.status).toBe(429);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
});
テストでは 固定 nowMs を注入し、HTTP-date の計算を決定論的に保つ。CI ではタイムゾーン TZ=UTC を指定すると、ローカルと GitHub Actions で結果が揃いやすい。
401・403・503 との使い分け(クライアント視点)
429 だけを特別扱いし、他ステータスと混同しない。
| ステータス | 意味 | クライアントの典型対応 |
|---|---|---|
| 401 | 認証失敗 | トークン更新。リトライしても無駄なことが多い |
| 403 | 権限不足(GitHub では Rate Limit 兼用) | ヘッダーで Remaining=0 なら待機、それ以外は人間対応 |
| 429 | クォータ超過 | Retry-After / Reset 待機 → リトライ |
| 503 | サーバー過負荷 | Retry-After があれば従う。サーキットブレーカー検討 |
運用・ログ・監視
本番で 429 対応が効いているかは、コードレビューより メトリクスで見る。
rate_limit_wait_seconds— Retry-After から計算した待機時間のヒストグラムrate_limit_remaining_min— ウィンドウ内の最小 Remaining(事前スロットリングの効き目)http_429_total— エンドポイント・API キー別の 429 率- 構造化ログ —
{ status, retryAfter, remaining, reset, attempt, url }を 1 行 JSON で
アラートは「429 率 > 5% が 5 分継続」のように 比率ベースにする。絶対数だけだと、トラフィック増加と区別できない。
サーバー提供者として Retry-After を 429 に必ず付与し、200 時も RateLimit ヘッダーを返す——詳細は APIのレート制限(Rate Limiting)実装ガイド の「429 の返し方」を参照。
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
まとめ
429 は API からの 協調の依頼であり、失敗ではない。Retry-After(秒数・HTTP-date 両対応)を最優先し、Reset と 指数バックオフ+ジッターをフォールバックに使う。GitHub と Stripe のようにヘッダー仕様はベンダー差が大きいので、プロファイル抽象化が長期運用の鍵になる。
429 を減らすには、429 後の待機だけでなく Remaining を監視するプロアクティブスロットリングを組み込む。TypeScript クライアントと Vitest テストでパースと再試行を固定し、本番では待機秒数と 429 率をメトリクス化する——この3点セットが、夜中の retry storm アラートを防ぐ実務的な落とし所だ。