Diagnostic API Fundamentals

BigCommerce rate limit callback fires only once per session instead of per request

A script wires a callback into its BigCommerce client to warn it when the rate limit is close, and the callback does fire, exactly once, then never again. Meanwhile the requests keep going and the 429 Too Many Requests responses pile up. BigCommerce never sends a webhook for this. It reports live quota state on every single response through four headers, and the fix is to stop trusting a one-shot callback and start reading those headers on every request.

Python and Node.js BigCommerce REST Management API Header-driven, no webhook
A white and black sign
Photo by sarah b on Unsplash
The short answer

BigCommerce enforces a sliding request quota per store (default 150 requests per 30,000 ms window for OAuth apps) and reports the live state on every response through X-Rate-Limit-Requests-Left, X-Rate-Limit-Requests-Quota, X-Rate-Limit-Time-Window-Ms, and X-Rate-Limit-Time-Reset-Ms. There is no server-side webhook or push callback for rate limiting, it is purely header driven. Client libraries like bigcommerce-api-python wire a callback function into the client once, at construction time, and track requests remaining with their own internal counter instead of re-reading the live headers on every call, so the callback fires once and the script free runs into repeated 429s. The fix is a small helper that reads the four headers off every response and decides whether to sleep before the next call, every single time, never trusting a one-shot callback again.

The problem in plain words

BigCommerce does not push a notification when your integration is about to hit its rate limit. There is no webhook topic for it, no event you can subscribe to. Instead, every single response from the REST Management API carries four headers that describe the quota right now: how many requests are left, what the quota is, how wide the sliding window is in milliseconds, and how many milliseconds until it resets.

Some client libraries try to make this easier by accepting a callback function you register once, when you construct the client, something like callback_function: ratelimit(). The intention is that the library calls your function whenever the remaining requests drop below a threshold. In practice, a common bug is that the value gets evaluated once at construction time, and the library's internal idea of requests remaining is only updated inside its own request loop rather than re-read from the live headers on every response. The result: the callback fires a single time for the whole process lifetime, then goes quiet, while the script keeps making requests based on stale internal state instead of what BigCommerce is actually telling it. It keeps colliding with the real quota and racking up 429 Too Many Requests responses that nobody is watching for.

Client built callback registered once Callback fires once, ever headers ignored Stale counter free running script 429 Too Many Requests repeats, silently
The callback is only ever evaluated once, at construction time. BigCommerce keeps returning live rate limit headers on every response, but nothing in the client is reading them anymore.

Why it happens

The root cause sits entirely in how the client library is wired, not in anything BigCommerce does wrong. A few concrete ways this shows up:

This exact behavior has been reported against bigcommerce-api-python, where the rate limit callback fires once and then the library keeps making requests until it hits 429 responses. See the citations at the end for the GitHub issues and BigCommerce's own rate limit documentation.

The key insight

A callback registered once at construction time is not a monitor, it is a one-time trigger. The only source of truth for your quota is the response you just got back. Every call to the BigCommerce REST Management API returns X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms right now, on that response, and the only safe pattern is to read them fresh after every single request rather than trusting any cached counter or one-shot callback to still be accurate later in the run.

The fix, as a flow

We do not patch the client library's internal callback wiring. We add a small wrapper around every request that reads the four rate limit headers off the response it just received and decides, on the spot, whether the next request needs to wait.

Make API call any REST route Read headers Requests-Left, Reset-Ms should_throttle pure decision, every call 429 or at threshold? yes no, proceed Sleep Reset-Ms then next call
The decision runs fresh after every request, reading the live headers instead of any cached callback state, so a shrinking quota is always caught on the very next call.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use your app's existing OAuth credentials. Any scope that lets you call the routes you already use is enough, since this fix does not need new permissions, it only reads response headers you already receive. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_REQUESTS_REMAINING="10"
export DRY_RUN="true"   # start safe, change to false to make live calls
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_REQUESTS_REMAINING="10"
export DRY_RUN="true"   // start safe, change to false to make live calls
2

Read the four rate limit headers off every response

