marketplace-rate-limiting について
marketplace-rate-limitingは、VTEX API連携でレート制限や障害に対処するためのスキルです。バックオフ再試行、制限ヘッダーの監視、サーキットブレーカー、リクエストキューを扱います。
主な機能
- 01429応答にジッター付き指数バックオフを適用
- 02Retry-Afterヘッダーを読み、待機時間を守る
- 03レート制限ヘッダーを監視して事前に減速
- 04継続的な障害にサーキットブレーカーを使用
- 05リクエストキューで処理量を制御
活用シーン
- 01カタログ同期時のレート制限エラーを減らす
- 02注文処理連携のリクエスト頻度を調整
- 03価格と在庫の更新を安定して実行
- 04フルフィルメントAPI障害の波及を抑える
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ vtex/ skills --skill marketplace-rate-limitingインストール後、Claude Code で/marketplace-rate-limitingを入力して読み込まれたか確認します。
詳細
API レート制限と耐障害性
このスキルを使用する場面
カタログ同期、注文処理、価格/在庫更新、フルフィルメント業務など、VTEX API を呼び出すあらゆる統合を構築する際に、データを失ったりパフォーマンスを低下させたりすることなく、レート制限に適切に対処する必要がある場合にこのスキルを使用します。
- 指数バックオフとジッターを使った再試行ロジックを実装する
- VTEX のレート制限ヘッダー(
Retry-After、X-RateLimit-Remaining、X-RateLimit-Reset)を読み取り、それに応じて対処する - 高スループットの統合向けにサーキットブレーカーを構築する
- キューイングでリクエストのスループットを制御する
以下の場合、このスキルは使用しないでください。
- カタログ固有の同期ロジック(
marketplace-catalog-syncを参照) - 注文イベントの消費と処理(
marketplace-order-hookを参照) - 請求書と追跡情報の送信(
marketplace-fulfillmentを参照)
判断ルール
- 429 応答では、必ず指数バックオフとジッターを実装してください。計算式:
delay = min(maxDelay, baseDelay * 2^attempt) * (0.5 + random(0, 0.5))。 - 429 応答では、必ず**
Retry-Afterヘッダーを読み取ってください**。Retry-Afterの値と計算したバックオフ遅延のうち、大きい方を使用します。 - サービスが継続的に失敗する場合(例:連続して 5 回失敗した後)は、サーキットブレーカーを使用して連鎖的な障害を防ぎ、サービスの復旧時間を確保します。
- スループットを制御し、レート制限を引き起こすバーストを避けるには、リクエストキューを使用します。
- 成功した応答でも
X-RateLimit-Remainingを事前に監視し、429 に達する前に速度を落とします。 - VTEX のレート制限は API によって異なります。
- Pricing API:PUT/POST:アカウントあたり毎秒 40 リクエスト、バーストクレジット 1000。DELETE:アカウントあたり毎秒 16 リクエスト、バーストクレジット 300。
- Catalog API:エンドポイントによって異なり、公表された固定制限はありません。
- Orders API:プラットフォーム全般の制限が適用されます。VTEX は 429 の場合に 1 分間のバックオフを推奨しています。
- バーストクレジット:レート制限を超えると、超過したリクエストごとにバーストクレジットが 1 消費されます。バーストクレジットが 0 になると、リクエストは 429 でブロックされます。そのルートが使用されていない間、クレジットはルートの制限と同じレートで時間の経過とともに補充されます。
レート制限応答ヘッダー:
| ヘッダー | 説明 |
|---|---|
Retry-After | 再試行までに待機する秒数(429 応答に含まれます) |
X-RateLimit-Remaining | 現在のウィンドウ内で残っているリクエスト数 |
X-RateLimit-Reset | レート制限ウィンドウがリセットされるタイムスタンプ(秒) |
アーキテクチャ/データフロー:
Your Integration VTEX API
│ │
│── Request ──────────────────────────▶│
│◀── 200 OK ─────────────────────────│ (success)
│ │
│── Request ──────────────────────────▶│
│◀── 429 + Retry-After: 30 ──────────│ (rate limited)
│ │
│ [Wait: max(Retry-After, backoff)] │
│ [backoff = base * 2^attempt * jitter]│
│ │
│── Retry ───────────────────────────▶│
│◀── 200 OK ─────────────────────────│ (success)
厳守事項
制約:429 応答では指数バックオフを実装する
429 応答を受信した場合、統合は指数バックオフとジッターを使用して、再試行前に必ず待機しなければなりません。Retry-After ヘッダーが存在する場合は、その値を待機時間に必ず反映しなければなりません。
これが重要な理由
429 の直後に再試行すると、再び拒否され、バーストクレジットをより速く消費して、ブロックが長引く原因になります。ジッターがない場合、ウィンドウのリセット後にすべてのクライアントが同時に再試行し、再びレート制限の急増(サンダリングハード)が発生します。
検出方法
429 の直後に再試行している(遅延やバックオフがない)場合 → 停止して指数バックオフを実装してください。 Retry-After ヘッダーを読み取らずに再試行ロジックを実装している場合 → ヘッダーを考慮する必要があることを警告してください。while(true) の再試行ループ、または間隔が 5 秒未満の setInterval がある場合 → 短すぎる間隔のループについて警告してください。
正しい例
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 5,
baseDelayMs: 1000,
maxDelayMs: 60000,
};
/**
* Calculates exponential backoff delay with full jitter.
*
* Formula: min(maxDelay, baseDelay * 2^attempt) * random(0.5, 1.0)
*
* The jitter prevents thundering herd when multiple clients
* are rate-limited simultaneously.
*/
function calculateBackoffWithJitter(
attempt: number,
baseDelayMs: number,
maxDelayMs: number
): number {
const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
const boundedDelay = Math.min(maxDelayMs, exponentialDelay);
// Full jitter: random value between 50% and 100% of the bounded delay
const jitter = 0.5 + Math.random() * 0.5;
return Math.floor(boundedDelay * jitter);
}
/**
* Executes an API request with automatic retry on 429 responses.
* Respects the Retry-After header and applies exponential backoff with jitter.
*/
async function requestWithRetry<T>(
client: AxiosInstance,
config: AxiosRequestConfig,
retryConfig: RetryConfig = DEFAULT_RETRY_CONFIG
): Promise<AxiosResponse<T>> {
let lastError: AxiosError | undefined;
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
try {
return await client.request<T>(config);
} catch (error: unknown) {
if (!axios.isAxiosError(error)) {
throw error;
}
lastError = error;
const status = error.response?.status;
// Only retry on 429 (rate limited) and 503 (circuit breaker)
if (status !== 429 && status !== 503) {
throw error;
}
if (attempt === retryConfig.maxRetries) {
break; // Exhausted retries
}
// Respect Retry-After header if present (value is in seconds)
const retryAfterHeader = error.response?.headers?.["retry-after"];
const retryAfterMs = retryAfterHeader
? parseInt(retryAfterHeader, 10) * 1000
: 0;
// Use the greater of Retry-After or calculated backoff
const backoffMs = calculateBackoffWithJitter(
attempt,
retryConfig.baseDelayMs,
retryConfig.maxDelayMs
);
const delayMs = Math.max(retryAfterMs, backoffMs);
console.warn(
`Rate limited (${status}). Retry ${attempt + 1}/${retryConfig.maxRetries} ` +
`in ${delayMs}ms (Retry-After: ${retryAfterHeader ?? "none"}, ` +
`backoff: ${backoffMs}ms)`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw lastError ?? new Error("Request failed after all retries");
}
誤った例
// WRONG: Immediate retry without backoff or Retry-After respect
async function retryImmediately<T>(
client: AxiosInstance,
config: AxiosRequestConfig,
maxRetries: number = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await client.request<T>(config);
return response.data;
} catch (error: unknown) {
// Retries immediately — will hit 429 again and drain burst credits
// Does not read Retry-After header — ignores server guidance
console.log(`Retry ${i + 1}...`);
// No delay at all — thundering herd when multiple instances retry
}
}
throw new Error("Failed after retries");
}
制約:Retry-After ヘッダーを尊重する
429 レスポンスに Retry-After ヘッダーが含まれている場合、インテグレーションは指定された秒数以上待ってから再試行しなければなりません。バックオフの遅延時間には、計算されたバックオフ時間と Retry-After の値のうち、大きい方を使用してください。
重要な理由
Retry-After ヘッダーは、サーバーがリクエストを再び受け付ける時刻を明示的に指示するものです。これを無視すると、指定された時間が経過するまでリクエストが拒否され、帯域幅が無駄になるうえ、ブロック期間が長引く可能性があります。
検出方法
Retry-After ヘッダーの値を読み取ったり使用したりしていない再試行ロジックを見つけた場合は、ヘッダーを確認する必要があると警告してください。ヘッダーの値に関係なく再試行の遅延時間が常に固定されている場合も、警告してください。
正しい例
function getRetryDelayMs(error: AxiosError, attempt: number): number {
const retryAfterHeader = error.response?.headers?.["retry-after"];
// Parse Retry-After (could be seconds or HTTP-date)
let retryAfterMs = 0;
if (retryAfterHeader) {
const seconds = parseInt(retryAfterHeader, 10);
if (!isNaN(seconds)) {
retryAfterMs = seconds * 1000;
} else {
// HTTP-date format
const retryDate = new Date(retryAfterHeader).getTime();
retryAfterMs = Math.max(0, retryDate - Date.now());
}
}
// Calculate backoff with jitter
const backoffMs = calculateBackoffWithJitter(attempt, 1000, 60000);
// Use the larger value — respect server guidance
return Math.max(retryAfterMs, backoffMs);
}
誤った例
// WRONG: Fixed 1-second retry ignoring Retry-After header
async function fixedRetry<T>(
client: AxiosInstance,
config: AxiosRequestConfig
): Promise<T> {
try {
const response = await client.request<T>(config);
return response.data;
} catch {
// Always waits 1 second regardless of Retry-After header
// If Retry-After says 60 seconds, this will fail again and again
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await client.request<T>(config);
return response.data;
}
}
制約:短い間隔で再試行を繰り返さない
インテグレーションでは、再試行に while(true) ループを使用したり、VTEX API のポーリングに 5 秒未満の間隔で setInterval/setTimeout を使用したりしてはなりません。
重要な理由
短い間隔でのループは過剰なリクエストを生成し、レート制限をすぐに使い切るほか、すべてのユーザーに影響する VTEX プラットフォームのパフォーマンス低下を招き、アカウントの VTEX Admin が利用できなくなる可能性があります。VTEX は、過剰な 429 エラーによって Admin が利用できなくなる可能性があると明示的に警告しています。
検出方法
適切な遅延を設けずに while(true) または for(;;) で再試行するパターンを見つけた場合は、短い間隔でのループについて警告してください。API 呼び出しに 5000ms 未満の間隔を指定した setInterval を見つけた場合は、ポーリング頻度について警告してください。
正しい例
// Correct: Controlled polling with adequate intervals
async function pollWithBackpressure(
client: AxiosInstance,
intervalMs: number = 30000 // 30 seconds minimum
): Promise<void> {
const poll = async (): Promise<void> => {
try {
const response = await client.get("/api/orders/feed");
const events = response.data;
if (events.length > 0) {
await processEvents(events);
await commitEvents(
client,
events.map((e: { handle: string }) => e.handle)
);
}
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 429) {
const retryAfter = parseInt(
error.response.headers["retry-after"] || "60",
10
);
console.warn(`Rate limited, waiting ${retryAfter}s`);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
return;
}
console.error("Polling error:", error);
}
// Schedule next poll
setTimeout(poll, intervalMs);
};
// Start polling
await poll();
}
async function processEvents(events: unknown[]): Promise<void> {
console.log(`Processing ${events.length} events`);
}
async function commitEvents(client: AxiosInstance, handles: string[]): Promise<void> {
await client.post("/api/orders/feed", { handles });
}
誤った例
// WRONG: Tight loop with no backpressure
async function tightLoop(client: AxiosInstance): Promise<void> {
while (true) {
try {
const response = await client.get("/api/orders/feed");
await processEvents(response.data);
} catch {
// Immediate retry — no delay, burns through rate limits
continue;
}
}
}
// WRONG: setInterval with 1-second polling
setInterval(async () => {
// 1 request/second = 3600/hour — will trigger rate limits quickly
const client = createClient();
await client.get("/api/catalog_system/pvt/sku/stockkeepingunitids");
}, 1000);
function createClient(): AxiosInstance {
return axios.create({ baseURL: "https://account.vtexcommercestable.com.br" });
}
推奨されるパターン
レート制限を考慮した HTTP クライアントを作成する
HTTP クライアントを自動再試行ロジックでラップします。
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
interface RateLimitedClientConfig {
accountName: string;
appKey: string;
appToken: string;
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
function createRateLimitedClient(config: RateLimitedClientConfig): {
client: AxiosInstance;
request: <T>(requestConfig: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
} {
const client = axios.create({
baseURL: `https://${config.accountName}.vtexcommercestable.com.br`,
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": config.appKey,
"X-VTEX-API-AppToken": config.appToken,
},
timeout: 30000,
});
const retryConfig: RetryConfig = {
maxRetries: config.maxRetries ?? 5,
baseDelayMs: config.baseDelayMs ?? 1000,
maxDelayMs: config.maxDelayMs ?? 60000,
};
return {
client,
request: <T>(requestConfig: AxiosRequestConfig) =>
requestWithRetry<T>(client, requestConfig, retryConfig),
};
}
サーキットブレーカーを実装する
サービスで障害が継続的に発生した場合に、障害の連鎖を防ぎます。
enum CircuitState {
CLOSED = "CLOSED", // Normal operation — requests flow through
OPEN = "OPEN", // Service failing — requests blocked
HALF_OPEN = "HALF_OPEN", // Testing recovery — one request allowed
}
class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failureCount: number = 0;
private lastFailureTime: number = 0;
private successCount: number = 0;
constructor(
private readonly failureThreshold: number = 5,
private readonly recoveryTimeMs: number = 30000,
private readonly halfOpenSuccessThreshold: number = 3
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === CircuitState.OPEN) {
if (Date.now() - this.lastFailureTime < this.recoveryTimeMs) {
throw new Error(
`Circuit breaker is OPEN. Retry after ${this.recoveryTimeMs}ms.`
);
}
// Transition to half-open for a test request
this.state = CircuitState.HALF_OPEN;
this.successCount = 0;
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
if (this.state === CircuitState.HALF_OPEN) {
this.successCount++;
if (this.successCount >= this.halfOpenSuccessThreshold) {
this.state = CircuitState.CLOSED;
this.failureCount = 0;
console.log("Circuit breaker: CLOSED (recovered)");
}
} else {
this.failureCount = 0;
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = CircuitState.OPEN;
console.warn(
`Circuit breaker: OPEN after ${this.failureCount} failures`
);
}
}
getState(): CircuitState {
return this.state;
}
}
リクエストキューを実装する
リクエストをキューに入れてスループットを制御し、短時間の集中を避けます。
interface QueuedRequest<T> {
config: AxiosRequestConfig;
resolve: (value: AxiosResponse<T>) => void;
reject: (error: Error) => void;
}
class RequestQueue {
private queue: Array<QueuedRequest<unknown>> = [];
private processing: boolean = false;
private readonly requestsPerSecond: number;
private readonly circuitBreaker: CircuitBreaker;
constructor(
private readonly client: {
request: <T>(config: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
},
requestsPerSecond: number = 10,
circuitBreaker?: CircuitBreaker
) {
this.requestsPerSecond = requestsPerSecond;
this.circuitBreaker = circuitBreaker ?? new CircuitBreaker();
}
async enqueue<T>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return new Promise<AxiosResponse<T>>((resolve, reject) => {
this.queue.push({
config,
resolve: resolve as (value: AxiosResponse<unknown>) => void,
reject,
});
this.processQueue();
});
}
private async processQueue(): Promise<void> {
if (this.processing || this.queue.length === 0) {
return;
}
this.processing = true;
const delayBetweenRequests = 1000 / this.requestsPerSecond;
while (this.queue.length > 0) {
const request = this.queue.shift()!;
try {
const result = await this.circuitBreaker.execute(() =>
this.client.request(request.config)
);
request.resolve(result);
} catch (error) {
request.reject(error instanceof Error ? error : new Error(String(error)));
}
// Throttle between requests
if (this.queue.length > 0) {
await new Promise((resolve) =>
setTimeout(resolve, delayBetweenRequests)
);
}
}
this.processing = false;
}
getQueueLength(): number {
return this.queue.length;
}
}
レート制限ヘッダーを事前に監視する
レート制限ヘッダーを読み取り、429 に達する前に速度を落とします。
import { AxiosResponse } from "axios";
interface RateLimitInfo {
remaining: number | null;
resetAt: number | null;
retryAfter: number | null;
}
function parseRateLimitHeaders(response: AxiosResponse): RateLimitInfo {
return {
remaining: response.headers["x-ratelimit-remaining"]
? parseInt(response.headers["x-ratelimit-remaining"], 10)
: null,
resetAt: response.headers["x-ratelimit-reset"]
? parseInt(response.headers["x-ratelimit-reset"], 10) * 1000
: null,
retryAfter: response.headers["retry-after"]
? parseInt(response.headers["retry-after"], 10) * 1000
: null,
};
}
async function adaptiveRequest<T>(
client: AxiosInstance,
config: AxiosRequestConfig,
queue: RequestQueue
): Promise<AxiosResponse<T>> {
const response = await queue.enqueue<T>(config);
const rateInfo = parseRateLimitHeaders(response);
// Proactively slow down when remaining requests are low
if (rateInfo.remaining !== null && rateInfo.remaining < 10) {
console.warn(
`Rate limit approaching: ${rateInfo.remaining} requests remaining. ` +
`Slowing down.`
);
// Add extra delay to reduce pressure
await new Promise((resolve) => setTimeout(resolve, 2000));
}
return response;
}
完全な例
import axios from "axios";
async function buildResilientIntegration(): Promise<void> {
const { client, request } = createRateLimitedClient({
accountName: process.env.VTEX_ACCOUNT_NAME!,
appKey: process.env.VTEX_APP_KEY!,
appToken: process.env.VTEX_APP_TOKEN!,
maxRetries: 5,
baseDelayMs: 1000,
maxDelayMs: 60000,
});
const circuitBreaker = new CircuitBreaker(
5, // Open after 5 failures
30000, // Wait 30s before testing recovery
3 // Close after 3 successful half-open requests
);
const queue = new RequestQueue({ request }, 10, circuitBreaker);
// Example: Batch update prices with rate limiting
const skuIds = ["sku-1", "sku-2", "sku-3", "sku-4", "sku-5"];
for (const skuId of skuIds) {
try {
const response = await queue.enqueue({
method: "POST",
url: `/notificator/seller01/changenotification/${skuId}/price`,
});
const rateInfo = parseRateLimitHeaders(response);
if (rateInfo.remaining !== null && rateInfo.remaining < 5) {
console.warn("Approaching rate limit, adding delay");
await new Promise((resolve) => setTimeout(resolve, 5000));
}
} catch (error) {
if (error instanceof Error && error.message.includes("Circuit breaker is OPEN")) {
console.error("Circuit breaker open — pausing all requests");
await new Promise((resolve) => setTimeout(resolve, 30000));
} else {
console.error(`Failed to update price for ${skuId}:`, error);
}
}
}
}
よくある失敗パターン
- ジッターのない固定再試行遅延。 指数バックオフとジッターを使用せず、常に 5 秒などの固定遅延を使用すると、「サンダリングハード」問題が発生します。レート制限を受けたすべてのクライアントが同時に再試行し、新たなリクエスト集中を引き起こして、再びレート制限されます。指数バックオフとランダムなジッターを使用し、再試行のタイミングを分散してください。
// Correct: Exponential backoff with jitter
function getRetryDelay(attempt: number): number {
const baseDelay = 1000;
const maxDelay = 60000;
const exponential = baseDelay * Math.pow(2, attempt);
const bounded = Math.min(maxDelay, exponential);
const jitter = 0.5 + Math.random() * 0.5;
return Math.floor(bounded * jitter);
}
// attempt 0: ~500-1000ms
// attempt 1: ~1000-2000ms
// attempt 2: ~2000-4000ms
// attempt 3: ~4000-8000ms
// attempt 4: ~8000-16000ms
- 事前のレート管理がない。 レート制限ヘッダーを監視して事前に速度を落とすのではなく、レート制限を受けた後に 429 エラーへ対処するだけの状態です。429 を受け取った時点で、すでにバーストクレジットを失っています。成功したレスポンスの
X-RateLimit-Remainingを監視し、残りのクォータが少なくなったらリクエスト頻度を下げてください。
// Correct: Proactive rate management
async function proactiveRateManagement(
client: AxiosInstance,
requests: AxiosRequestConfig[]
): Promise<void> {
let delayBetweenRequests = 100; // Start at 100ms between requests
for (const config of requests) {
const response = await requestWithRetry(client, config);
const rateInfo = parseRateLimitHeaders(response);
// Proactively adjust speed based on remaining quota
if (rateInfo.remaining !== null) {
if (rateInfo.remaining < 5) {
delayBetweenRequests = 5000; // Slow down significantly
} else if (rateInfo.remaining < 20) {
delayBetweenRequests = 1000; // Moderate slowdown
} else {
delayBetweenRequests = 100; // Normal speed
}
}
await new Promise((resolve) => setTimeout(resolve, delayBetweenRequests));
}
}
レビューチェックリスト
- 429 レスポンスに対して、ジッター付きの指数バックオフが実装されていますか?
- 429 レスポンスで
Retry-Afterヘッダーを読み取り、その値を尊重していますか? - 短い間隔で繰り返す再試行ループ(
while(true)、5 秒未満のsetInterval)はありませんか? - 継続的に障害が発生するサービスにサーキットブレーカーを導入していますか?
- 制限に達する前に速度を落とせるよう、
X-RateLimit-Remainingヘッダーを事前に監視していますか? - 正しい数値のしきい値を使用していますか(maxRetries: 5、baseDelayMs: 1000、maxDelayMs: 60000)?
- Pricing API の制限(PUT/POST は 40 req/s、DELETE は 16 req/s、バーストクレジット)を守っていますか?
参考資料
- レート制限エラーを回避するためのベストプラクティス — レート制限の管理とベストプラクティスに関する VTEX の公式ガイド
- エラーと例外の処理 — 429 や 5xx のレスポンスを含む、エラー処理に関する VTEX のガイド
- API レスポンスステータスコード — VTEX API のレスポンスコードとその意味の完全な一覧
- Pricing API の概要 - レート制限 — バーストクレジットを含む、Pricing API 固有のレート制限に関するドキュメント
- Feed v3 - ベストプラクティス — 注文フィード連携におけるレート制限の推奨事項
- ストアの負荷テスト方法 — レート制限の動作、429 レスポンス、サーキットブレーカーに関する VTEX のドキュメント