← back to Designer Wallcoverings
harden roll-builder gqlR + longtail refund-on-abort net (close phantom-drain path)
57e17efc4912308b542bcc31aae349cec8a44fc5 · 2026-06-25 07:50:58 -0700 · Steve Abrams
Root cause (2026-06-25): bucketB-longtail crashed mid-run with an uncaught
TypeError in the shared gqlR() helper ((r.errors||[]).some throws when the
underlying request rejects with a raw string body / returns a non-array errors
shape). A crash between an up-front take('roll',N) and the tail refund would
strand the grant = a phantom debit on the shared variant ledger, violating the
standing refund-on-abort invariant (memory reference_dw_variant_budget_refund_rule).
- gqlR() hardened in all 13 bucketB-*-roll-variants.js: catches a rejected gql()
(retry, then degrade to a safe {errors:[...]} shape callers already tolerate);
_isThrottled() tolerates any .errors shape (array | object | string).
- bucketB-longtail: module-scope _outstandingRollGrant tracker + IIFE .catch
abort-net refunds only the still-unused remainder on any uncaught throw
(decremented per real create; cleared on the clean tail + PG-abort paths so it
never double-refunds). Arithmetic verified.
NOTE: today's roll:400 ledger reconciles EXACTLY to 400 real Shopify creations
(annafrench 60 + grahambrown 260 + longtail 80) — there was NO phantom debit;
this closes the latent crash path before it can cause one.
Files touched
M shopify/scripts/bucketB-annafrench-roll-variants.jsM shopify/scripts/bucketB-coordonne-roll-variants.jsM shopify/scripts/bucketB-grahambrown-roll-variants.jsM shopify/scripts/bucketB-harlequin-roll-variants.jsM shopify/scripts/bucketB-hollywood-roll-variants.jsM shopify/scripts/bucketB-longtail-roll-variants.jsM shopify/scripts/bucketB-mindthegap-roll-variants.jsM shopify/scripts/bucketB-nina-roll-variants.jsM shopify/scripts/bucketB-phillipromano-roll-variants.jsM shopify/scripts/bucketB-rebelwalls-roll-variants.jsM shopify/scripts/bucketB-scalamandre-roll-variants.jsM shopify/scripts/bucketB-schumacher-roll-variants.jsM shopify/scripts/bucketB-thibaut-roll-variants.js
Diff
commit 57e17efc4912308b542bcc31aae349cec8a44fc5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 25 07:50:58 2026 -0700
harden roll-builder gqlR + longtail refund-on-abort net (close phantom-drain path)
Root cause (2026-06-25): bucketB-longtail crashed mid-run with an uncaught
TypeError in the shared gqlR() helper ((r.errors||[]).some throws when the
underlying request rejects with a raw string body / returns a non-array errors
shape). A crash between an up-front take('roll',N) and the tail refund would
strand the grant = a phantom debit on the shared variant ledger, violating the
standing refund-on-abort invariant (memory reference_dw_variant_budget_refund_rule).
- gqlR() hardened in all 13 bucketB-*-roll-variants.js: catches a rejected gql()
(retry, then degrade to a safe {errors:[...]} shape callers already tolerate);
_isThrottled() tolerates any .errors shape (array | object | string).
- bucketB-longtail: module-scope _outstandingRollGrant tracker + IIFE .catch
abort-net refunds only the still-unused remainder on any uncaught throw
(decremented per real create; cleared on the clean tail + PG-abort paths so it
never double-refunds). Arithmetic verified.
NOTE: today's roll:400 ledger reconciles EXACTLY to 400 real Shopify creations
(annafrench 60 + grahambrown 260 + longtail 80) — there was NO phantom debit;
this closes the latent crash path before it can cause one.
---
.../scripts/bucketB-annafrench-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-coordonne-roll-variants.js | 26 ++++++++--
.../scripts/bucketB-grahambrown-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-harlequin-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-hollywood-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-longtail-roll-variants.js | 55 ++++++++++++++++++++--
.../scripts/bucketB-mindthegap-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-nina-roll-variants.js | 26 ++++++++--
.../scripts/bucketB-phillipromano-roll-variants.js | 26 ++++++++--
.../scripts/bucketB-rebelwalls-roll-variants.js | 26 ++++++++--
.../scripts/bucketB-scalamandre-roll-variants.js | 26 ++++++++--
.../scripts/bucketB-schumacher-roll-variants.js | 26 ++++++++--
shopify/scripts/bucketB-thibaut-roll-variants.js | 26 ++++++++--
13 files changed, 314 insertions(+), 53 deletions(-)
diff --git a/shopify/scripts/bucketB-annafrench-roll-variants.js b/shopify/scripts/bucketB-annafrench-roll-variants.js
index ac3e04e4..c67946b4 100644
--- a/shopify/scripts/bucketB-annafrench-roll-variants.js
+++ b/shopify/scripts/bucketB-annafrench-roll-variants.js
@@ -133,15 +133,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-coordonne-roll-variants.js b/shopify/scripts/bucketB-coordonne-roll-variants.js
index d5bad4c8..4bf42a87 100644
--- a/shopify/scripts/bucketB-coordonne-roll-variants.js
+++ b/shopify/scripts/bucketB-coordonne-roll-variants.js
@@ -121,15 +121,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-grahambrown-roll-variants.js b/shopify/scripts/bucketB-grahambrown-roll-variants.js
index 0d1777b9..2841fc22 100644
--- a/shopify/scripts/bucketB-grahambrown-roll-variants.js
+++ b/shopify/scripts/bucketB-grahambrown-roll-variants.js
@@ -126,15 +126,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-harlequin-roll-variants.js b/shopify/scripts/bucketB-harlequin-roll-variants.js
index f6faf801..28e7e4d8 100644
--- a/shopify/scripts/bucketB-harlequin-roll-variants.js
+++ b/shopify/scripts/bucketB-harlequin-roll-variants.js
@@ -120,15 +120,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-hollywood-roll-variants.js b/shopify/scripts/bucketB-hollywood-roll-variants.js
index 14b70743..b0fee9b9 100644
--- a/shopify/scripts/bucketB-hollywood-roll-variants.js
+++ b/shopify/scripts/bucketB-hollywood-roll-variants.js
@@ -130,15 +130,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-longtail-roll-variants.js b/shopify/scripts/bucketB-longtail-roll-variants.js
index e064604f..835c813d 100644
--- a/shopify/scripts/bucketB-longtail-roll-variants.js
+++ b/shopify/scripts/bucketB-longtail-roll-variants.js
@@ -106,6 +106,13 @@ let budget = null;
try { budget = require(BUDGET); } catch (e) { console.error('⚠ budget.cjs not loadable — falling back to MAX_CREATE only:', e.message); }
function budgetTake(n) { if (!budget) return n; try { return budget.take('roll', n); } catch (e) { console.error('budget.take failed:', e.message); return 0; } }
function budgetRefund(n) { if (!budget || n <= 0) return 0; try { return budget.refund('roll', n); } catch (e) { console.error('budget.refund failed:', e.message); return 0; } }
+// Module-scope outstanding-grant tracker (refund-on-abort net, 2026-06-25). Holds the count of
+// roll slots TAKEN up front but not yet refunded/consumed, so the IIFE's top-level .catch can
+// return them if any uncaught throw lands BETWEEN the up-front take('roll') and the tail refund.
+// Enforces the standing DW refund invariant (memory reference_dw_variant_budget_refund_rule):
+// a take-up-front writer must NEVER strand its grant on a crash. Root-caused by today's
+// bucketB-longtail gqlR TypeError mid-run (gqlR is now hardened; this is belt-and-suspenders).
+let _outstandingRollGrant = 0;
// ---- PG helpers (Kamatera canonical mirror, sudo -u postgres over ssh) -----
function pgQuery(sql) {
@@ -294,15 +301,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags options{name values}
variants(first:30){nodes{id title sku selectedOptions{name value} inventoryItem{id}}}}}`;
@@ -385,6 +410,7 @@ function loadWorklist() {
let grant = 0;
if (APPLY && want > 0) {
grant = budgetTake(want);
+ _outstandingRollGrant = grant; // track for the abort-refund net (see top-level .catch)
report.budget = { requested: want, granted: grant };
console.log(`shared budget.cjs take('roll', ${want}) -> granted ${grant}` + (grant < want ? ` (⛔ ${want - grant} priced rows deferred by shared 1k/day cap — staged for nightly lander)` : '') + '\n');
if (grant === 0 && pricedAll.length) console.log('⛔ shared daily variant cap exhausted — 0 roll grant. Deferring ALL priced rows; tagging unpriced/discontinued only.');
@@ -408,6 +434,7 @@ function loadWorklist() {
} catch (e) {
console.error('PG write FAILED — aborting before Shopify (refunding budget grant):', e.message || e);
budgetRefund(grant);
+ _outstandingRollGrant = 0; // refunded the full grant here — clear the abort net
process.exit(2);
}
} else if (willCreate.length) {
@@ -459,6 +486,7 @@ function loadWorklist() {
report.created.push({ ...w, skuBase, price, variantId: newId });
createdThisRun++; inBatch++;
+ if (_outstandingRollGrant > 0) _outstandingRollGrant--; // this slot was really consumed; shrink the abort net to the still-unused remainder
if (inBatch >= BATCH_SIZE) { console.log(` …batch of ${inBatch} done — sleeping ${BATCH_GAP_MS/1000}s (≥90s bulk gap)`); await sleep(BATCH_GAP_MS); inBatch = 0; }
else await sleep(200);
}
@@ -468,6 +496,9 @@ function loadWorklist() {
const unused = grant - createdThisRun;
if (unused > 0) { const back = budgetRefund(unused); report.budget.refunded = back; console.log(`\nrefunded ${back} unused roll grant to shared budget (granted ${grant}, created ${createdThisRun}).`); }
}
+ // Clean tail reached: the grant has been fully accounted (createdThisRun consumed + unused
+ // refunded above). Clear the abort net so the top-level .catch can't double-refund.
+ _outstandingRollGrant = 0;
// ---- PHASE 5: unpriced → Needs-Price ; discontinued → Discontinued-Review ----
console.log(`\n--- tagging ${unpricedAll.length} unpriced rows (Needs-Price) ---`);
@@ -518,4 +549,18 @@ function loadWorklist() {
console.log(`\nmfr_sku BRIDGE unlocked ${totalBridge} of ${pricedAll.length} priced rows (rows that would have recovered 0 without the live-mfr join).`);
console.log('\nbudget:', JSON.stringify(report.budget));
if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
-})();
+})().catch(err => {
+ // Top-level abort-refund net (2026-06-25): if ANY uncaught throw escaped main() between the
+ // up-front take('roll') and the tail refund, return the still-unused grant so the shared
+ // variant ledger never carries a phantom debit (standing rule reference_dw_variant_budget_refund_rule).
+ // _outstandingRollGrant has already been decremented for every variant actually created, so we
+ // only ever refund the genuinely-unused remainder — never the slots whose Shopify variants exist.
+ if (_outstandingRollGrant > 0) {
+ const back = budgetRefund(_outstandingRollGrant);
+ console.error(`\n‼ uncaught error after up-front take — refunded ${back} unused roll slots to the ledger (abort net):`, err && err.message || err);
+ _outstandingRollGrant = 0;
+ } else {
+ console.error('\n‼ uncaught error (no outstanding roll grant to refund):', err && err.message || err);
+ }
+ process.exit(1);
+});
diff --git a/shopify/scripts/bucketB-mindthegap-roll-variants.js b/shopify/scripts/bucketB-mindthegap-roll-variants.js
index 965cf186..050dc9bb 100644
--- a/shopify/scripts/bucketB-mindthegap-roll-variants.js
+++ b/shopify/scripts/bucketB-mindthegap-roll-variants.js
@@ -120,15 +120,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-nina-roll-variants.js b/shopify/scripts/bucketB-nina-roll-variants.js
index acf4a92e..735a5cf4 100644
--- a/shopify/scripts/bucketB-nina-roll-variants.js
+++ b/shopify/scripts/bucketB-nina-roll-variants.js
@@ -129,15 +129,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-phillipromano-roll-variants.js b/shopify/scripts/bucketB-phillipromano-roll-variants.js
index 5e3a037b..6ffe3748 100644
--- a/shopify/scripts/bucketB-phillipromano-roll-variants.js
+++ b/shopify/scripts/bucketB-phillipromano-roll-variants.js
@@ -125,15 +125,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-rebelwalls-roll-variants.js b/shopify/scripts/bucketB-rebelwalls-roll-variants.js
index b4e14755..b1b436d1 100644
--- a/shopify/scripts/bucketB-rebelwalls-roll-variants.js
+++ b/shopify/scripts/bucketB-rebelwalls-roll-variants.js
@@ -107,15 +107,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-scalamandre-roll-variants.js b/shopify/scripts/bucketB-scalamandre-roll-variants.js
index 6d3e6b2f..16b0decd 100644
--- a/shopify/scripts/bucketB-scalamandre-roll-variants.js
+++ b/shopify/scripts/bucketB-scalamandre-roll-variants.js
@@ -126,15 +126,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-schumacher-roll-variants.js b/shopify/scripts/bucketB-schumacher-roll-variants.js
index d78404d9..4c5e98cd 100644
--- a/shopify/scripts/bucketB-schumacher-roll-variants.js
+++ b/shopify/scripts/bucketB-schumacher-roll-variants.js
@@ -121,15 +121,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
diff --git a/shopify/scripts/bucketB-thibaut-roll-variants.js b/shopify/scripts/bucketB-thibaut-roll-variants.js
index c48a19a9..d97a7f4c 100644
--- a/shopify/scripts/bucketB-thibaut-roll-variants.js
+++ b/shopify/scripts/bucketB-thibaut-roll-variants.js
@@ -114,15 +114,33 @@ function gql(query, variables = {}) {
req.on('error', reject); req.write(body); req.end();
});
}
+function _isThrottled(errs) {
+ // tolerate any shape: GraphQL array, a single error object, or a raw string body
+ if (!errs) return false;
+ const arr = Array.isArray(errs) ? errs : [errs];
+ return arr.some(e => /throttl/i.test(typeof e === 'string' ? e : JSON.stringify(e)));
+}
async function gqlR(q, v, tries = 4) {
+ let last = null;
for (let i = 0; i < tries; i++) {
- const r = await gql(q, v);
+ let r;
+ try { r = await gql(q, v); }
+ catch (e) {
+ // gql() rejected (raw string body / socket error). Treat as a retryable transient —
+ // NEVER let it throw past gqlR and strand an up-front roll grant before the refund.
+ last = { errors: [{ message: 'gql-reject', detail: typeof e === 'string' ? e.slice(0, 200) : String(e && e.message || e) }] };
+ await sleep(1500 * (i + 1));
+ continue;
+ }
+ last = r;
if (r && !r.errors) return r;
- const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
- if (!throttled) return r;
+ if (!_isThrottled(r && r.errors)) return r;
await sleep(1500 * (i + 1));
}
- return gql(q, v);
+ // exhausted retries: return the last response (or a safe error shape) — callers read
+ // r.data?... and userErrors defensively, so a null-data return degrades to a logged skip,
+ // not a crash.
+ try { return await gql(q, v); } catch (e) { return last || { errors: [{ message: 'gql-exhausted' }] }; }
}
const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
← b05f19a0 auto-save: 2026-06-25T07:02:28 (4 files) — shopify/scripts/c
·
back to Designer Wallcoverings
·
Fix front-facing Brewster/York leak: 26 products retitled+re b142d6f5 →