Diagnostic API Fundamentals
429 responses sometimes omit rate limit headers under high load
A normal BigCommerce 429 comes with four headers that tell you exactly how long to wait. But when the platform itself is under high load, not just your own store's quota, the edge layer can return a bare 429 with none of those headers attached. Scripts that expect X-Rate-Limit-Time-Reset-Ms and choke on its absence end up retrying immediately in a hot loop, which makes the overload worse. Here is why the headers go missing and a small backoff helper that never depends on them.
BigCommerce's REST API normally returns 429 Too Many Requests with X-Rate-Limit-Time-Window-Ms, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota, and X-Rate-Limit-Requests-Left, so a client can compute exactly how long to back off. When the platform itself is under high load, excessive traffic across a store or a shared infrastructure tier, the edge or proxy layer can throttle a request before it ever reaches the per-token accounting logic that stamps those headers, so it comes back as a bare 429 with none of them. A script that treats the missing header as an error, or retries immediately because it found nothing to parse, worsens the very overload that caused the 429. The fix is a small pure function, compute_backoff_seconds, that uses the exact reset time when it is present and falls back to a capped exponential backoff with jitter when it is not. Full code, tests, and a dry run guard are below.
The problem in plain words
Every BigCommerce v2 and v3 endpoint enforces a per-token rate limit, and when you exceed it, the response comes back as 429 with a set of headers that tell you your time window, your quota, how many requests you have left, and how many milliseconds until the window resets. A well-behaved client reads X-Rate-Limit-Time-Reset-Ms, sleeps for that long, and tries again. That is the documented, expected path.
But that header set is added by the per-token rate-limit accounting logic, and that logic sits behind BigCommerce's edge and proxy layer. When the platform itself is under heavy load, a spike of traffic across a whole store, or pressure on a shared infrastructure tier that many stores sit on, the edge layer can throttle a request before it ever reaches the code that stamps those headers. The response is still a 429, but it arrives with none of the four rate limit headers attached. There is no quota number to read and no reset time to wait on, because the request never got far enough into the pipeline to be given one.
Client code that was only ever tested against the normal, headers-present 429 tends to fall over here. If it does int(response.headers["X-Rate-Limit-Time-Reset-Ms"]) without a fallback, it throws a KeyError or a parse error. Some scripts catch that error clumsily and just retry on the spot. Either way, the result is a hot loop of near-immediate retries hammering an endpoint that is already under load, which is the opposite of what you want to do during a platform-wide throttling event.
Why it happens
BigCommerce's own documentation and support channels describe this as expected behavior at the edge, not a bug in a specific store. A few things line up to cause it:
- The four X-Rate-Limit-* headers (Time-Window-Ms, Time-Reset-Ms, Requests-Quota, Requests-Left) are added by the per-token rate-limit accounting logic, which only runs for requests that actually reach it.
- Platform-wide high load, excessive traffic across a store or pressure on a shared infrastructure tier, is handled further upstream, at the edge or proxy layer, before a request gets anywhere near per-token accounting.
- When the edge layer throttles first, the 429 it returns never passes through the code that stamps the rate limit headers, so the response body and headers look materially different from the normal, per-store quota 429.
- This is distinct from your own app simply exceeding its per-store quota. That case is normal, expected, and always comes with headers. The header-less case only shows up when the platform itself is under stress.
Since there is no dedicated endpoint to list affected records, this is not something you query for in the Management API. It only shows up by instrumenting your own HTTP call sites and inspecting the raw response every time a 429 comes back. See the citations at the end for BigCommerce's own rate limit documentation and the support threads where this has come up.
A missing X-Rate-Limit-Time-Reset-Ms header is not an error condition, it is a signal that the throttling happened somewhere that does not know your precise reset time. The safe pattern is not "parse the header or fail." It is "use the header when it is there, and always have a capped exponential backoff with jitter ready for when it is not." That way a header-less 429 during a platform-wide load spike gets the same respectful backoff as a normal one, instead of triggering a crash or a hot loop that adds to the load.
The fix, as a flow
We do not change how BigCommerce throttles requests, because there is nothing on the platform side to configure or repair. We add a guard in the client's retry path that checks for the headers on every 429, uses the exact reset time when present, and always has a safe fallback when it is not.
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 reuse an existing app's credentials. Any scope that lets you call the endpoints you actually need is enough, since this fix lives entirely in how you handle 429 responses, not in what you request. 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 MAX_RETRIES="5"
export DRY_RUN="true" # start safe, change to false to run live calls
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MAX_RETRIES="5"
export DRY_RUN="true" // start safe, change to false to run live calls
Talk to the REST API and keep the raw headers
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with the token in the X-Auth-Token header. The important detail here is that on a 429, the helper must hand back the response headers exactly as received, not just the status code, because that is the only way to tell a normal per-store 429 apart from a header-less platform-load 429.
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}"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
"""Returns (status_code, headers, body). Never raises on 429,
so the caller can inspect headers and decide how long to wait."""
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
body = r.json() if r.text else {}
return r.status_code, r.headers, body
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}`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
// Returns { statusCode, headers, body }. Never throws on 429,
// so the caller can inspect headers and decide how long to wait.
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 text = await res.text();
const body = text ? JSON.parse(text) : {};
const headers = Object.fromEntries(res.headers.entries());
return { statusCode: res.status, headers, body };
}
Detect which kind of 429 you got
For every 429, check whether all four headers, X-Rate-Limit-Time-Window-Ms, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota, and X-Rate-Limit-Requests-Left, are present in the response. If they are, this is normal per-store quota throttling. If any are missing, this is the platform-wide high-load case, and it is worth logging the request path, store hash, and timestamp so you can see how often it happens.
RATE_LIMIT_HEADERS = [
"X-Rate-Limit-Time-Window-Ms",
"X-Rate-Limit-Time-Reset-Ms",
"X-Rate-Limit-Requests-Quota",
"X-Rate-Limit-Requests-Left",
]
def headers_present(headers):
"""headers is a case-insensitive mapping, e.g. requests.Response.headers."""
return all(h in headers for h in RATE_LIMIT_HEADERS)
def log_headerless_429(path, store_hash, timestamp):
print(f"[flag] header-less 429 path={path} store_hash={store_hash} at={timestamp}")
const RATE_LIMIT_HEADERS = [
"x-rate-limit-time-window-ms",
"x-rate-limit-time-reset-ms",
"x-rate-limit-requests-quota",
"x-rate-limit-requests-left",
];
// headers here is a plain lowercase-keyed object, e.g. from bcGet() above.
function headersPresent(headers) {
const keys = new Set(Object.keys(headers).map((k) => k.toLowerCase()));
return RATE_LIMIT_HEADERS.every((h) => keys.has(h));
}
function logHeaderlessRateLimit(path, storeHash, timestamp) {
console.log(`[flag] header-less 429 path=${path} store_hash=${storeHash} at=${timestamp}`);
}
Decide the wait, with one pure function
This is the core of the fix. compute_backoff_seconds takes the status code, the response headers, and the current attempt number, and returns exactly how long to wait, in seconds. Non-429 responses need no backoff at all. A 429 with a parseable reset header waits exactly that long. Anything else, missing header, empty value, or a value that will not parse as a number, falls back to a capped exponential backoff with jitter so repeated header-less 429s during a load spike still back off further and further instead of hammering the same endpoint.
import random
RESET_HEADER = "X-Rate-Limit-Time-Reset-Ms"
def compute_backoff_seconds(
status_code, headers, attempt,
base_seconds=1.0, max_seconds=60.0, jitter_ratio=0.2,
):
if status_code != 429:
return 0
reset_ms = None
for key, value in (headers or {}).items():
if key.lower() == RESET_HEADER.lower():
reset_ms = value
break
if reset_ms is not None:
try:
reset_ms_num = float(reset_ms)
if reset_ms_num >= 0:
return reset_ms_num / 1000.0
except (TypeError, ValueError):
pass
wait = min(base_seconds * (2 ** attempt), max_seconds)
jitter = wait * jitter_ratio
return wait + random.uniform(-jitter, jitter)
const RESET_HEADER = "x-rate-limit-time-reset-ms";
export function computeBackoffSeconds(
statusCode, headers, attempt,
baseSeconds = 1.0, maxSeconds = 60.0, jitterRatio = 0.2,
) {
if (statusCode !== 429) return 0;
let resetMs = null;
for (const [key, value] of Object.entries(headers || {})) {
if (key.toLowerCase() === RESET_HEADER) {
resetMs = value;
break;
}
}
if (resetMs !== null && resetMs !== undefined && resetMs !== "") {
const resetMsNum = Number.parseFloat(resetMs);
if (Number.isFinite(resetMsNum) && resetMsNum >= 0) {
return resetMsNum / 1000.0;
}
}
const wait = Math.min(baseSeconds * 2 ** attempt, maxSeconds);
const jitter = wait * jitterRatio;
return wait + (Math.random() * 2 - 1) * jitter;
}
Wrap real calls in the retry guard
Any function that calls a BigCommerce endpoint now checks the status code first. On a 429, it looks up whether the headers were present, logs the header-less case if that is what happened, sleeps for compute_backoff_seconds, and tries again, up to a configured max attempts. On anything else, it returns normally or raises for a real error.
import time
def get_with_backoff(path, params=None, max_retries=5):
for attempt in range(max_retries + 1):
status_code, headers, body = bc_get(path, params)
if status_code != 429:
return status_code, headers, body
if not headers_present(headers):
log_headerless_429(path, os.environ.get("BIGCOMMERCE_STORE_HASH"), time.time())
wait_seconds = compute_backoff_seconds(status_code, headers, attempt)
time.sleep(wait_seconds)
return status_code, headers, body
function sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
async function getWithBackoff(path, params = {}, maxRetries = 5) {
let last = null;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
last = await bcGet(path, params);
if (last.statusCode !== 429) return last;
if (!headersPresent(last.headers)) {
logHeaderlessRateLimit(path, process.env.BIGCOMMERCE_STORE_HASH, Date.now());
}
const waitSeconds = computeBackoffSeconds(last.statusCode, last.headers, attempt);
await sleep(waitSeconds);
}
return last;
}
Run it in dry run first
With DRY_RUN=true, the script only logs each detected header-less 429 (store hash, endpoint, timestamp) without changing behavior. This is a flag/report tool at heart, since there is nothing on BigCommerce's side to mutate. Switch to live mode once you have confirmed the flagged endpoints and can decide whether repeated platform-level throttling on a store is worth reporting to BigCommerce support.
Always start with DRY_RUN=true, and never let a retry loop fire immediately after a 429 just because the reset header was missing. A hot loop against an endpoint that is already being throttled at the platform level only adds to the load causing the throttling.
The full code
Here is the complete helper in one file for each language. It reads settings from the environment, checks every 429 for the four rate limit headers, logs the header-less case, and always waits before retrying, using the exact reset time when it has one and a capped exponential backoff with jitter when it does not.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Back off safely on BigCommerce 429s, even when rate limit headers are missing.
BigCommerce's REST API normally returns 429 Too Many Requests with four headers,
X-Rate-Limit-Time-Window-Ms, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota,
and X-Rate-Limit-Requests-Left, so a client can compute exactly how long to wait.
When the platform itself is under high load, excessive traffic across a store or a
shared infrastructure tier, the edge or proxy layer can throttle a request before it
reaches the per-token accounting logic that stamps those headers, so it returns a
bare 429 with none of them. Client code that expects the reset header and crashes or
retries immediately when it is missing makes the overload worse. This helper checks
every 429 for the four headers, uses the exact reset time when present, and falls
back to a capped exponential backoff with jitter when it is not. It also logs the
header-less occurrence (store hash, endpoint, timestamp) for monitoring. There is
nothing to write back to BigCommerce here, this is a client-side guard, not a
store-data repair.
Guide: https://www.allanninal.dev/bigcommerce/429-missing-rate-limit-headers/
"""
import os
import time
import random
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("rate_limit_backoff")
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"
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RATE_LIMIT_HEADERS = [
"X-Rate-Limit-Time-Window-Ms",
"X-Rate-Limit-Time-Reset-Ms",
"X-Rate-Limit-Requests-Quota",
"X-Rate-Limit-Requests-Left",
]
RESET_HEADER = "X-Rate-Limit-Time-Reset-Ms"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def compute_backoff_seconds(
status_code: int,
headers: dict,
attempt: int,
base_seconds: float = 1.0,
max_seconds: float = 60.0,
jitter_ratio: float = 0.2,
) -> float:
"""Pure decision. No I/O, no sleep call, no network access.
if status_code != 429: return 0, no backoff needed.
If X-Rate-Limit-Time-Reset-Ms is present (case-insensitive) and parses as a
non-negative number, return that value divided by 1000 as exact seconds to wait.
Otherwise fall back to min(base_seconds * 2**attempt, max_seconds) with
+/- jitter_ratio random jitter applied.
"""
if status_code != 429:
return 0
reset_ms = None
for key, value in (headers or {}).items():
if key.lower() == RESET_HEADER.lower():
reset_ms = value
break
if reset_ms is not None:
try:
reset_ms_num = float(reset_ms)
if reset_ms_num >= 0:
return reset_ms_num / 1000.0
except (TypeError, ValueError):
pass
wait = min(base_seconds * (2 ** attempt), max_seconds)
jitter = wait * jitter_ratio
return wait + random.uniform(-jitter, jitter)
def headers_present(headers) -> bool:
keys = {str(k).lower() for k in (headers or {}).keys()}
return all(h.lower() in keys for h in RATE_LIMIT_HEADERS)
def log_headerless_429(path, store_hash, timestamp):
log.warning(
"Header-less 429 detected. path=%s store_hash=%s at=%s",
path, store_hash, timestamp,
)
def bc_get(path, params=None):
"""Returns (status_code, headers, body). Never raises on 429."""
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
body = r.json() if r.text else {}
return r.status_code, r.headers, body
def get_with_backoff(path, params=None, max_retries=MAX_RETRIES):
status_code, headers, body = None, {}, {}
for attempt in range(max_retries + 1):
status_code, headers, body = bc_get(path, params)
if status_code != 429:
return status_code, headers, body
if not headers_present(headers):
log_headerless_429(path, STORE_HASH, time.time())
if DRY_RUN:
log.info("DRY_RUN: would back off and retry attempt=%d", attempt)
return status_code, headers, body
wait_seconds = compute_backoff_seconds(status_code, headers, attempt)
log.info("429 on %s, waiting %.2fs before retry (attempt %d)", path, wait_seconds, attempt)
if not DRY_RUN:
time.sleep(wait_seconds)
return status_code, headers, body
def run():
status_code, headers, body = get_with_backoff("/catalog/products", {"limit": 1})
log.info("Final status=%s headers_present=%s", status_code, headers_present(headers))
if __name__ == "__main__":
run()
/**
* Back off safely on BigCommerce 429s, even when rate limit headers are missing.
*
* BigCommerce's REST API normally returns 429 Too Many Requests with four headers,
* X-Rate-Limit-Time-Window-Ms, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota,
* and X-Rate-Limit-Requests-Left, so a client can compute exactly how long to wait.
* When the platform itself is under high load, excessive traffic across a store or a
* shared infrastructure tier, the edge or proxy layer can throttle a request before it
* reaches the per-token accounting logic that stamps those headers, so it returns a
* bare 429 with none of them. Client code that expects the reset header and crashes or
* retries immediately when it is missing makes the overload worse. This helper checks
* every 429 for the four headers, uses the exact reset time when present, and falls
* back to a capped exponential backoff with jitter when it is not. It also logs the
* header-less occurrence (store hash, endpoint, timestamp) for monitoring. There is
* nothing to write back to BigCommerce here, this is a client-side guard, not a
* store-data repair.
*
* Guide: https://www.allanninal.dev/bigcommerce/429-missing-rate-limit-headers/
*/
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 MAX_RETRIES = Number(process.env.MAX_RETRIES || 5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RATE_LIMIT_HEADERS = [
"x-rate-limit-time-window-ms",
"x-rate-limit-time-reset-ms",
"x-rate-limit-requests-quota",
"x-rate-limit-requests-left",
];
const RESET_HEADER = "x-rate-limit-time-reset-ms";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No I/O, no sleep call, no network access.
*
* if statusCode !== 429: return 0, no backoff needed.
* If x-rate-limit-time-reset-ms is present (case-insensitive) and parses as a
* non-negative number, return that value divided by 1000 as exact seconds to wait.
* Otherwise fall back to Math.min(baseSeconds * 2**attempt, maxSeconds) with
* +/- jitterRatio random jitter applied.
*/
export function computeBackoffSeconds(
statusCode,
headers,
attempt,
baseSeconds = 1.0,
maxSeconds = 60.0,
jitterRatio = 0.2,
) {
if (statusCode !== 429) return 0;
let resetMs = null;
for (const [key, value] of Object.entries(headers || {})) {
if (key.toLowerCase() === RESET_HEADER) {
resetMs = value;
break;
}
}
if (resetMs !== null && resetMs !== undefined && resetMs !== "") {
const resetMsNum = Number.parseFloat(resetMs);
if (Number.isFinite(resetMsNum) && resetMsNum >= 0) {
return resetMsNum / 1000.0;
}
}
const wait = Math.min(baseSeconds * 2 ** attempt, maxSeconds);
const jitter = wait * jitterRatio;
return wait + (Math.random() * 2 - 1) * jitter;
}
export function headersPresent(headers) {
const keys = new Set(Object.keys(headers || {}).map((k) => k.toLowerCase()));
return RATE_LIMIT_HEADERS.every((h) => keys.has(h));
}
function logHeaderlessRateLimit(path, storeHash, timestamp) {
console.warn(`Header-less 429 detected. path=${path} store_hash=${storeHash} at=${timestamp}`);
}
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 text = await res.text();
const body = text ? JSON.parse(text) : {};
const headers = Object.fromEntries(res.headers.entries());
return { statusCode: res.status, headers, body };
}
function sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
async function getWithBackoff(path, params = {}, maxRetries = MAX_RETRIES) {
let last = { statusCode: null, headers: {}, body: {} };
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
last = await bcGet(path, params);
if (last.statusCode !== 429) return last;
if (!headersPresent(last.headers)) {
logHeaderlessRateLimit(path, STORE_HASH, Date.now());
if (DRY_RUN) {
console.log(`DRY_RUN: would back off and retry attempt=${attempt}`);
return last;
}
}
const waitSeconds = computeBackoffSeconds(last.statusCode, last.headers, attempt);
console.log(`429 on ${path}, waiting ${waitSeconds.toFixed(2)}s before retry (attempt ${attempt})`);
if (!DRY_RUN) await sleep(waitSeconds);
}
return last;
}
export async function run() {
const { statusCode, headers } = await getWithBackoff("/catalog/products", { limit: 1 });
console.log(`Final status=${statusCode} headers_present=${headersPresent(headers)}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a retry loop backs off safely or hammers an already-overloaded endpoint. Because compute_backoff_seconds takes only plain values and returns a plain number, the test needs no network and no BigCommerce store. It just feeds in a status code, a headers object, and an attempt number, and checks the answer.
from rate_limit_backoff import compute_backoff_seconds
def test_non_429_needs_no_backoff():
assert compute_backoff_seconds(200, {}, 0) == 0
assert compute_backoff_seconds(500, {"X-Rate-Limit-Time-Reset-Ms": "2000"}, 0) == 0
def test_headers_present_returns_exact_reset_seconds():
headers = {"X-Rate-Limit-Time-Reset-Ms": "2500"}
assert compute_backoff_seconds(429, headers, 0) == 2.5
def test_headers_present_is_case_insensitive():
headers = {"x-rate-limit-time-reset-ms": "1000"}
assert compute_backoff_seconds(429, headers, 0) == 1.0
def test_headers_missing_falls_back_to_bounded_exponential_backoff():
wait = compute_backoff_seconds(429, {}, 3, base_seconds=1.0, max_seconds=60.0, jitter_ratio=0.2)
# attempt 3 -> base 8s +/- 20% jitter, well under the 60s cap
assert 6.0 <= wait <= 10.0
def test_headers_missing_backoff_is_capped():
wait = compute_backoff_seconds(429, {}, 20, base_seconds=1.0, max_seconds=60.0, jitter_ratio=0.2)
assert wait <= 60.0 * 1.2
def test_attempts_produce_monotonically_non_decreasing_backoff_up_to_cap():
# Compare the jitter-free midpoints across attempts, since jitter alone
# could make one sample noisy, but the underlying curve must not decrease.
previous = 0.0
for attempt in range(0, 8):
base_wait = min(1.0 * (2 ** attempt), 60.0)
assert base_wait >= previous
previous = base_wait
def test_unparseable_reset_header_falls_back_to_backoff():
wait = compute_backoff_seconds(429, {"X-Rate-Limit-Time-Reset-Ms": "not-a-number"}, 0)
assert wait > 0
def test_empty_reset_header_falls_back_to_backoff():
wait = compute_backoff_seconds(429, {"X-Rate-Limit-Time-Reset-Ms": ""}, 0)
assert wait > 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeBackoffSeconds } from "./rate-limit-backoff.js";
test("non-429 needs no backoff", () => {
assert.equal(computeBackoffSeconds(200, {}, 0), 0);
assert.equal(computeBackoffSeconds(500, { "X-Rate-Limit-Time-Reset-Ms": "2000" }, 0), 0);
});
test("headers present returns exact reset seconds", () => {
const headers = { "X-Rate-Limit-Time-Reset-Ms": "2500" };
assert.equal(computeBackoffSeconds(429, headers, 0), 2.5);
});
test("headers present is case insensitive", () => {
const headers = { "x-rate-limit-time-reset-ms": "1000" };
assert.equal(computeBackoffSeconds(429, headers, 0), 1.0);
});
test("headers missing falls back to bounded exponential backoff", () => {
const wait = computeBackoffSeconds(429, {}, 3, 1.0, 60.0, 0.2);
// attempt 3 -> base 8s +/- 20% jitter, well under the 60s cap
assert.ok(wait >= 6.0 && wait <= 10.0);
});
test("headers missing backoff is capped", () => {
const wait = computeBackoffSeconds(429, {}, 20, 1.0, 60.0, 0.2);
assert.ok(wait <= 60.0 * 1.2);
});
test("attempts produce monotonically non-decreasing backoff up to cap", () => {
let previous = 0;
for (let attempt = 0; attempt < 8; attempt += 1) {
const baseWait = Math.min(1.0 * 2 ** attempt, 60.0);
assert.ok(baseWait >= previous);
previous = baseWait;
}
});
test("unparseable reset header falls back to backoff", () => {
const wait = computeBackoffSeconds(429, { "X-Rate-Limit-Time-Reset-Ms": "not-a-number" }, 0);
assert.ok(wait > 0);
});
test("empty reset header falls back to backoff", () => {
const wait = computeBackoffSeconds(429, { "X-Rate-Limit-Time-Reset-Ms": "" }, 0);
assert.ok(wait > 0);
});
Case studies
The store whose sync job crashed during a flash sale
A store's inventory sync hit GET /v3/catalog/products every few minutes without incident for months. During a site-wide flash sale, traffic across the shared infrastructure tier spiked, and the sync job started getting 429s that had no X-Rate-Limit-Time-Reset-Ms header at all. The script's original retry code did int(response.headers["X-Rate-Limit-Time-Reset-Ms"]) directly, so it threw a KeyError on every one of those responses and the whole job died mid-sync.
After adding the backoff helper, the same header-less 429s now fall through to the capped exponential backoff instead of crashing. The job logs each occurrence, waits progressively longer, and finishes the sync once the flash sale traffic subsides, instead of needing someone to notice the crash and restart it by hand.
The order poller that made its own throttling worse
An order polling script retried immediately, no wait at all, whenever it could not parse a rate limit header from the response. During normal per-store throttling, this rarely mattered because it only happened occasionally. But during a period of platform-wide load, the header-less case became the common case, and the immediate retries turned into a tight loop hitting the endpoint dozens of times a second.
The fix was not a bigger retry budget, it was recognizing that a missing header is not a special error to retry past quickly, it is a sign the wait needs to be a safe estimate instead of an exact one. The same script now backs off exponentially with jitter on every header-less 429, and the request rate during load spikes dropped back to something reasonable within a couple of retries.
After this guard is in place, every 429 gets a wait before the next retry, whether the exact reset time was available or not. Normal per-store throttling waits exactly as long as BigCommerce says to. Platform-wide high-load throttling, the header-less case, waits a capped, growing, jittered amount instead of crashing or hammering the endpoint again immediately. Repeated header-less 429s on a store get logged so you can notice a pattern and report it to BigCommerce support if it keeps happening.
FAQ
Why does a BigCommerce 429 response sometimes have no rate limit headers?
BigCommerce normally stamps every 429 with X-Rate-Limit-Time-Window-Ms, X-Rate-Limit-Time-Reset-Ms, X-Rate-Limit-Requests-Quota, and X-Rate-Limit-Requests-Left because the per-token rate-limit accounting logic adds them. When the platform itself is under high load, excessive traffic across a store or the shared infrastructure tier, the edge or proxy layer can throttle the request before it ever reaches that accounting logic, so it returns a bare 429 with none of those headers attached.
Is a header-less 429 a bug in my BigCommerce store or app?
No. There is nothing to repair in your store data and no record to patch through the Management API. It is expected platform behavior under high load, and the only thing to fix is client-side retry logic so it falls back to a safe backoff instead of crashing or retrying immediately when the reset header is missing.
What should my script do when X-Rate-Limit-Time-Reset-Ms is missing?
Fall back to a capped exponential backoff with jitter, for example a 1 second base that doubles on each attempt, capped around 60 seconds, with roughly plus or minus 20 percent random jitter. Never retry immediately and never treat the missing header as a fatal error, since either of those makes the platform-wide overload worse.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: API Fundamentals, rate limit headers and 429 behavior. developer.bigcommerce.com api-rate-limits
- BigCommerce Support Community: API rate limit question thread. support.bigcommerce.com api-rate-limit
- BigCommerce Help Center: 429 Too Many Requests question thread. support.bigcommerce.com 429-too-many-requests
On the solution:
- BigCommerce Developer Center: API Rate Limits, headers, 429 behavior, and backoff guidance. developer.bigcommerce.com api-rate-limits
- BigCommerce Resource Hub: Navigating BigCommerce's API Rate Limits Update. developer.bigcommerce.com rate-limits-update
- BigCommerce Help Center: Platform Limits reference. support.bigcommerce.com platform-limits
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or rate limiting 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 a retry loop from making things worse?
If this saved you from a hot loop or a crashed sync job during a platform load spike, 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