Diagnostic Catalog / Products
BigCommerce brand update immediately before product create returns empty reply
You PUT an update to a brand, then POST a new product against it right away, and the client comes back with nothing. No status code, no JSON, just "Empty reply from server." BigCommerce's rate limit and concurrency cap can close the connection mid-response when the two calls land back to back, and the brand update or the product create may have actually gone through on BigCommerce's side even though your script never saw a usable answer. Here is why that gap opens up and a small script that reconciles the pair against real catalog state instead of guessing.
BigCommerce enforces a per-store request quota (150 to 450 requests per 30 second OAuth window, depending on plan) and a concurrency cap, normally surfaced as a structured 429 with X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms headers. But when a brand PUT /v3/catalog/brands/{id} fires immediately before a product POST /v3/catalog/products, the store's connection sometimes closes before the response finishes, and cURL-based HTTP clients report that as a generic empty reply instead of a real error. This is a confirmed, reproduced issue in BigCommerce's own PHP SDK repo. You cannot fix this from the failed response itself, because there is no response. Instead, log every (brand update, product create) pair, then reconcile: GET the brand to confirm the update applied, GET the products list filtered by name and brand_id to check whether the create actually landed, and only retry the create when it is idempotently safe to do so. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce's Management API is not unlimited. Every store on every plan has a request quota inside a rolling 30 second OAuth window, somewhere between 150 and 450 requests depending on plan, plus a cap on how many requests can be in flight at once. Cross that line and BigCommerce is supposed to hand back a 429 with the two headers that tell you exactly how much room is left and how long until the window resets.
The trouble shows up in a specific, narrow case. You send a brand update, PUT /v3/catalog/brands/{id}, and immediately after, without any pause, you send a product create, POST /v3/catalog/products, referencing that brand. Fired back to back like that, the store's connection can close before the response headers and body finish being written out. Your HTTP client never gets a structured error to parse. It gets nothing, which cURL and the SDKs built on top of it report as "Empty reply from server." No status code. No JSON body. No signal about which of the two calls, if either, actually succeeded.
This is not a hunch. BigCommerce's own engineering team reproduced and confirmed this exact failure in the bigcommerce-api-php SDK's issue tracker: firing those two calls back to back is intermittent, reordering them (create the product first, then update the brand) or adding a short delay between calls avoids it. And critically, the mutation that appeared to fail may have actually completed on BigCommerce's side. Your client just never got told.
Why it happens
A few things stack up to cause this specific failure mode:
- BigCommerce's per-store rate limit is a rolling 30 second OAuth window with a fixed request quota (150 to 450 depending on plan) and a separate concurrency cap on simultaneous in-flight requests. Firing two mutating calls back to back can trip either limit.
- When the limit is tripped in this specific window, the observed failure is not always a clean 429. The connection can close mid-response, which cURL and cURL-based HTTP clients (including many SDKs) surface as "Empty reply from server" rather than any parsable HTTP status.
- An empty reply carries no
X-Rate-Limit-Requests-LeftorX-Rate-Limit-Time-Reset-Msheaders, because there is no response to read them from. The only way to correlate the failure with rate limiting is to look at the headers on the calls immediately before and after it. - The mutation itself, brand update or product create, is not guaranteed to have failed just because the client got nothing back. BigCommerce engineering confirmed in bigcommerce-api-php issue #138 that this exact pairing is intermittent, and that reordering the calls or adding a delay avoids it, which points at a timing and load issue rather than a hard rejection of the request itself.
Because the failure is transport-level with no body, you cannot inspect the failed response to learn anything. You have to look at the actual catalog state afterward. See the citations at the end for the exact issue thread and API rate limit docs.
An empty reply is not proof of failure, and it is not proof of success either. It is proof of nothing. So the safe pattern is not "assume it failed and retry" and not "assume it succeeded and move on." It is "go check." For each (brand update, product create) pair fired within a short window, GET the brand to confirm the update actually applied, then GET the products list filtered by name and brand_id (or sku) to see whether the create landed. Only retry the create when the brand update is confirmed, the product is confirmed absent, and the rate limit has recovered or a backoff has elapsed. Anything murkier gets flagged for a human, never auto-repaired.
The fix, as a flow
We do not touch the live checkout or the catalog write path. We add a reconciler that takes the logged (brand update, product create) pairs, checks real catalog state for each, and only retries the create when every safety condition lines up.
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 Products (modify) scope so it can read and write catalog data. 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 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 DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Catalog 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 POST, reads back the X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms headers on every response, and raises on a non-2xx status so a real error never gets confused with an empty reply.
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()
rate_limit_left = int(r.headers.get("X-Rate-Limit-Requests-Left", "1"))
return r.json(), rate_limit_left
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
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}`);
const rateLimitLeft = Number(res.headers.get("X-Rate-Limit-Requests-Left") || "1");
const body = await res.json();
return { body, rateLimitLeft };
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
Confirm the brand update and check whether the product exists
For each logged pair, call GET /v3/catalog/brands/{brand_id} and compare the fields you intended to change (name, custom_url, meta_keywords, page_title) against what actually landed. Then call GET /v3/catalog/products?name={intended_name}&brand_id={brand_id} (or filter by sku= if you have one) to see whether data[] already contains the product from the failed attempt.
def confirm_brand_update(brand_id, intended_fields):
data, rate_limit_left = bc_get(f"/catalog/brands/{brand_id}")
brand = data.get("data", {})
matches = all(brand.get(field) == value for field, value in intended_fields.items())
return matches, rate_limit_left
def find_existing_product(name, brand_id):
data, rate_limit_left = bc_get("/catalog/products", {"name": name, "brand_id": brand_id})
products = data.get("data", [])
return (products[0] if products else None), rate_limit_left
async function confirmBrandUpdate(brandId, intendedFields) {
const { body, rateLimitLeft } = await bcGet(`/catalog/brands/${brandId}`);
const brand = body.data || {};
const matches = Object.entries(intendedFields).every(([field, value]) => brand[field] === value);
return { matches, rateLimitLeft };
}
async function findExistingProduct(name, brandId) {
const { body, rateLimitLeft } = await bcGet("/catalog/products", { name, brand_id: brandId });
const products = body.data || [];
return { product: products[0] || null, rateLimitLeft };
}
Decide, with one pure function
Keep the decision in its own function that takes whether the brand update was confirmed, whether the product already exists, the current rate limit budget, and the retry attempt count, and returns one of five outcomes. The rule is strict on purpose: a stale brand update never gets an auto-created product against it, and an exhausted rate limit means a wait, not an immediate hammer.
def decide_action(
brand_confirmed: bool,
product_exists: bool,
rate_limit_left: int,
attempt: int,
max_attempts: int = 5,
) -> str:
if brand_confirmed and product_exists:
return "noop_success"
if not brand_confirmed:
return "flag_manual_review"
if attempt >= max_attempts and not product_exists:
return "give_up"
if rate_limit_left <= 0:
return "wait_and_retry"
return "retry_create"
export function decideAction(brandConfirmed, productExists, rateLimitLeft, attempt, maxAttempts = 5) {
if (brandConfirmed && productExists) return "noop_success";
if (!brandConfirmed) return "flag_manual_review";
if (attempt >= maxAttempts && !productExists) return "give_up";
if (rateLimitLeft <= 0) return "wait_and_retry";
return "retry_create";
}
Retry the create only after the idempotency check
When the decision is retry_create, re-run the existence check one more time immediately before posting, since state can change between the decision and the write. Only then call POST /v3/catalog/products. If the decision is wait_and_retry, sleep with a fixed exponential backoff (1s, 2s, 4s, 8s, up to the max attempts) before looping back to step 3.
def create_product(product_payload):
return bc_post("/catalog/products", product_payload)
def backoff_seconds(attempt):
return min(2 ** attempt, 8)
async function createProduct(productPayload) {
return bcPost("/catalog/products", productPayload);
}
function backoffSeconds(attempt) {
return Math.min(2 ** attempt, 8);
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs what it would do for each pending pair: retry, wait, flag, or confirm a no-op success. Read the output, agree with it, then switch it off. Feed it the pairs your own logging already captured for calls issued within a couple seconds of each other.
Always start with DRY_RUN=true, and never let the job create a product against a brand update that failed to apply. Resubmitting a create without the existence check first can leave you with a duplicate product under the same brand.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only retries a create after confirming the product does not already exist, and only after the brand update itself is confirmed applied.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Reconcile a BigCommerce brand update fired immediately before a product create
that came back as an empty reply from server.
BigCommerce enforces a per-store request quota (150 to 450 requests per 30 second
OAuth window depending on plan) and a concurrency cap, normally surfaced as a 429
with X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms headers. When a
brand PUT to /v3/catalog/brands/{id} is fired immediately before a product POST to
/v3/catalog/products, the store's connection sometimes closes before the response
finishes, which HTTP clients surface as a generic empty reply instead of a
structured error. The underlying mutation may have actually succeeded server side
even though the client received nothing parsable. This is a confirmed, reproduced
issue in BigCommerce's own bigcommerce-api-php SDK repo (issue #138).
This job takes logged (brand_id, intended_fields, product_payload) pairs, confirms
whether the brand update actually applied, checks whether the product already
exists from the failed attempt, and only retries the create when it is safe:
brand confirmed, product confirmed absent, and rate limit budget or backoff
allows another call. Anything else (a stale brand update, or a same-named product
whose fields do not match) is flagged for manual review, never auto-repaired.
Guide: https://www.allanninal.dev/bigcommerce/brand-update-product-create-race/
"""
import os
import time
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_brand_product_race")
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"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "5"))
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()
rate_limit_left = int(r.headers.get("X-Rate-Limit-Requests-Left", "1"))
return r.json(), rate_limit_left
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def decide_action(
brand_confirmed: bool,
product_exists: bool,
rate_limit_left: int,
attempt: int,
max_attempts: int = 5,
) -> str:
"""Pure decision logic, no I/O. Returns one of:
'noop_success' - brand_confirmed and product_exists: pair actually
succeeded despite empty reply.
'retry_create' - brand_confirmed, not product_exists, rate_limit_left
> 0, attempt < max_attempts: safe to retry create.
'wait_and_retry' - rate_limit_left <= 0 and attempt < max_attempts:
back off before retrying.
'flag_manual_review' - not brand_confirmed (brand update itself never
applied): don't create against a stale brand.
'give_up' - attempt >= max_attempts and not product_exists:
surface for manual review.
"""
if brand_confirmed and product_exists:
return "noop_success"
if not brand_confirmed:
return "flag_manual_review"
if attempt >= max_attempts and not product_exists:
return "give_up"
if rate_limit_left <= 0:
return "wait_and_retry"
return "retry_create"
def confirm_brand_update(brand_id, intended_fields):
data, rate_limit_left = bc_get(f"/catalog/brands/{brand_id}")
brand = data.get("data", {})
matches = all(brand.get(field) == value for field, value in intended_fields.items())
return matches, rate_limit_left
def find_existing_product(name, brand_id):
data, rate_limit_left = bc_get("/catalog/products", {"name": name, "brand_id": brand_id})
products = data.get("data", [])
return (products[0] if products else None), rate_limit_left
def create_product(product_payload):
return bc_post("/catalog/products", product_payload)
def backoff_seconds(attempt):
return min(2 ** attempt, 8)
def reconcile_pair(brand_id, intended_fields, product_payload):
attempt = 0
while True:
brand_confirmed, rate_limit_left = confirm_brand_update(brand_id, intended_fields)
existing, rate_limit_left = find_existing_product(
product_payload.get("name"), brand_id
)
product_exists = existing is not None
decision = decide_action(brand_confirmed, product_exists, rate_limit_left, attempt, MAX_ATTEMPTS)
log.info(
"brand_id=%s attempt=%s brand_confirmed=%s product_exists=%s "
"rate_limit_left=%s decision=%s",
brand_id, attempt, brand_confirmed, product_exists, rate_limit_left, decision,
)
if decision == "noop_success":
return "noop_success"
if decision == "flag_manual_review":
log.warning("Brand %s update not confirmed. Flagging pair for manual review.", brand_id)
return "flag_manual_review"
if decision == "give_up":
log.warning("Brand %s exhausted %s attempts. Flagging for manual review.", brand_id, MAX_ATTEMPTS)
return "give_up"
if decision == "wait_and_retry":
wait_for = backoff_seconds(attempt)
log.info("Rate limit exhausted, waiting %ss before retry.", wait_for)
if not DRY_RUN:
time.sleep(wait_for)
attempt += 1
continue
# decision == "retry_create"
if DRY_RUN:
log.info("Dry run: would create product %s under brand %s.", product_payload.get("name"), brand_id)
return "retry_create"
# Re-check existence immediately before writing, state can change between checks.
existing_recheck, _ = find_existing_product(product_payload.get("name"), brand_id)
if existing_recheck is not None:
log.info("Product appeared before retry. Treating as noop_success.")
return "noop_success"
create_product(product_payload)
log.info("Created product %s under brand %s.", product_payload.get("name"), brand_id)
return "created"
def run(pairs):
"""pairs: iterable of (brand_id, intended_fields, product_payload)."""
results = []
for brand_id, intended_fields, product_payload in pairs:
results.append(reconcile_pair(brand_id, intended_fields, product_payload))
log.info("Done. %d pair(s) processed.", len(results))
return results
if __name__ == "__main__":
run([])
/**
* Reconcile a BigCommerce brand update fired immediately before a product create
* that came back as an empty reply from server.
*
* BigCommerce enforces a per-store request quota (150 to 450 requests per 30
* second OAuth window depending on plan) and a concurrency cap, normally
* surfaced as a 429 with X-Rate-Limit-Requests-Left and
* X-Rate-Limit-Time-Reset-Ms headers. When a brand PUT to
* /v3/catalog/brands/{id} is fired immediately before a product POST to
* /v3/catalog/products, the store's connection sometimes closes before the
* response finishes, which HTTP clients surface as a generic empty reply
* instead of a structured error. The underlying mutation may have actually
* succeeded server side even though the client received nothing parsable.
* This is a confirmed, reproduced issue in BigCommerce's own bigcommerce-api-php
* SDK repo (issue #138).
*
* This job takes logged {brandId, intendedFields, productPayload} pairs,
* confirms whether the brand update actually applied, checks whether the
* product already exists from the failed attempt, and only retries the create
* when it is safe: brand confirmed, product confirmed absent, and rate limit
* budget or backoff allows another call. Anything else is flagged for manual
* review, never auto-repaired.
*
* Guide: https://www.allanninal.dev/bigcommerce/brand-update-product-create-race/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 5);
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision logic, no I/O. Returns one of:
*
* "noop_success" - brandConfirmed and productExists: pair actually
* succeeded despite empty reply.
* "retry_create" - brandConfirmed, not productExists, rateLimitLeft
* > 0, attempt < maxAttempts: safe to retry create.
* "wait_and_retry" - rateLimitLeft <= 0 and attempt < maxAttempts:
* back off before retrying.
* "flag_manual_review" - not brandConfirmed (brand update itself never
* applied): don't create against a stale brand.
* "give_up" - attempt >= maxAttempts and not productExists:
* surface for manual review.
*/
export function decideAction(brandConfirmed, productExists, rateLimitLeft, attempt, maxAttempts = 5) {
if (brandConfirmed && productExists) return "noop_success";
if (!brandConfirmed) return "flag_manual_review";
if (attempt >= maxAttempts && !productExists) return "give_up";
if (rateLimitLeft <= 0) return "wait_and_retry";
return "retry_create";
}
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}`);
const rateLimitLeft = Number(res.headers.get("X-Rate-Limit-Requests-Left") || "1");
const body = await res.json();
return { body, rateLimitLeft };
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function confirmBrandUpdate(brandId, intendedFields) {
const { body, rateLimitLeft } = await bcGet(`/catalog/brands/${brandId}`);
const brand = body.data || {};
const matches = Object.entries(intendedFields).every(([field, value]) => brand[field] === value);
return { matches, rateLimitLeft };
}
async function findExistingProduct(name, brandId) {
const { body, rateLimitLeft } = await bcGet("/catalog/products", { name, brand_id: brandId });
const products = body.data || [];
return { product: products[0] || null, rateLimitLeft };
}
async function createProduct(productPayload) {
return bcPost("/catalog/products", productPayload);
}
function backoffSeconds(attempt) {
return Math.min(2 ** attempt, 8);
}
function sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
async function reconcilePair(brandId, intendedFields, productPayload) {
let attempt = 0;
for (;;) {
const { matches: brandConfirmed } = await confirmBrandUpdate(brandId, intendedFields);
const { product: existing, rateLimitLeft } = await findExistingProduct(productPayload.name, brandId);
const productExists = existing !== null;
const decision = decideAction(brandConfirmed, productExists, rateLimitLeft, attempt, MAX_ATTEMPTS);
console.log(
`brand_id=${brandId} attempt=${attempt} brand_confirmed=${brandConfirmed} ` +
`product_exists=${productExists} rate_limit_left=${rateLimitLeft} decision=${decision}`
);
if (decision === "noop_success") return "noop_success";
if (decision === "flag_manual_review") {
console.warn(`Brand ${brandId} update not confirmed. Flagging pair for manual review.`);
return "flag_manual_review";
}
if (decision === "give_up") {
console.warn(`Brand ${brandId} exhausted ${MAX_ATTEMPTS} attempts. Flagging for manual review.`);
return "give_up";
}
if (decision === "wait_and_retry") {
const waitFor = backoffSeconds(attempt);
console.log(`Rate limit exhausted, waiting ${waitFor}s before retry.`);
if (!DRY_RUN) await sleep(waitFor);
attempt += 1;
continue;
}
// decision === "retry_create"
if (DRY_RUN) {
console.log(`Dry run: would create product ${productPayload.name} under brand ${brandId}.`);
return "retry_create";
}
const { product: recheck } = await findExistingProduct(productPayload.name, brandId);
if (recheck !== null) {
console.log("Product appeared before retry. Treating as noop_success.");
return "noop_success";
}
await createProduct(productPayload);
console.log(`Created product ${productPayload.name} under brand ${brandId}.`);
return "created";
}
}
export async function run(pairs = []) {
const results = [];
for (const { brandId, intendedFields, productPayload } of pairs) {
results.push(await reconcilePair(brandId, intendedFields, productPayload));
}
console.log(`Done. ${results.length} pair(s) processed.`);
return results;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run([]).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a script auto-creates a product or leaves it for a human. Because decide_action takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in plain values and checks the answer.
from reconcile_brand_product_race import decide_action
def test_noop_success_when_both_confirmed():
assert decide_action(True, True, 50, 0) == "noop_success"
def test_retry_create_when_brand_confirmed_and_product_missing():
assert decide_action(True, False, 50, 0) == "retry_create"
def test_wait_and_retry_when_rate_limit_exhausted():
assert decide_action(True, False, 0, 1) == "wait_and_retry"
def test_flag_manual_review_when_brand_not_confirmed():
assert decide_action(False, False, 50, 0) == "flag_manual_review"
def test_flag_manual_review_even_if_product_exists_but_brand_not_confirmed():
assert decide_action(False, True, 50, 0) == "flag_manual_review"
def test_give_up_after_max_attempts_without_product():
assert decide_action(True, False, 50, 5, max_attempts=5) == "give_up"
def test_noop_success_takes_priority_over_give_up():
assert decide_action(True, True, 0, 5, max_attempts=5) == "noop_success"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideAction } from "./reconcile-brand-product-race.js";
test("noop_success when both confirmed", () => {
assert.equal(decideAction(true, true, 50, 0), "noop_success");
});
test("retry_create when brand confirmed and product missing", () => {
assert.equal(decideAction(true, false, 50, 0), "retry_create");
});
test("wait_and_retry when rate limit exhausted", () => {
assert.equal(decideAction(true, false, 0, 1), "wait_and_retry");
});
test("flag_manual_review when brand not confirmed", () => {
assert.equal(decideAction(false, false, 50, 0), "flag_manual_review");
});
test("flag_manual_review even if product exists but brand not confirmed", () => {
assert.equal(decideAction(false, true, 50, 0), "flag_manual_review");
});
test("give_up after max attempts without product", () => {
assert.equal(decideAction(true, false, 50, 5, 5), "give_up");
});
test("noop_success takes priority over give_up", () => {
assert.equal(decideAction(true, true, 0, 5, 5), "noop_success");
});
Case studies
The migration script that renamed brands right before creating each brand's first product
A catalog migration tool normalized brand names and immediately created the first product under each brand, one pair right after another, with no delay. A handful of pairs, always the same ones under load, came back with an empty reply. The import script treated that as a hard failure and stopped the whole batch, even though most of those brand updates and even some of the product creates had actually gone through.
Running the reconciler against the logged pairs afterward showed most of them were already fully applied, brand confirmed and product present, a clean noop_success. Only two pairs had a genuinely stale brand update, and those were flagged and fixed by hand instead of being blindly retried.
The retry loop that almost created two of the same product
An earlier version of an internal tool retried the product create immediately on any error, including empty replies, with no existence check first. During a burst of brand updates, one create actually succeeded server side despite the empty reply, and the naive retry created a second, duplicate product under the same brand before anyone noticed.
Adding the idempotency check, GET products by name and brand_id immediately before every retry, closed that gap. Now a retry only ever fires when the product is confirmed absent, and the case that used to create a duplicate now resolves as a clean noop_success instead.
After this runs against your logged call pairs, an empty reply from server stops being scary. Every pair either turns out to have actually succeeded, gets a safe idempotent retry once the rate limit and existence check both agree it is fine, or gets flagged for a human when the brand update itself never applied. No pair ever gets a duplicate product, and no pair ever gets silently written off as failed when it actually went through.
FAQ
Why does a brand update right before a product create return an empty reply from server?
BigCommerce enforces a per-store request quota and a concurrency cap. When a brand PUT is fired immediately before a product POST, the store's connection can close before the response headers or body finish sending, which HTTP clients like cURL surface as a generic empty reply instead of a structured 429 or 500. The underlying mutation may have actually succeeded server-side even though the client never received a parsable response.
Is it safe to just retry the product create after an empty reply?
Not blindly. First confirm the brand update actually applied with a GET on the brand, then GET the products endpoint filtered by name and brand_id (or sku) to check whether the product already exists from the failed attempt. Only POST again if it is absent, the brand update is confirmed, and the rate limit has recovered or a backoff has elapsed. Resubmitting without that check risks a duplicate product.
How do I tell a rate limit problem from a genuine outage?
Inspect the X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms headers on the calls immediately before and after the failure. Requests-Left near zero corroborates a rate-limit-adjacent cause. If the quota still had headroom and the empty reply persists, treat it as a possible outage and fall back to the fixed exponential backoff instead of retrying immediately.
Related field notes
Citations
On the problem:
- bigcommerce-api-php Issue #138: empty reply from server after updating brand followed by immediately creating product. github.com bigcommerce-api-php issue #138
- BigCommerce Support: response status 207 for creation product or update products (batch), API RESTful. support.bigcommerce.com response status 207
- BigCommerce Support: getting 500 internal server error with the empty body in the endpoint. support.bigcommerce.com 500 with empty body
On the solution:
- BigCommerce Developer Center: API rate limits, request quota, and rate limit headers. developer.bigcommerce.com API rate limits
- BigCommerce Developer Center: error handling migration guide. developer.bigcommerce.com error handling
- BigCommerce API Reference: Update a Brand. developer.bigcommerce.com update a brand
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or the catalog 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 save you from a duplicate product?
If this saved you a pile of manual cleanup or caught a race you would have otherwise missed, 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