TECH JOURNAL

Amazon SP-API のレートリミット制約と、本番運用の 5 つの実装パターン

ShareXB!
Amazon SP-API のレートリミット制約と、本番運用の 5 つの実装パターン
目次

はじめに

Amazon SP-API は、在庫・注文・出荷・商品・レポート・Ads など多岐にわたる機能を提供するが、レートリミットが用途別・テナント別・エンドポイント別に細かく設定されている。

本番運用に入ると、429 (TooManyRequests) が散発し始め、次のような問題が噴出しやすい。

  • 注文ポーリングの Lambda がスパイク時に 429 を受けてリトライ嵐に陥り、処理が数分間詰まる
  • 複数の Lambda 関数が同一 SP-API エンドポイントを叩いていて、互いの消費が見えずトークンを食い合う
  • 大量注文インポートのために getOrders をループしたら、数百件で即座にバーストを使い果たした

これらをまとめて解決するために、ここでは Token Bucket の仕組みから始め、本番で実績のある 5 つの実装パターンを紹介する。

前提: SP-API のレートリミットモデル

SP-API のレートリミットは Token Bucket モデルを採用している。エンドポイントごとに次の 2 パラメータが公式ドキュメントで公開されている。

  • Rate: 1 秒あたりに補充されるトークン数
  • Burst: バケットの最大サイズ(瞬発的に連続実行できる上限)

たとえば getOrders は Rate=0.0167, Burst=20(= 約 60 秒に 1 トークン補充、最大 20 リクエストまで瞬時に実行可能)。一方 getOrderItems は Rate=0.5, Burst=30 とやや余裕がある。

429 を受けたレスポンスには x-amzn-RateLimit-Limit ヘッダが含まれており、現在のバケット上限レートを確認できる。

// 429 レスポンスのヘッダから現在のレートを読む
const rateLimit = parseFloat(
  response.headers.get("x-amzn-RateLimit-Limit") ?? "0"
)
// 例: "0.0167" → 約 60 秒に 1 トークン

重要: テナント(出品アカウント)ごとにバケットは独立している。マルチテナント構成では、アカウント数に比例してリクエスト予算が増えるが、同一アカウントに対して複数 Lambda がリクエストを投げると食い合いになる。

実装パターン 1: Exponential Backoff + Full Jitter

最初に入れるべき基本対策。429 を受けたら 2^attempt × baseMs の指数バックオフに、0〜1 のランダムな Full Jitter を乗せてリトライする。

Jitter なしの純粋な指数バックオフは、複数の Lambda インスタンスが一斉にリトライしたとき「Thundering Herd」を引き起こす。Full Jitter はリトライのタイミングを分散させ、集中砲火を防ぐ。

async function retryWithBackoff<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (error) {
      if (!isRateLimitError(error) || attempt === maxAttempts - 1) throw error
      const baseMs = 1000 * 2 ** attempt     // 1s, 2s, 4s, 8s, 16s
      const jitter = Math.random() * baseMs  // 0〜baseMs のランダム幅
      await sleep(baseMs + jitter)
    }
  }
  throw new Error("unreachable")
}
 
function isRateLimitError(err: unknown): boolean {
  return (err as { statusCode?: number }).statusCode === 429
}
 
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

maxAttempts=5 のとき最大待機時間は約 1+2+4+8+16 = 31 秒。それを超えても回復しない場合は上流のキューに戻して Dead Letter Queue に積む。

実装パターン 2: x-amzn-RateLimit-Limit ヘッダによる先回りスロットリング

429 を「受けてからリトライ」するのは後手の対策。成功レスポンスのヘッダを見て、次のリクエストまでの間隔を動的に調整すれば 429 そのものを減らせる。

x-amzn-RateLimit-Limit は成功レスポンスにも付与されており、現在のバケットレート(1 秒あたりのトークン補充数)を返す。

async function throttledFetch(url: string, init?: RequestInit): Promise<Response> {
  const response = await fetch(url, init)
 
  const rateHeader = response.headers.get("x-amzn-RateLimit-Limit")
  if (rateHeader) {
    const ratePerSec = parseFloat(rateHeader)
    // 補充レートの逆数 = 最低限必要なリクエスト間隔(ms)
    const minIntervalMs = (1 / ratePerSec) * 1000
    // 安全マージン 10% を乗せて待機
    await sleep(minIntervalMs * 1.1)
  }
 
  return response
}

getOrders (Rate=0.0167) なら約 60 秒に 1 回のペース、getOrderItems (Rate=0.5) なら約 2 秒に 1 回が適正間隔になる。ページネーションループで大量ページを取得する場合に特に効果的。

実装パターン 3: SQS キューによる流量平準化

バーストが発生するのは「まとめて大量リクエストを投げる設計」が原因であることが多い。SP-API 呼び出しをキューに積み、専用の Consumer Lambda が一定レートで消費する構成に変えると、バーストリミットを超えにくくなる。

Producer Lambda (注文 ID を列挙)
    ↓
SQS Standard Queue (バッファ)
    ↓
Consumer Lambda (Concurrency=1, Reserved)
    ↓
SP-API getOrderItems
    ↓
DynamoDB / OMS
// Producer: 注文 ID を SQS に積むだけ
await sqs.sendMessageBatch({
  QueueUrl: ORDER_ITEMS_QUEUE_URL,
  Entries: orderIds.map((id, i) => ({
    Id: String(i),
    MessageBody: JSON.stringify({ orderId: id }),
  })),
})
 
// Consumer Lambda (Lambda Reserved Concurrency=1 で多重実行を防ぐ)
export const handler: SQSHandler = async (event) => {
  for (const record of event.Records) {
    const { orderId } = JSON.parse(record.body)
    await fetchAndSaveOrderItems(orderId)
    // Rate=0.5 のエンドポイントなら 2 秒に 1 件
    await sleep(2000)
  }
}

