[object Object]

← 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

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 →