APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装

API レート制限 HTTP 429 リトライ TypeScript
結論
  • 429 Too Many Requests は「しばらく待て」という協調シグナル。
  • Retry-AfterRateLimit ヘッダーを読んで待機時間を決め、429 前に Remaining でペースを落とすプロアクティブスロットリングを組み合わせる。
  • 固定 1 秒リトライは避け、指数バックオフ+ジッターで同期再試行の雪崩を防ぐ。
  • サーバー側のアルゴリズム選定は APIのレート制限(Rate Limiting)実装ガイド を参照。

429 Too Many Requests が返る典型シナリオ

外部 API 連携、Webhook 再送、バッチ同期、マイクロサービス間のポーリング——いずれも 429 は「異常」ではなく日常になりうる。429 が返る直接原因は、サーバーが設定した時間窓あたりのリクエスト上限を超えたことだ。

よくあるトリガーは次のとおり。

  • フロントエンドの連打 — 検索の debounce なし、ページネーションの並列 prefetch
  • バッチジョブの過剰並列 — ワーカー数 × リトライがクォータを一気に使い切る
  • マルチテナント SaaS — 1 テナントの暴走が API キー単位の上限に達する
  • サードパーティ API の共有クォータ — ステージングと本番で同じキーを使っている

429 の本体はステータスコードだけでは不十分で、Retry-AfterRateLimit 系ヘッダーがクライアントの次の一手を決める。ここを読まずに 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: 42GitHub、多くの REST API
429 専用Retry-After: 60RFC 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-ResetUnix 秒の絶対時刻。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形式のみが有効。

  1. delay-seconds(整数)Retry-After: 120 → 120 秒待つ
  2. HTTP-dateRetry-After: Wed, 21 Jun 2026 07:28:00 GMT → その時刻まで待つ

秒数形式のエッジケース

入力期待動作理由
00 秒(即再試行可だがジッター推奨)仕様上有効
120120 秒通常ケース
60 60 秒前後空白は trim
60.5無効として null整数のみ有効。小数は HTTP-date 側に回さない
-10 秒に clamp または無効負数は仕様外
999999上限キャップ(例: 300 秒)異常値・DoS 的待機の防止

HTTP-date 形式のエッジケース

入力期待動作落とし穴
Wed, 21 Jun 2026 07:28:00 GMTその時刻までの秒数標準形式
Wed, 21 Jun 2026 07:28:00パース可能なら同様タイムゾーン省略時は 実装依存(UTC 扱いを推奨)
過去の日時0 秒既に Reset 済み。即再試行可
不正文字列 soonnull → バックオフへDate.parse が NaN
Date.parsenew Date の差統一して Date.parseSafari 過去バグ対策でテスト必須
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));
}
Retry-After を無視しない

axios-retry や got のデフォルトが「429 を 5xx と同じ 1 秒待機」になっている例がある。429 専用ハンドラを必ず差し込む。サーバーが Retry-After: 120 と言っているのに 1 秒後に再送すると、120 秒分の 429 が返り続ける。

待機時間の優先順位と指数バックオフ

429 受信時の待機秒数は、次の優先順位で決めるのが実務的な定石だ。

1

1. Retry-After(429/503 時)—— サーバーが明示した待機を最優先

2

2. RateLimit-Reset / X-RateLimit-Reset から残り秒数を逆算

3

3. 指数バックオフ: base × 2^attempt に上限キャップ(例: 最大 60 秒)

4

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 もサーバー負荷も改善する。

アルゴリズム概要

  1. 各レスポンスで limitremaining を状態に保存する
  2. remaining / limit < threshold(例: 10%)なら 送信間隔を線形に引き上げる
  3. remaining === 0 なら Reset まで 新規リクエストをキューに溜め、Release まで dequeue しない
  4. キューから出すときは トークン桶(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 アラートを防ぐ実務的な落とし所だ。