Rate limits, retries, and backoff

Bluestone PIM uses throttling to keep the Management API responsive and fair for all customers. Integrations should expect rate limits, handle 429 responses, and use retry logic with exponential backoff and jitter.

Use this guide when building batch jobs, scheduled syncs, AI agents, or other automation that can make many API requests in a short time.

Rate limits at a glance

PropertyValue
Standard steady rate10 requests per second
Burst allowanceUp to +10 requests per second (about 20 RPS peak for short spikes)
ScopeAll clients combined per customer organization
Response when exceededHTTP 429 Too Many Requests
Rate-limit headersNot exposed for the per-second throttle (see Response headers)
Higher limitsAvailable by contract agreement

The standard 10 requests-per-second limit is a per-request rate, not a cap on how much data you can move. Bulk endpoints change up to 100 products per request, so one request inside the rate budget can affect hundreds of records. See Reduce rate-limit pressure.

How limits are applied

The standard Management API rate limit is 10 requests per second, applied across all clients combined within a customer organization. The exact limit is set in the contract agreement with Bluestone PIM, and customers that need higher sustained throughput should contact Bluestone PIM to review the contracted limit.

Ten requests per second is the request rate, not the work rate. Because bulk endpoints mutate up to 100 products per request (for example, Add attribute values in given set of products) and cursor reads return up to 100 items per request, a single request within the 10 RPS budget can read or change hundreds of records. Effective data throughput is therefore much higher than the raw request rate suggests when integrations use the bulk, cursor, and async patterns described in Reduce rate-limit pressure.

The Management API rate limit applies to all API clients combined within a customer organization. In practical terms, requests from multiple applications, client IDs, services, or access tokens can share the same organization-level allowance. Do not assume that creating more OAuth clients or access tokens increases total throughput.

QuestionBehavior
ScopeShared across all clients within the customer organization
Client or token isolationThe organization-level allowance is shared; separate clients or access tokens do not increase total throughput
Contract variationExact limits are stipulated in the Bluestone PIM contract agreement
Endpoint variationThe contracted Management API limit is the integration planning baseline
Environment variationUse the contracted production limit as the planning baseline; coordinate load testing in the test environment with Bluestone PIM
Customer tier variationManaged through the customer contract agreement

Standard throttle response

When a client exceeds the allowed rate, Bluestone PIM returns HTTP 429 Too Many Requests.

Clients should treat 429 as a retryable response. Retry the request after a delay, and increase that delay if additional 429 responses occur.

Response headers

Bluestone PIM rate limiting is evaluated per second, with a short burst allowance above the contracted steady rate. Because the throttle window is so short, fixed-window quota headers are less useful than they are for minute, hour, or day based limits.

For that reason, Bluestone PIM does not expose Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, or X-RateLimit-Reset for the Management API per-second throttle.

There are two practical reasons for this:

  1. X-RateLimit-Reset would usually point to the next second, so it would add little value compared with retrying after a short client-side delay.
  2. X-RateLimit-Remaining can become stale immediately because the limit is shared by all clients in the same customer organization, not only the request currently being handled.

The supported integration pattern is:

  1. Know the contracted organization-level RPS limit.
  2. Apply client-side request pacing below that limit.
  3. Retry 429, transient 5xx, and network failures with exponential backoff and jitter.
  4. Reduce request volume by using bulk operations, cursor pagination, and async task status polling patterns.

Use a default backoff delay such as 1 second for the first retry, then increase the delay if the client continues to receive 429 responses.

Burst behavior

The burst limit allows short bursts above the steady rate, up to 10 additional requests per second beyond the contracted steady rate.

With the standard 10 requests-per-second rate, a client can therefore make up to 20 requests in a single second after an idle second:

Previous secondRequests available in the next second
0 requestsUp to 20 requests
9 requestsUp to 11 requests
10 requestsUp to 10 requests

Do not design integrations to rely on the burst limit for sustained throughput. Use it only as a buffer for short spikes.

Retry strategy

Use exponential backoff with jitter for 429, transient 5xx, and network errors.

Recommended defaults:

SettingRecommendation
Retryable statuses429, 500, 502, 503, 504
First retry delay1 second
Backoff multiplier2
JitterAdd random delay between 0 and 250 milliseconds, or use full jitter for high-concurrency clients
Maximum retries5 attempts for ordinary requests; fewer for user-facing calls
Maximum delayCap at 30 seconds unless your workflow can tolerate longer delays

Retry only when the operation is safe to repeat. Read requests are usually safe. For write requests, use client-side deduplication and job tracking so a retry does not apply the same change twice.

Reduce rate-limit pressure

Prefer API patterns that reduce the number of requests your integration needs to make.

PatternUse it forReference
Cursor paginationReading large product result sets without page-number driftGet products using cursor with details from given views
Bulk operationsUpdating many products or attribute values in fewer callsAdd attribute values in given set of products
Async task status pollingTracking long-running jobs without repeating the original operationGet async task status
Metadata async task statusTracking metadata async tasksGet task status

When polling async task status endpoints, start with a short delay, then back off while the task remains in progress. Avoid tight loops such as polling every 100 milliseconds.

Python example

This example retries 429, transient 5xx, and network failures with exponential backoff and jitter.

Set BLUESTONE_API_BASE and BLUESTONE_AUTH_BASE from the Environments page.

import random
import time
from typing import Any

import requests

BLUESTONE_AUTH_BASE = "https://idp.test.bluestonepim.com/op/token"
BLUESTONE_API_BASE = "https://api.test.bluestonepim.com"
CLIENT_ID = "YOUR_CLIENT_ID"
CLIENT_SECRET = "YOUR_CLIENT_SECRET"

RETRY_STATUSES = {429, 500, 502, 503, 504}
MAX_RETRIES = 5
TIMEOUT_SECONDS = 20


