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.
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.
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:
- The callback function reference is passed into the client constructor as a keyword argument, for example
callback_function: ratelimit()in bigcommerce-api-python, and gets evaluated once when the object is built rather than being invoked fresh on every response. - The library keeps its own internal counter of requests remaining, updated only inside its own request loop, instead of reading
X-Rate-Limit-Requests-Leftstraight offresponse.headerson every call. - Because there is no server-side webhook or push callback for rate limiting, anything that is not explicitly re-checking the headers on every request has no other way to learn the quota changed.
- The threshold check that should trigger the callback compares against the stale internal counter, which drifts further from reality with every request the script makes, so the condition that fires the callback only evaluates true once, right at the start, then never again even as the real quota keeps shrinking.
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.
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.
Build it step by step
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.
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
// 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
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.
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
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 };
}
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.
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
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];
}
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.
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
/**
* 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];
}
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.
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
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;
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- bigcommerce-api-python GitHub Issue: rate limit callback only firing once. github.com bigcommerce-api-python issue 116
- bigcommerce-api-python GitHub Issue: throttling for rate limit exceeded, 509s and 429s. github.com bigcommerce-api-python issue 12
- BigCommerce Help Center: 429 Too Many Requests. support.bigcommerce.com 429 too many requests
On the solution:
- BigCommerce Docs: Rate Limits, API Fundamentals. docs.bigcommerce.com rate limits
- BigCommerce Developer Center: API rate limits. developer.bigcommerce.com api rate limits
- 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.
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