Every call to https://api.bigcommerce.com/stores/{store_hash}/, V2 or V3, carries X-Rate-Limit-Requests-Left, X-Rate-Limit-Requests-Quota, X-Rate-Limit-Time-Window-Ms, and X-Rate-Limit-Time-Reset-Ms on the response, whether it succeeds or comes back as a 429. A small helper wraps every GET so we always look at the live numbers instead of anything cached from an earlier call.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    rate = {
        "requests_left": int(r.headers.get("X-Rate-Limit-Requests-Left", -1)),
        "requests_quota": int(r.headers.get("X-Rate-Limit-Requests-Quota", -1)),
        "window_ms": int(r.headers.get("X-Rate-Limit-Time-Window-Ms", 0)),
        "reset_ms": int(r.headers.get("X-Rate-Limit-Time-Reset-Ms", 0)),
        "status_code": r.status_code,
    }
    return r, rate
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  const rate = {
    requestsLeft: Number(res.headers.get("X-Rate-Limit-Requests-Left") ?? -1),
    requestsQuota: Number(res.headers.get("X-Rate-Limit-Requests-Quota") ?? -1),
    windowMs: Number(res.headers.get("X-Rate-Limit-Time-Window-Ms") ?? 0),
    resetMs: Number(res.headers.get("X-Rate-Limit-Time-Reset-Ms") ?? 0),
    statusCode: res.status,
  };
  return { res, rate };
}
3

Stop trusting the client library's one-shot callback

If you are using a library like bigcommerce-api-python with a callback_function option, leave it in place if you like, but do not depend on it. Instead of registering a callback once at construction time and hoping it fires again later, call a throttle check after every single response using the numbers you just read, never a cached counter from earlier in the run.

step3.py
DEFAULT_MIN_REQUESTS_REMAINING = 10

def should_throttle(requests_left, time_reset_ms, min_requests_remaining=DEFAULT_MIN_REQUESTS_REMAINING, status_code=200):
    if status_code == 429:
        return True, max(time_reset_ms, 0)
    if requests_left is None or requests_left <= min_requests_remaining:
        return True, max(time_reset_ms, 0)
    return False, 0
step3.js
const DEFAULT_MIN_REQUESTS_REMAINING = 10;

function shouldThrottle(requestsLeft, timeResetMs, minRequestsRemaining = DEFAULT_MIN_REQUESTS_REMAINING, statusCode = 200) {
  if (statusCode === 429) return [true, Math.max(timeResetMs, 0)];
  if (requestsLeft == null || requestsLeft <= minRequestsRemaining) return [true, Math.max(timeResetMs, 0)];
  return [false, 0];
}
4

Make the decision a pure function, with a fail-safe default

Keep the actual decision in one function that takes plain values, requests left, the reset window in milliseconds, the threshold, and the status code, and returns whether to throttle and for how long. Missing or negative header values, which can happen if a proxy strips a header or a request fails before headers are parsed, should default to throttling rather than assuming it is safe to keep going.

decide.py
from typing import Optional, Tuple

def should_throttle(
    requests_left: Optional[int],
    time_reset_ms: Optional[int],
    min_requests_remaining: int = 10,
    status_code: int = 200,
) -> Tuple[bool, int]:
    """Pure decision. No network, no side effects.

    Returns (True, time_reset_ms) if status_code == 429 or requests_left is
    missing/negative/<= min_requests_remaining, meaning the caller must sleep
    time_reset_ms before its next request. Otherwise returns (False, 0).
    Missing or invalid header values fail safe toward throttling.
    """
    safe_reset_ms = time_reset_ms if isinstance(time_reset_ms, int) and time_reset_ms > 0 else 0

    if status_code == 429:
        return True, safe_reset_ms

    if requests_left is None or requests_left <= min_requests_remaining:
        return True, safe_reset_ms

    return False, 0
decide.js
/**
 * Pure decision. No network, no side effects.
 *
 * Returns [true, timeResetMs] if statusCode === 429 or requestsLeft is
 * missing/negative/<= minRequestsRemaining, meaning the caller must sleep
 * timeResetMs before its next request. Otherwise returns [false, 0].
 * Missing or invalid header values fail safe toward throttling.
 */
export function shouldThrottle(requestsLeft, timeResetMs, minRequestsRemaining = 10, statusCode = 200) {
  const safeResetMs = Number.isFinite(timeResetMs) && timeResetMs > 0 ? timeResetMs : 0;

  if (statusCode === 429) return [true, safeResetMs];

  if (requestsLeft == null || requestsLeft <= minRequestsRemaining) return [true, safeResetMs];

  return [false, 0];
}
5