def get_token() -> str:
    response = requests.post(
        BLUESTONE_AUTH_BASE,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        timeout=TIMEOUT_SECONDS,
    )
    response.raise_for_status()
    return response.json()["access_token"]


def retry_delay_seconds(attempt: int) -> float:
    base_delay = 2 ** attempt
    jitter = random.uniform(0, 0.25)
    return min(base_delay + jitter, 30)


def request_api(method: str, path: str, token: str, json_body: dict[str, Any] | None = None) -> Any:
    url = f"{BLUESTONE_API_BASE}/{path.lstrip('/')}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "context-fallback": "true",
    }

    for attempt in range(MAX_RETRIES):
        try:
            response = requests.request(
                method,
                url,
                headers=headers,
                json=json_body,
                timeout=TIMEOUT_SECONDS,
            )

            if response.status_code not in RETRY_STATUSES:
                response.raise_for_status()
                return response.json() if response.content else None

            if attempt == MAX_RETRIES - 1:
                response.raise_for_status()

            time.sleep(retry_delay_seconds(attempt))
        except (requests.ConnectionError, requests.Timeout):
            if attempt == MAX_RETRIES - 1:
                raise
            time.sleep(retry_delay_seconds(attempt))

    raise RuntimeError("Request failed after retries")


token = get_token()
catalogs = request_api("GET", "/pim/catalogs", token)
print(catalogs)

Node/TypeScript example

This example uses the built-in fetch API available in current Node.js versions.

Set BLUESTONE_API_BASE and BLUESTONE_AUTH_BASE from the Environments page.

const BLUESTONE_AUTH_BASE = "https://idp.test.bluestonepim.com/op/token";
const BLUESTONE_API_BASE = "https://api.test.bluestonepim.com";
const CLIENT_ID = "YOUR_CLIENT_ID";
const CLIENT_SECRET = "YOUR_CLIENT_SECRET";

const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 5;

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function retryDelayMs(attempt: number): number {
  const baseDelay = 2 ** attempt * 1000;
  const jitter = Math.floor(Math.random() * 250);
  return Math.min(baseDelay + jitter, 30000);
}

async function getToken(): Promise<string> {
  const body = new URLSearchParams({
    grant_type: "client_credentials",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });

  const response = await fetch(BLUESTONE_AUTH_BASE, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  if (!response.ok) {
    throw new Error(`Token request failed: ${response.status}`);
  }

  const json = await response.json();
  return json.access_token;
}

async function requestApi<T>(method: string, path: string, token: string, body?: unknown): Promise<T> {
  const url = `${BLUESTONE_API_BASE}/${path.replace(/^\/+/, "")}`;

  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
    try {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
          "context-fallback": "true",
        },
        body: body === undefined ? undefined : JSON.stringify(body),
      });

      if (!RETRY_STATUSES.has(response.status)) {
        if (!response.ok) {
          throw new Error(`API request failed: ${response.status}`);
        }
        return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
      }

      if (attempt === MAX_RETRIES - 1) {
        throw new Error(`API request failed after retries: ${response.status}`);
      }

      await sleep(retryDelayMs(attempt));
    } catch (error) {
      if (!(error instanceof TypeError)) {
        throw error;
      }
      if (attempt === MAX_RETRIES - 1) {
        throw error;
      }
      await sleep(retryDelayMs(attempt));
    }
  }

  throw new Error("API request failed after retries");
}

const token = await getToken();
const catalogs = await requestApi<unknown>("GET", "/pim/catalogs", token);
console.log(catalogs);

cURL example

This shell example retries 429, transient 5xx, and network failures with exponential backoff and jitter using curl and bash.

Set BLUESTONE_API_BASE and BLUESTONE_AUTH_BASE from the Environments page.

#!/usr/bin/env bash
set -euo pipefail

BLUESTONE_AUTH_BASE="https://idp.test.bluestonepim.com/op/token"
BLUESTONE_API_BASE="https://api.test.bluestonepim.com"
CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_SECRET="YOUR_CLIENT_SECRET"

MAX_RETRIES=5

# Retryable statuses: 429, 500, 502, 503, 504
is_retryable() {
  case "$1" in
    429|500|502|503|504) return 0 ;;
    *) return 1 ;;
  esac
}

# Exponential backoff with jitter, capped at 30 seconds.
backoff_sleep() {
  local attempt="$1"
  local base=$((2 ** attempt))
  local jitter_ms=$((RANDOM % 250))
  local delay
  delay=$(awk -v b="$base" -v j="$jitter_ms" 'BEGIN { d = b + j/1000; if (d > 30) d = 30; print d }')
  sleep "$delay"
}

get_token() {
  curl -fsS -X POST "$BLUESTONE_AUTH_BASE" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=${CLIENT_ID}" \
    -d "client_secret=${CLIENT_SECRET}" \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}

# Usage: request_api GET /pim/catalogs
request_api() {
  local method="$1" path="$2"
  local url="${BLUESTONE_API_BASE}/${path#/}"
  local attempt status body

  for ((attempt = 0; attempt < MAX_RETRIES; attempt++)); do
    body=$(mktemp)
    status=$(curl -sS -o "$body" -w "%{http_code}" -X "$method" "$url" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      -H "context-fallback: true" || echo "000")

    if [[ "$status" == "000" ]] || is_retryable "$status"; then
      if (( attempt == MAX_RETRIES - 1 )); then
        echo "Request failed after retries (last status: ${status})" >&2
        cat "$body" >&2; rm -f "$body"; return 1
      fi
      rm -f "$body"
      backoff_sleep "$attempt"
      continue
    fi

    cat "$body"; rm -f "$body"
    return 0
  done
}

TOKEN="$(get_token)"
request_api GET /pim/catalogs