Repair Pricing / Price Lists
Concurrent price list bulk upserts fail the whole batch with 429
A nightly sync fans out one bulk upsert job per price list. Most of the time it works. Some nights, one or more of those jobs comes back with a 429 and none of its up to 1000 price records land, silently. BigCommerce allows only one in-flight bulk upsert job per store at a time on the price list records endpoint, no matter which price list each job targets, and a 429 from that lock drops the entire batch rather than applying it partially. Here is why that collision happens and a small script that serializes the writes and retries safely.
BigCommerce serializes writes to Price List records at the store level. The bulk upsert endpoint, PUT /v3/pricelists/{price_list_id}/records, allows only one in-flight bulk upsert job per store at a time, regardless of which price list is targeted. When multiple jobs, cron tasks, or app instances submit bulk PUT batches concurrently, the platform's price-list processing lock rejects every overlapping request with HTTP 429, and unlike a partial-batch validation error, the entire batch is dropped rather than partially applied. Fix it by acquiring a per-store lock (for example a Redis key pricelist:{store_hash}:bulk_lock) before every bulk PUT, queuing competing jobs instead of racing them, and on a 429 backing off with jitter and resubmitting the identical batch, since the upsert is idempotent on variant_id or sku plus price_list_id and currency. Full code, tests, and a dry run guard are below.
The problem in plain words
Price List records in BigCommerce are not just another catalog resource you can hammer with parallel writes. The bulk upsert endpoint processes each PUT as a job against the store's price list subsystem, and that subsystem only lets one such job run at a time, store-wide. It does not matter that job A is writing to price list 12 and job B is writing to price list 47. They still collide, because the lock is scoped to the store, not to the individual price list.
This becomes a problem the moment more than one thing can submit a bulk upsert: a nightly sync that fans out a batch per price list, a retry that fires while the original request is still being processed, or a second app instance running the same job. The second (and any later) overlapping request gets HTTP 429 back immediately. The catch is what that 429 actually means here. It is not the general API rate limit bucket telling you to slow down, and it is not a partial success where some records made it in. It is a flat rejection of the whole call, so none of the up to 1000 records in that batch are upserted. A caller that only checks for a 2xx and moves on, or that blindly retries without backing off, ends up quietly losing whole batches of price, sale_price, and map_price updates, with no partial-failure signal to notice by.
Why it happens
A few concrete situations reliably trigger this collision:
- A nightly sync that fans out one bulk upsert job per price list, submitting them in parallel to finish faster, unaware that the platform only allows one in flight per store regardless of which price list each job targets.
- A retry mechanism that resubmits a batch as soon as it sees a non-2xx response, while the original request is still being processed and still holds the lock, so the retry itself collides and gets its own 429.
- Two app instances or two scheduled tasks (for example a cron job and a manual admin trigger) both writing price updates to the same store hash at overlapping times.
- Confusing this 429 with the general API rate limit bucket. The general bucket exhaustion shows
X-Rate-Limit-Requests-Left: 0in the response headers; the price-list concurrency lock does not necessarily exhaust that bucket at all, so you can get a 429 here with plenty of general rate limit budget left.
See the citations at the end for the price list records API reference, a community thread on bulk price updates, and BigCommerce's own rate limit documentation.
This 429 is not the same signal as running out of API rate limit. It is a concurrency lock on price list processing, scoped to the whole store, not to an individual price list. Confirm which one you are looking at by checking the response headers: general rate limit exhaustion carries X-Rate-Limit-Requests-Left: 0, while a concurrent bulk-upsert collision typically does not, and instead is best confirmed by cross-referencing your own job registry or lock table to see that a second job attempted a write while another job's lock was still held. Once you know it is the concurrency lock, the fix is serialization, not just generic rate-limit backoff.
The fix, as a flow
We do not change what gets written. We add a per-store lock that every bulk upsert job must acquire before calling the records endpoint, queue anything that arrives while the lock is held, and on a 429 back off and resubmit the identical batch rather than treating it as a failure to abandon.
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 the store's existing app credentials. Grant it Price Lists (modify) scope so it can list and write price list records. 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_ATTEMPTS="6"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MAX_ATTEMPTS="6"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Price Lists REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT, returns the parsed JSON body alongside the status code and headers (we need those for the 429 case), and never raises on a 429 so the caller can decide what to do with it.
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)
r.raise_for_status()
return r.json()
def bc_put_records(price_list_id, records):
r = requests.put(
f"{API_BASE}/pricelists/{price_list_id}/records",
headers=HEADERS, json=records, timeout=60,
)
body = r.json() if r.text else {}
return r.status_code, dict(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}/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 });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPutRecords(priceListId, records) {
const res = await fetch(`${API_BASE}/pricelists/${priceListId}/records`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(records),
});
const text = await res.text();
const body = text ? JSON.parse(text) : {};
return { status: res.status, headers: Object.fromEntries(res.headers.entries()), body };
}
Enumerate price lists and hold a per-store lock
Call GET /v3/pricelists?limit=250, paginated via meta.pagination, to get every price_list_id and its active/name state. Before any job issues a bulk PUT for one of those price lists, it must acquire a per-store lock, for example SETNX pricelist:{store_hash}:bulk_lock in Redis with a TTL, or an in-process semaphore of size 1 if everything runs in one process. Competing jobs queue instead of racing, and the lock is released only after the PUT call returns something other than 429.
import threading
def all_price_lists():
page = 1
while True:
payload = bc_get("/pricelists", {"limit": 250, "page": page})
data = payload.get("data") or []
if not data:
return
for price_list in data:
yield price_list
pagination = (payload.get("meta") or {}).get("pagination") or {}
if page >= (pagination.get("total_pages") or page):
return
page += 1
# In-process serialization. Swap for a Redis SETNX lock across multiple hosts.
STORE_BULK_LOCK = threading.Lock()
async function* allPriceLists() {
let page = 1;
while (true) {
const payload = await bcGet("/pricelists", { limit: 250, page });
const data = payload.data || [];
if (!data.length) return;
for (const priceList of data) yield priceList;
const pagination = (payload.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
// In-process serialization. Swap for a Redis SETNX lock across multiple hosts.
let storeBulkLockHeld = false;
async function withStoreBulkLock(fn) {
while (storeBulkLockHeld) await new Promise((r) => setTimeout(r, 50));
storeBulkLockHeld = true;
try {
return await fn();
} finally {
storeBulkLockHeld = false;
}
}
Decide, with one pure function
Keep the retry decision in its own function that takes the response status, the attempt count, and the response headers, and returns exactly what to do next. Success statuses (200, 201, 207) mean the batch was accepted. A 429 means retry with a computed wait, honoring X-Rate-Limit-Time-Reset-Ms or Retry-After if BigCommerce sent one, otherwise capped exponential backoff. Any other 4xx gives up immediately since it will not resolve itself. A 5xx is treated as transient and retried the same as a 429, up to the attempt cap.
def decide_retry(status_code, attempt, headers, max_attempts=6):
if status_code in (200, 201, 207):
return {"action": "success"}
if status_code == 429:
if attempt >= max_attempts:
return {"action": "give_up", "reason": "max_attempts_exceeded"}
wait_ms = _compute_wait_ms(attempt, headers)
return {"action": "retry", "wait_ms": wait_ms, "reason": "concurrent_bulk_lock"}
if 500 <= status_code < 600:
if attempt >= max_attempts:
return {"action": "give_up", "reason": "server_error_max_attempts"}
wait_ms = _compute_wait_ms(attempt, headers)
return {"action": "retry", "wait_ms": wait_ms, "reason": "server_error"}
return {"action": "give_up", "reason": "client_error_non_retryable"}
def _compute_wait_ms(attempt, headers):
headers = headers or {}
reset_ms = headers.get("X-Rate-Limit-Time-Reset-Ms")
if reset_ms is not None:
try:
return int(reset_ms)
except (TypeError, ValueError):
pass
retry_after = headers.get("Retry-After")
if retry_after is not None:
try:
return int(float(retry_after) * 1000)
except (TypeError, ValueError):
pass
return min(60000, 2000 * (2 ** (attempt - 1)))
export function decideRetry(statusCode, attempt, headers, maxAttempts = 6) {
if ([200, 201, 207].includes(statusCode)) {
return { action: "success" };
}
if (statusCode === 429) {
if (attempt >= maxAttempts) {
return { action: "give_up", reason: "max_attempts_exceeded" };
}
return { action: "retry", wait_ms: computeWaitMs(attempt, headers), reason: "concurrent_bulk_lock" };
}
if (statusCode >= 500 && statusCode < 600) {
if (attempt >= maxAttempts) {
return { action: "give_up", reason: "server_error_max_attempts" };
}
return { action: "retry", wait_ms: computeWaitMs(attempt, headers), reason: "server_error" };
}
return { action: "give_up", reason: "client_error_non_retryable" };
}
function computeWaitMs(attempt, headers) {
headers = headers || {};
const resetMs = headers["X-Rate-Limit-Time-Reset-Ms"] ?? headers["x-rate-limit-time-reset-ms"];
if (resetMs !== undefined && resetMs !== null && !Number.isNaN(Number(resetMs))) {
return Number(resetMs);
}
const retryAfter = headers["Retry-After"] ?? headers["retry-after"];
if (retryAfter !== undefined && retryAfter !== null && !Number.isNaN(Number(retryAfter))) {
return Number(retryAfter) * 1000;
}
return Math.min(60000, 2000 * 2 ** (attempt - 1));
}
Submit inside the lock, retry on the identical batch
When it is a job's turn to hold the lock, it submits the batch, feeds the response into decide_retry, and either releases the lock on success, waits and resubmits the same records array on retry, or releases the lock and gives up, logging the reason. Because the endpoint upserts on variant_id or sku plus price_list_id and currency, resubmitting the identical batch never double-applies anything.
import time
def submit_batch_with_retry(price_list_id, records, max_attempts=6, dry_run=True):
attempt = 1
while True:
if dry_run:
return {"action": "success", "dry_run": True, "attempt": attempt}
status_code, headers, body = bc_put_records(price_list_id, records)
decision = decide_retry(status_code, attempt, headers, max_attempts)
if decision["action"] == "success":
return {"action": "success", "attempt": attempt, "body": body}
if decision["action"] == "give_up":
return decision
time.sleep(decision["wait_ms"] / 1000)
attempt += 1
async function submitBatchWithRetry(priceListId, records, maxAttempts = 6, dryRun = true) {
let attempt = 1;
while (true) {
if (dryRun) {
return { action: "success", dry_run: true, attempt };
}
const { status, headers, body } = await bcPutRecords(priceListId, records);
const decision = decideRetry(status, attempt, headers, maxAttempts);
if (decision.action === "success") return { action: "success", attempt, body };
if (decision.action === "give_up") return decision;
await new Promise((r) => setTimeout(r, decision.wait_ms));
attempt += 1;
}
}
Wire it together with a dry run guard
The run loop takes each pending job, acquires the store lock, submits with retry, and releases the lock. Notice the dry run guard. Under DRY_RUN=true, the job only logs the planned serialized submission order and the lock or queue wait time per job, it never acquires a real lock or issues the PUT. Read the output, agree with it, then switch it off and let it serialize your real jobs.
Always start with DRY_RUN=true, never fan out bulk upsert jobs in parallel against the same store, and never treat a 429 on this endpoint as proof the batch is gone for good without first checking whether it should simply be retried after the lock clears.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, serializes bulk upserts with a lock, retries a 429 with backoff on the identical idempotent batch, and respects the dry run flag so nothing writes until you are ready.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Serialize BigCommerce price list bulk upserts so concurrent jobs stop losing batches to 429.
BigCommerce serializes writes to Price List records at the store level. The bulk
upsert endpoint, PUT /v3/pricelists/{price_list_id}/records, allows only one
in-flight bulk upsert job per store at a time, regardless of which price list is
targeted. When multiple jobs, cron tasks, or app instances submit bulk PUT batches
concurrently, the platform's price-list processing lock rejects every overlapping
request with HTTP 429, and unlike a partial-batch validation error, the entire
batch is dropped rather than partially applied. This script acquires a per-store
lock before every bulk PUT, queues competing jobs instead of racing them, and on a
429 backs off with jitter and resubmits the identical batch, which is safe because
the endpoint upserts on variant_id or sku plus price_list_id and currency. Run one
instance per store. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/concurrent-price-list-upserts-429/
"""
import os
import time
import random
import logging
import threading
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("serialize_price_list_upserts")
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"
MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "6"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
# In-process serialization for a single-host scheduler. For multiple hosts or
# processes, swap this for a Redis lock, e.g. SETNX pricelist:{store_hash}:bulk_lock
# with a TTL, released only after the PUT call returns a non-429 status.
_STORE_BULK_LOCK = threading.Lock()
def decide_retry(status_code, attempt, headers, max_attempts=6):
"""Pure decision logic (no I/O) for handling a price-list bulk-upsert response.
status 200/201/207 -> success (batch accepted/upserted)
status 429 and attempt < max_attempts -> retry with a computed wait_ms,
reason "concurrent_bulk_lock"
status 429 and attempt >= max_attempts -> give_up, "max_attempts_exceeded"
status 4xx (not 429) -> give_up, "client_error_non_retryable"
status 5xx -> retry (transient) up to max_attempts, else give_up
"server_error_max_attempts"
"""
if status_code in (200, 201, 207):
return {"action": "success"}
if status_code == 429:
if attempt >= max_attempts:
return {"action": "give_up", "reason": "max_attempts_exceeded"}
return {
"action": "retry",
"wait_ms": _compute_wait_ms(attempt, headers),
"reason": "concurrent_bulk_lock",
}
if 500 <= status_code < 600:
if attempt >= max_attempts:
return {"action": "give_up", "reason": "server_error_max_attempts"}
return {
"action": "retry",
"wait_ms": _compute_wait_ms(attempt, headers),
"reason": "server_error",
}
return {"action": "give_up", "reason": "client_error_non_retryable"}
def _compute_wait_ms(attempt, headers):
headers = headers or {}
reset_ms = headers.get("X-Rate-Limit-Time-Reset-Ms")
if reset_ms is not None:
try:
return int(reset_ms)
except (TypeError, ValueError):
pass
retry_after = headers.get("Retry-After")
if retry_after is not None:
try:
return int(float(retry_after) * 1000)
except (TypeError, ValueError):
pass
base = min(60000, 2000 * (2 ** (attempt - 1)))
jitter = random.randint(0, 250)
return base + jitter
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put_records(price_list_id, records):
r = requests.put(
f"{API_BASE}/pricelists/{price_list_id}/records",
headers=HEADERS,
json=records,
timeout=60,
)
body = r.json() if r.text else {}
return r.status_code, dict(r.headers), body
def all_price_lists():
page = 1
while True:
payload = bc_get("/pricelists", {"limit": 250, "page": page})
data = payload.get("data") or []
if not data:
return
for price_list in data:
yield price_list
pagination = (payload.get("meta") or {}).get("pagination") or {}
if page >= (pagination.get("total_pages") or page):
return
page += 1
def submit_batch_with_retry(price_list_id, records, max_attempts=MAX_ATTEMPTS, dry_run=DRY_RUN):
attempt = 1
while True:
if dry_run:
log.info(
"DRY RUN: would submit batch price_list_id=%s records=%d attempt=%d",
price_list_id, len(records), attempt,
)
return {"action": "success", "dry_run": True, "attempt": attempt}
status_code, headers, body = bc_put_records(price_list_id, records)
decision = decide_retry(status_code, attempt, headers, max_attempts)
if decision["action"] == "success":
log.info(
"price_list_id=%s records=%d upserted on attempt %d",
price_list_id, len(records), attempt,
)
return {"action": "success", "attempt": attempt, "body": body}
if decision["action"] == "give_up":
log.error(
"price_list_id=%s gave up after attempt %d: %s",
price_list_id, attempt, decision["reason"],
)
return decision
log.warning(
"price_list_id=%s got %s on attempt %d, retrying in %dms (%s)",
price_list_id, status_code, attempt, decision["wait_ms"], decision["reason"],
)
time.sleep(decision["wait_ms"] / 1000)
attempt += 1
def run_job(price_list_id, records):
"""Acquire the per-store lock, submit with retry, release the lock."""
if DRY_RUN:
log.info(
"DRY RUN: job for price_list_id=%s queued, would wait for store lock",
price_list_id,
)
return submit_batch_with_retry(price_list_id, records)
with _STORE_BULK_LOCK:
return submit_batch_with_retry(price_list_id, records)
def run(jobs):
"""jobs: iterable of (price_list_id, records) tuples to submit, one at a time."""
results = []
for price_list_id, records in jobs:
results.append(run_job(price_list_id, records))
succeeded = sum(1 for r in results if r["action"] == "success")
log.info("Done. %d/%d job(s) succeeded.", succeeded, len(results))
return results
if __name__ == "__main__":
example_jobs = [
(price_list["id"], [])
for price_list in all_price_lists()
]
run(example_jobs)
/**
* Serialize BigCommerce price list bulk upserts so concurrent jobs stop losing batches to 429.
*
* BigCommerce serializes writes to Price List records at the store level. The bulk
* upsert endpoint, PUT /v3/pricelists/{price_list_id}/records, allows only one
* in-flight bulk upsert job per store at a time, regardless of which price list is
* targeted. When multiple jobs, cron tasks, or app instances submit bulk PUT batches
* concurrently, the platform's price-list processing lock rejects every overlapping
* request with HTTP 429, and unlike a partial-batch validation error, the entire
* batch is dropped rather than partially applied. This script acquires a per-store
* lock before every bulk PUT, queues competing jobs instead of racing them, and on a
* 429 backs off with jitter and resubmits the identical batch, which is safe because
* the endpoint upserts on variant_id or sku plus price_list_id and currency. Run one
* instance per store.
*
* Guide: https://www.allanninal.dev/bigcommerce/concurrent-price-list-upserts-429/
*/
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_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 6);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
// In-process serialization for a single-host scheduler. For multiple hosts or
// processes, swap this for a Redis lock, e.g. SETNX pricelist:{store_hash}:bulk_lock
// with a TTL, released only after the PUT call returns a non-429 status.
let storeBulkLockHeld = false;
async function withStoreBulkLock(fn) {
while (storeBulkLockHeld) await new Promise((r) => setTimeout(r, 25));
storeBulkLockHeld = true;
try {
return await fn();
} finally {
storeBulkLockHeld = false;
}
}
/**
* Pure decision logic (no I/O) for handling a price-list bulk-upsert response.
*
* status 200/201/207 -> success (batch accepted/upserted)
* status 429 and attempt < maxAttempts -> retry with a computed wait_ms,
* reason "concurrent_bulk_lock"
* status 429 and attempt >= maxAttempts -> give_up, "max_attempts_exceeded"
* status 4xx (not 429) -> give_up, "client_error_non_retryable"
* status 5xx -> retry (transient) up to maxAttempts, else give_up
* "server_error_max_attempts"
*/
export function decideRetry(statusCode, attempt, headers, maxAttempts = 6) {
if ([200, 201, 207].includes(statusCode)) {
return { action: "success" };
}
if (statusCode === 429) {
if (attempt >= maxAttempts) {
return { action: "give_up", reason: "max_attempts_exceeded" };
}
return {
action: "retry",
wait_ms: computeWaitMs(attempt, headers),
reason: "concurrent_bulk_lock",
};
}
if (statusCode >= 500 && statusCode < 600) {
if (attempt >= maxAttempts) {
return { action: "give_up", reason: "server_error_max_attempts" };
}
return {
action: "retry",
wait_ms: computeWaitMs(attempt, headers),
reason: "server_error",
};
}
return { action: "give_up", reason: "client_error_non_retryable" };
}
function computeWaitMs(attempt, headers) {
headers = headers || {};
const resetMs = headers["X-Rate-Limit-Time-Reset-Ms"] ?? headers["x-rate-limit-time-reset-ms"];
if (resetMs !== undefined && resetMs !== null && !Number.isNaN(Number(resetMs))) {
return Number(resetMs);
}
const retryAfter = headers["Retry-After"] ?? headers["retry-after"];
if (retryAfter !== undefined && retryAfter !== null && !Number.isNaN(Number(retryAfter))) {
return Number(retryAfter) * 1000;
}
const base = Math.min(60000, 2000 * 2 ** (attempt - 1));
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}
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 });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPutRecords(priceListId, records) {
const res = await fetch(`${API_BASE}/pricelists/${priceListId}/records`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(records),
});
const text = await res.text();
const body = text ? JSON.parse(text) : {};
return { status: res.status, headers: Object.fromEntries(res.headers.entries()), body };
}
async function* allPriceLists() {
let page = 1;
while (true) {
const payload = await bcGet("/pricelists", { limit: 250, page });
const data = payload.data || [];
if (!data.length) return;
for (const priceList of data) yield priceList;
const pagination = (payload.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function submitBatchWithRetry(priceListId, records, maxAttempts = MAX_ATTEMPTS, dryRun = DRY_RUN) {
let attempt = 1;
while (true) {
if (dryRun) {
console.log(
`DRY RUN: would submit batch price_list_id=${priceListId} records=${records.length} attempt=${attempt}`
);
return { action: "success", dry_run: true, attempt };
}
const { status, headers, body } = await bcPutRecords(priceListId, records);
const decision = decideRetry(status, attempt, headers, maxAttempts);
if (decision.action === "success") {
console.log(`price_list_id=${priceListId} records=${records.length} upserted on attempt ${attempt}`);
return { action: "success", attempt, body };
}
if (decision.action === "give_up") {
console.error(`price_list_id=${priceListId} gave up after attempt ${attempt}: ${decision.reason}`);
return decision;
}
console.warn(
`price_list_id=${priceListId} got ${status} on attempt ${attempt}, retrying in ${decision.wait_ms}ms (${decision.reason})`
);
await new Promise((r) => setTimeout(r, decision.wait_ms));
attempt += 1;
}
}
async function runJob(priceListId, records) {
if (DRY_RUN) {
console.log(`DRY RUN: job for price_list_id=${priceListId} queued, would wait for store lock`);
return submitBatchWithRetry(priceListId, records);
}
return withStoreBulkLock(() => submitBatchWithRetry(priceListId, records));
}
export async function run(jobs) {
const results = [];
for (const [priceListId, records] of jobs) {
results.push(await runJob(priceListId, records));
}
const succeeded = results.filter((r) => r.action === "success").length;
console.log(`Done. ${succeeded}/${results.length} job(s) succeeded.`);
return results;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
(async () => {
const jobs = [];
for await (const priceList of allPriceLists()) {
jobs.push([priceList.id, []]);
}
await run(jobs);
})().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The retry decision is the part most worth testing, because it decides whether a real batch gets resubmitted, abandoned, or wrongly treated as done. Because decide_retry takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in status codes, attempt counts, and headers, and checks the answer.
from serialize_price_list_upserts import decide_retry
def test_success_on_200():
assert decide_retry(200, 1, {}) == {"action": "success"}
def test_success_on_207_multi_status():
assert decide_retry(207, 1, {}) == {"action": "success"}
def test_429_retries_with_reset_header_ms():
result = decide_retry(429, 1, {"X-Rate-Limit-Time-Reset-Ms": "1500"}, max_attempts=6)
assert result["action"] == "retry"
assert result["wait_ms"] == 1500
assert result["reason"] == "concurrent_bulk_lock"
def test_429_retries_with_retry_after_seconds():
result = decide_retry(429, 1, {"Retry-After": "2"}, max_attempts=6)
assert result["action"] == "retry"
assert result["wait_ms"] == 2000
def test_429_falls_back_to_capped_exponential_backoff():
result = decide_retry(429, 3, {}, max_attempts=6)
assert result["action"] == "retry"
# base 2000 * 2**(attempt-1) = 8000, plus up to 250ms of jitter
assert 8000 <= result["wait_ms"] <= 8250
def test_429_gives_up_after_max_attempts():
result = decide_retry(429, 6, {}, max_attempts=6)
assert result == {"action": "give_up", "reason": "max_attempts_exceeded"}
def test_non_429_client_error_gives_up_immediately():
result = decide_retry(422, 1, {}, max_attempts=6)
assert result == {"action": "give_up", "reason": "client_error_non_retryable"}
def test_server_error_retries_then_gives_up():
retry = decide_retry(503, 1, {}, max_attempts=2)
assert retry["action"] == "retry"
give_up = decide_retry(503, 2, {}, max_attempts=2)
assert give_up == {"action": "give_up", "reason": "server_error_max_attempts"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRetry } from "./serialize-price-list-upserts.js";
test("success on 200", () => {
assert.deepEqual(decideRetry(200, 1, {}), { action: "success" });
});
test("success on 207 multi-status", () => {
assert.deepEqual(decideRetry(207, 1, {}), { action: "success" });
});
test("429 retries with reset header ms", () => {
const result = decideRetry(429, 1, { "X-Rate-Limit-Time-Reset-Ms": "1500" }, 6);
assert.equal(result.action, "retry");
assert.equal(result.wait_ms, 1500);
assert.equal(result.reason, "concurrent_bulk_lock");
});
test("429 retries with retry-after seconds", () => {
const result = decideRetry(429, 1, { "Retry-After": "2" }, 6);
assert.equal(result.action, "retry");
assert.equal(result.wait_ms, 2000);
});
test("429 falls back to capped exponential backoff", () => {
const result = decideRetry(429, 3, {}, 6);
assert.equal(result.action, "retry");
// base 2000 * 2**(attempt-1) = 8000, plus up to 250ms of jitter
assert.ok(result.wait_ms >= 8000 && result.wait_ms <= 8250);
});
test("429 gives up after max attempts", () => {
assert.deepEqual(decideRetry(429, 6, {}, 6), { action: "give_up", reason: "max_attempts_exceeded" });
});
test("non-429 client error gives up immediately", () => {
assert.deepEqual(decideRetry(422, 1, {}, 6), { action: "give_up", reason: "client_error_non_retryable" });
});
test("server error retries then gives up", () => {
const retry = decideRetry(503, 1, {}, 2);
assert.equal(retry.action, "retry");
const giveUp = decideRetry(503, 2, {}, 2);
assert.deepEqual(giveUp, { action: "give_up", reason: "server_error_max_attempts" });
});
Case studies
The store that synced every price list in parallel to save time
A merchant ran a nightly job that pulled fresh cost and margin data from an external system and pushed it into BigCommerce, one bulk upsert per price list, fired all at once to keep the sync fast. Every few nights, one or two price lists would simply not update, with no error surfaced anywhere except a 429 buried in a log no one was reading.
Switching the sync to acquire a single per-store lock before each bulk PUT, and queuing the rest, cost a few extra minutes of total run time but eliminated the silent drops entirely. Every price list updates every night now, in a predictable order.
The retry loop that collided with itself
A team had added a naive retry: on any non-2xx response, immediately resubmit. That "fix" made things worse, because the immediate resubmission fired while the original request's lock was often still held, generating a second 429 back to back, sometimes a third, before eventually giving up and logging a generic failure.
Feeding the response into decide_retry instead of retrying blindly fixed it. The wait_ms it returns, informed by X-Rate-Limit-Time-Reset-Ms when BigCommerce sends one, gives the lock time to actually clear before the resubmission, so the second attempt usually succeeds outright.
After this runs, every bulk price list upsert against a store goes through one lock, one at a time, so no job ever races another for the same store-level slot. A 429 is expected occasionally under load and handled by waiting and resubmitting the identical, safely idempotent batch, never by silently dropping price data or hammering the endpoint with an instant retry.
FAQ
Why does a bulk price list upsert fail with 429 when I have API rate limit left?
BigCommerce serializes writes to Price List records at the store level. The bulk upsert endpoint allows only one in-flight bulk upsert job per store at a time, regardless of which price list is targeted. A second overlapping PUT is rejected with 429 from the price-list processing lock, which is a different limit than the general API rate limit bucket, so you can see this 429 even when X-Rate-Limit-Requests-Left is well above zero.
When a bulk upsert batch gets a 429, did any of the records get applied?
No. Unlike a partial-batch validation error, a 429 from the concurrent-lock collision drops the entire batch. None of the up to 1000 records in that call are upserted, so you cannot assume partial progress and must resubmit the identical batch once the lock clears.
Is it safe to just resubmit the same batch after a 429?
Yes, once you back off and the lock has cleared. PUT /v3/pricelists/{id}/records is an idempotent upsert keyed on variant_id or sku plus price_list_id and currency, so resubmitting the identical batch does not create duplicates or double-apply anything. What is not safe is resubmitting immediately without a delay, or running multiple jobs in parallel against the same store, since that just recreates the collision.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Price Lists Records reference. developer.bigcommerce.com price lists records
- BigCommerce Support: bulk price update based on SKU, community question and answer. support.bigcommerce.com bulk price update based on SKU
- BigCommerce Developer Center: API rate limits. developer.bigcommerce.com API rate limits
On the solution:
- BigCommerce API Reference: Create Batch of Price Lists Records. docs.bigcommerce.com create price lists records
- BigCommerce API Reference: Price Lists. docs.bigcommerce.com price lists
- BigCommerce Resource Hub: Navigating BigCommerce's API Rate Limits Update. developer.bigcommerce.com navigating API rate limits update
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or pricing 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 nightly sync from losing batches?
If this saved you a pile of silently dropped price updates or a confusing 429 chase, 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