Sleep on the reset window, then re-check on the very next call

When should_throttle returns true, sleep for the reported time_reset_ms before making the next request, then read the headers again on that next response. Never assume the sleep you did once is enough for the rest of the run. The check runs fresh after every single call, so a quota that keeps shrinking is always caught on the next request instead of waiting for a callback that will not fire again.

apply.py
import time

def sleep_if_needed(rate, min_requests_remaining):
    throttle, wait_ms = should_throttle(
        rate["requests_left"], rate["reset_ms"], min_requests_remaining, rate["status_code"]
    )
    if throttle and wait_ms > 0:
        time.sleep(wait_ms / 1000)
    return throttle
apply.js
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function sleepIfNeeded(rate, minRequestsRemaining) {
  const [throttle, waitMs] = shouldThrottle(rate.requestsLeft, rate.resetMs, minRequestsRemaining, rate.statusCode);
  if (throttle && waitMs > 0) await sleep(waitMs);
  return throttle;
}
6

Wire it together and log every throttle decision

The loop calls the API, reads the rate limit headers off that exact response, logs the timestamp, requests left, reset window, and whether it throttled, then makes the decision before firing the next request. On DRY_RUN=true it replays a log of historical responses and only prints the sleep durations it would have used, without making live calls, so you can confirm the logic against real traffic before it controls anything.

Run it safe

Start with DRY_RUN=true so the throttle decisions are only logged against a replayed history, never controlling live calls, until you have confirmed the sleep durations look right. Do not lower MIN_REQUESTS_REMAINING to zero, it removes your safety margin against the exact moment BigCommerce returns 429.

The full code

Here is the complete helper in one file for each language. It reads settings from the environment, reads the rate limit headers off every response instead of trusting a client library's one-shot callback, and is safe to run again and again because the throttle decision is re-evaluated fresh on every single request.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

rate_limit_guard.py
"""Read BigCommerce's live rate limit headers on every request instead of a one-shot callback.

BigCommerce enforces a sliding request quota per store (default 150 requests per
30,000 ms window for OAuth apps) and reports the live state on every response
through X-Rate-Limit-Requests-Left, X-Rate-Limit-Requests-Quota,
X-Rate-Limit-Time-Window-Ms, and X-Rate-Limit-Time-Reset-Ms. There is no
server-side webhook or push callback for rate limiting, it is purely response
header driven. Client libraries such as bigcommerce-api-python wire a
callback_function into the client once, at construction time, and their
internal "requests remaining" counter is only updated inside their own request
loop rather than re-read from the live headers on every call, so the callback
fires a single time instead of on every request that crosses the threshold.
The script then free runs on stale internal state and keeps colliding with the
real quota, hitting repeated 429 Too Many Requests responses.

This helper reads the four headers off every response and decides, fresh each
time, whether to sleep before the next call. If DRY_RUN=true it only replays a
log of historical responses and prints the computed sleep durations without
making live calls. If DRY_RUN=false it applies the throttling in the live
request loop.

Guide: https://www.allanninal.dev/bigcommerce/rate-limit-callback-fires-once/
"""
import os
import time
import logging
from typing import Optional, Tuple

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("rate_limit_guard")

STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
MIN_REQUESTS_REMAINING = int(os.environ.get("MIN_REQUESTS_REMAINING", "10"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def should_throttle(
    requests_left: Optional[int],
    time_reset_ms: Optional[int],
    min_requests_remaining: int = MIN_REQUESTS_REMAINING,
    status_code: int = 200,
) -> Tuple[bool, int]:
    """Pure decision. No network, no side effects.

    Returns (True, time_reset_ms) if status_code == 429 or requests_left is
    missing/negative/<= min_requests_remaining, meaning the caller must sleep
    time_reset_ms before its next request. Otherwise returns (False, 0).
    Missing or invalid header values fail safe toward throttling.
    """
    safe_reset_ms = time_reset_ms if isinstance(time_reset_ms, int) and time_reset_ms > 0 else 0

    if status_code == 429:
        return True, safe_reset_ms

    if requests_left is None or requests_left <= min_requests_remaining:
        return True, safe_reset_ms

    return False, 0


def _parse_rate_headers(response) -> dict:
    def _int_or_none(value):
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    return {
        "requests_left": _int_or_none(response.headers.get("X-Rate-Limit-Requests-Left")),
        "requests_quota": _int_or_none(response.headers.get("X-Rate-Limit-Requests-Quota")),
        "window_ms": _int_or_none(response.headers.get("X-Rate-Limit-Time-Window-Ms")) or 0,
        "reset_ms": _int_or_none(response.headers.get("X-Rate-Limit-Time-Reset-Ms")) or 0,
        "status_code": response.status_code,
    }


def bc_get(path, params=None):
    response = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    rate = _parse_rate_headers(response)
    return response, rate


def replay_dry_run(historical_responses):
    """Simulate throttle decisions against a replayed log, no live calls."""
    for entry in historical_responses:
        throttle, wait_ms = should_throttle(
            entry.get("requests_left"),
            entry.get("reset_ms", 0),
            MIN_REQUESTS_REMAINING,
            entry.get("status_code", 200),
        )
        log.info(
            "timestamp=%s requests_left=%s reset_ms=%s status_code=%s throttle=%s wait_ms=%s",
            entry.get("timestamp"), entry.get("requests_left"), entry.get("reset_ms"),
            entry.get("status_code", 200), throttle, wait_ms if throttle else 0,
        )


def run(paths=None):
    paths = paths or ["/catalog/products"]

    if DRY_RUN:
        log.info("DRY_RUN=true, replaying without live calls is expected; pass a historical log to replay_dry_run().")
        return

    for path in paths:
        response, rate = bc_get(path)
        throttle, wait_ms = should_throttle(
            rate["requests_left"], rate["reset_ms"], MIN_REQUESTS_REMAINING, rate["status_code"]
        )
        log.info(
            "path=%s status_code=%s requests_left=%s reset_ms=%s throttle=%s",
            path, rate["status_code"], rate["requests_left"], rate["reset_ms"], throttle,
        )
        if throttle and wait_ms > 0:
            time.sleep(wait_ms / 1000)


if __name__ == "__main__":
    run()
rate-limit-guard.js
/**
 * Read BigCommerce's live rate limit headers on every request instead of a one-shot callback.
 *
 * BigCommerce enforces a sliding request quota per store (default 150 requests per
 * 30,000 ms window for OAuth apps) and reports the live state on every response
 * through X-Rate-Limit-Requests-Left, X-Rate-Limit-Requests-Quota,
 * X-Rate-Limit-Time-Window-Ms, and X-Rate-Limit-Time-Reset-Ms. There is no
 * server-side webhook or push callback for rate limiting, it is purely response
 * header driven. Client libraries wire a callback into the client once, at
 * construction time, and their internal "requests remaining" counter is only
 * updated inside their own request loop rather than re-read from the live
 * headers on every call, so the callback fires a single time instead of on
 * every request that crosses the threshold. The script then free runs on
 * stale internal state and keeps colliding with the real quota, hitting
 * repeated 429 Too Many Requests responses.
 *
 * This helper reads the four headers off every response and decides, fresh
 * each time, whether to sleep before the next call. If DRY_RUN=true it only
 * replays a log of historical responses and prints the computed sleep
 * durations without making live calls. If DRY_RUN=false it applies the
 * throttling in the live request loop.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/rate-limit-callback-fires-once/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const MIN_REQUESTS_REMAINING = Number(process.env.MIN_REQUESTS_REMAINING || 10);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure decision. No network, no side effects.
 *
 * Returns [true, timeResetMs] if statusCode === 429 or requestsLeft is
 * missing/negative/<= minRequestsRemaining, meaning the caller must sleep
 * timeResetMs before its next request. Otherwise returns [false, 0].
 * Missing or invalid header values fail safe toward throttling.
 */
export function shouldThrottle(requestsLeft, timeResetMs, minRequestsRemaining = MIN_REQUESTS_REMAINING, statusCode = 200) {
  const safeResetMs = Number.isFinite(timeResetMs) && timeResetMs > 0 ? timeResetMs : 0;

  if (statusCode === 429) return [true, safeResetMs];

  if (requestsLeft == null || requestsLeft <= minRequestsRemaining) return [true, safeResetMs];

  return [false, 0];
}

function parseRateHeaders(res) {
  const toIntOrNull = (value) => {
    const parsed = Number.parseInt(value, 10);
    return Number.isFinite(parsed) ? parsed : null;
  };

  return {
    requestsLeft: toIntOrNull(res.headers.get("X-Rate-Limit-Requests-Left")),
    requestsQuota: toIntOrNull(res.headers.get("X-Rate-Limit-Requests-Quota")),
    windowMs: toIntOrNull(res.headers.get("X-Rate-Limit-Time-Window-Ms")) || 0,
    resetMs: toIntOrNull(res.headers.get("X-Rate-Limit-Time-Reset-Ms")) || 0,
    statusCode: res.status,
  };
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  const rate = parseRateHeaders(res);
  return { res, rate };
}

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

/** Simulate throttle decisions against a replayed log, no live calls. */
export function replayDryRun(historicalResponses) {
  for (const entry of historicalResponses) {
    const [throttle, waitMs] = shouldThrottle(
      entry.requestsLeft, entry.resetMs || 0, MIN_REQUESTS_REMAINING, entry.statusCode ?? 200
    );
    console.log(
      `timestamp=${entry.timestamp} requests_left=${entry.requestsLeft} reset_ms=${entry.resetMs} ` +
      `status_code=${entry.statusCode ?? 200} throttle=${throttle} wait_ms=${throttle ? waitMs : 0}`
    );
  }
}

export async function run(paths = ["/catalog/products"]) {
  if (DRY_RUN) {
    console.log("DRY_RUN=true, replaying without live calls is expected; pass a historical log to replayDryRun().");
    return;
  }

  for (const path of paths) {
    const { rate } = await bcGet(path);
    const [throttle, waitMs] = shouldThrottle(rate.requestsLeft, rate.resetMs, MIN_REQUESTS_REMAINING, rate.statusCode);
    console.log(
      `path=${path} status_code=${rate.statusCode} requests_left=${rate.requestsLeft} reset_ms=${rate.resetMs} throttle=${throttle}`
    );
    if (throttle && waitMs > 0) await sleep(waitMs);
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The throttle decision is the part most worth testing, because it decides whether the integration waits before its next call or keeps colliding with the quota. Because should_throttle takes only plain values and returns a plain tuple, the test needs no network and no BigCommerce store. It just feeds in numbers and checks the answer.

test_rate_limit_throttle.py
from rate_limit_guard import should_throttle


def test_no_throttle_when_requests_left_above_threshold():
    assert should_throttle(50, 30000, min_requests_remaining=10, status_code=200) == (False, 0)


def test_throttle_when_requests_left_below_threshold():
    throttle, wait_ms = should_throttle(5, 30000, min_requests_remaining=10, status_code=200)
    assert throttle is True
    assert wait_ms == 30000


def test_throttle_when_requests_left_equals_threshold():
    throttle, _ = should_throttle(10, 15000, min_requests_remaining=10, status_code=200)
    assert throttle is True


def test_throttle_on_429_even_when_requests_left_still_high():
    throttle, wait_ms = should_throttle(120, 8000, min_requests_remaining=10, status_code=429)
    assert throttle is True
    assert wait_ms == 8000


def test_throttle_with_zero_time_reset_ms_returns_zero_wait():
    throttle, wait_ms = should_throttle(2, 0, min_requests_remaining=10, status_code=200)
    assert throttle is True
    assert wait_ms == 0


def test_missing_requests_left_defaults_to_throttle():
    throttle, _ = should_throttle(None, 30000, min_requests_remaining=10, status_code=200)
    assert throttle is True


def test_negative_time_reset_ms_defaults_to_zero_wait():
    throttle, wait_ms = should_throttle(5, -100, min_requests_remaining=10, status_code=200)
    assert throttle is True
    assert wait_ms == 0
rate-limit-guard.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldThrottle } from "./rate-limit-guard.js";

test("no throttle when requests left above threshold", () => {
  assert.deepEqual(shouldThrottle(50, 30000, 10, 200), [false, 0]);
});

test("throttle when requests left below threshold", () => {
  const [throttle, waitMs] = shouldThrottle(5, 30000, 10, 200);
  assert.equal(throttle, true);
  assert.equal(waitMs, 30000);
});

test("throttle when requests left equals threshold", () => {
  const [throttle] = shouldThrottle(10, 15000, 10, 200);
  assert.equal(throttle, true);
});

test("throttle on 429 even when requests left still high", () => {
  const [throttle, waitMs] = shouldThrottle(120, 8000, 10, 429);
  assert.equal(throttle, true);
  assert.equal(waitMs, 8000);
});

test("throttle with zero time reset ms returns zero wait", () => {
  const [throttle, waitMs] = shouldThrottle(2, 0, 10, 200);
  assert.equal(throttle, true);
  assert.equal(waitMs, 0);
});

test("missing requests left defaults to throttle", () => {
  const [throttle] = shouldThrottle(null, 30000, 10, 200);
  assert.equal(throttle, true);
});

test("negative time reset ms defaults to zero wait", () => {
  const [throttle, waitMs] = shouldThrottle(5, -100, 10, 200);
  assert.equal(throttle, true);
  assert.equal(waitMs, 0);
});

Case studies

Bulk product sync

The nightly job that hit 429 a hundred times before anyone noticed

A merchant ran a nightly catalog sync against GET /v3/catalog/products and GET /v3/catalog/products/{id}/variants for a few thousand SKUs, using a client wired with a rate limit callback. The callback logged once at the very start, then nothing. The application logs quietly filled with 429 responses for hours while the sync silently retried and fell further behind.

Once the job read X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms off every response instead of relying on the callback, the sync self-paced against the real quota. The 429 count dropped to zero and the nightly run finished in a predictable, slightly longer, window instead of stalling out.

Order export integration

The support ticket that traced back to a callback that "should have fired"

A support engineer was asked to explain why an order export tool kept failing with 429 errors even though its logs showed the rate limit warning had been logged. Instrumenting the HTTP layer showed the warning had fired exactly once, at startup, while dozens of near-zero X-Rate-Limit-Requests-Left readings and repeated 429s followed it with no further log line.

The ratio of 429-or-near-zero events to callback invocations was far above one, confirming the callback-fires-once defect rather than a BigCommerce-side outage. Replacing the callback with a per-response header check closed the ticket without any change on BigCommerce's side.

What good looks like

After this runs, every single request is followed by a check against the real, current quota, never a cached counter or a callback that only ever fires once. A shrinking X-Rate-Limit-Requests-Left is caught on the very next call, the integration sleeps for exactly the reported X-Rate-Limit-Time-Reset-Ms, and 429 Too Many Requests responses stop being a recurring surprise.

FAQ

Why does my BigCommerce rate limit callback only fire once?

Client libraries such as bigcommerce-api-python wire a user-supplied callback function into the client object once, at construction time. The library's own idea of requests remaining is only updated inside its internal request loop instead of being re-read from the live response headers on every call, so the callback fires a single time instead of on every request that crosses the threshold. The script then free runs on stale internal state and keeps colliding with the real quota.

Does BigCommerce send a webhook or push callback when I am close to the rate limit?

No. Rate limiting is purely response-header driven. There is no server-side webhook or push notification for it. BigCommerce reports the live state on every single response through X-Rate-Limit-Requests-Left, X-Rate-Limit-Requests-Quota, X-Rate-Limit-Time-Window-Ms, and X-Rate-Limit-Time-Reset-Ms, and your integration is responsible for reading those headers itself on every call.

What is the actual BigCommerce rate limit for OAuth apps?

The default is a sliding quota of 150 requests per 30,000 millisecond window per store for OAuth apps. The exact numbers can vary by plan and endpoint, so do not hardcode 150, always read the live X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Window-Ms headers from the response instead of assuming a fixed constant.

Related field notes

Citations

On the problem:

  1. bigcommerce-api-python GitHub Issue: rate limit callback only firing once. github.com bigcommerce-api-python issue 116
  2. bigcommerce-api-python GitHub Issue: throttling for rate limit exceeded, 509s and 429s. github.com bigcommerce-api-python issue 12
  3. BigCommerce Help Center: 429 Too Many Requests. support.bigcommerce.com 429 too many requests

On the solution:

  1. BigCommerce Docs: Rate Limits, API Fundamentals. docs.bigcommerce.com rate limits
  2. BigCommerce Developer Center: API rate limits. developer.bigcommerce.com api rate limits
  3. BigCommerce Developer Center: API Best Practices. developer.bigcommerce.com api best practices

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this stop your 429s?

If this saved you from a pile of silent rate limit failures or helped you finally track down a callback that was not doing its job, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all BigCommerce field notes