目次
はじめに
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 に切り替える、という順序でリファクタリングすると段階的に品質を上げやすい。