Reserved Concurrency=1 にすることで Consumer が同時に 1 インスタンスしか動かなくなり、意図せず並列消費してトークンを食い合うリスクを排除できる。

実装パターン 4: Redis 共有レートリミッタ(複数 Lambda 間の調整)

同一アカウントに対して複数の機能ドメイン(注文取込・在庫更新・出荷連携など)が独立した Lambda から SP-API を叩く場合、各 Lambda のバックオフだけではトークンの「食い合い」を防げない。Redis を共有カウンタとして使い、プロセス横断でリクエスト数をレートリミットするパターンが有効。

import { createClient } from "redis"
 
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
 
// 直近の発行時刻を見て、interval 以上空いていれば now を記録して 1 を返す Lua。
// GET と SET を 1 スクリプトに閉じることで、複数 Lambda が同時に呼んでも
// 「空キーを観測 → 全員が通過」という競合が起きず、スロット取得がアトミックになる。
const ACQUIRE_LUA = `
local last = redis.call('GET', KEYS[1])
local now = tonumber(ARGV[1])
local interval = tonumber(ARGV[2])
if (not last) or (now - tonumber(last) >= interval) then
  redis.call('SET', KEYS[1], now, 'PX', math.ceil(interval * 2))
  return 1
end
return 0
`
 
async function acquireToken(
  endpoint: string,
  ratePerSec: number,
  timeoutMs = 30_000
): Promise<void> {
  const key = `sp-api:rate:${endpoint}`
  const intervalMs = (1 / ratePerSec) * 1000
  const deadline = Date.now() + timeoutMs
 
  while (Date.now() < deadline) {
    // チェックと記録をアトミックに行う(get→set の間に他インスタンスが割り込めない)
    const acquired = await redis.eval(ACQUIRE_LUA, {
      keys: [key],
      arguments: [String(Date.now()), String(Math.ceil(intervalMs))],
    })
    if (acquired === 1) return // スロット取得成功
    await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())))
  }
  throw new Error(`Rate limiter timeout for ${endpoint}`)
}
 
// 使い方
await acquireToken("getOrders", 0.0167)
const response = await ordersApi.getOrders(/* ... */)

Redis の TTL をインターバルの 2 倍に設定しているのは、Lambda クラッシュ等でキーが残留してもロックが解けるようにするため。本番では ElastiCache Serverless や Upstash Redis が費用対効果が高い。

実装パターン 5: Reports API へのシフト(大量データ取得時)

一覧系エンドポイント(getOrders, getInventorySummaries など)は逐次リクエストで大量データを取得するとトークンが枯渇しやすい。Amazon はレポート生成 API(Reports API)を提供しており、バルクデータ取得はこちらに切り替えると劇的にリクエスト数が減る。

createReport → ポーリング or イベント待機 → getReport(S3 presigned URL でダウンロード)という非同期フローになるが、数千件の注文を 1 リクエスト相当のコストで取得できる。

// 1. レポート生成をリクエスト(リクエスト 1 回)
const { reportId } = await reportsApi.createReport({
  reportType: "GET_FLAT_FILE_ALL_ORDERS_DATA_BY_LAST_UPDATE_GENERAL",
  dataStartTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
  dataEndTime: new Date().toISOString(),
  marketplaceIds: [process.env.MARKETPLACE_ID!],
})
 
// 2. 完了まで Exponential Backoff でポーリング(数分かかることがある)
async function waitForReport(reportId: string): Promise<string> {
  for (let attempt = 0; attempt < 10; attempt++) {
    const { processingStatus, reportDocumentId } = await reportsApi.getReport(reportId)
    if (processingStatus === "DONE" && reportDocumentId) return reportDocumentId
    if (processingStatus === "FATAL") throw new Error(`Report failed: ${reportId}`)
    await sleep(1000 * 2 ** attempt) // 指数バックオフでポーリング
  }
  throw new Error("Report timed out")
}
 
// 3. ドキュメント取得(S3 presigned URL から一括ダウンロード)
const { url } = await reportsApi.getReportDocument(await waitForReport(reportId))
const tsvContent = await fetch(url).then((r) => r.text())
// TSV をパースして OMS に取り込む

過去 24 時間の全注文を取得するなら、getOrders のページネーションループ(数十〜数百リクエスト)が createReport + getReport の 2〜3 リクエストに圧縮できる。定期バッチやバックフィル処理では積極的に採用する価値がある。

まとめ

問題対策
429 後のリトライ集中(Thundering Herd)Exponential Backoff + Full Jitter
ページネーションループでトークン枯渇x-amzn-RateLimit-Limit ヘッダで先回りスロットリング
バースト時の大量リクエストSQS キュー + Reserved Concurrency=1 の Consumer
複数 Lambda からの食い合いRedis 共有レートリミッタ
大量データ取得の逐次リクエストReports API へのシフト

5 つのパターンは排他ではなく、組み合わせて使う。まずパターン 1(Backoff + Jitter)で安全網を張り、次にパターン 2(ヘッダ先読み)で無駄な 429 を減らす。スケールアウト局面でパターン 3・4 を加え、バッチ処理はパターン 5 に切り替える、という順序でリファクタリングすると段階的に品質を上げやすい。

ShareXB!

この記事を書いた人

渡部 誠也

執行役員 / CTO

独立系 SIer で Web・組み込み・基幹システムの開発を経験し、2017 年に illustrious へ。CTO としてシステム開発事業を立ち上げ、要件定義からコーディングまで一貫して担う。EC に特化した Web アプリケーションを数多く手がける。

ECの業務やシステムについて、
ご相談ください。

いまの運用で困っていること、実現したいことから、一緒に整理します。