[object Object]

← back to Goldleafwallpaper

IG poster: stop misclassifying code-9004 media errors as a dead token

8e3455587cbd0467b5f4dc691ed3fef231030aad · 2026-09-17 09:30:45 -0700 · Steve Abrams

Meta stamps media-fetch rejections (code 9004 / subcode 2207052) as
type:OAuthException, so the old bare-"OAuth" regex fired a false
"token expired — re-auth" alert on a HEALTHY token (confirmed healthy
by meta-token-canary) and masked the real image-URL bug. Extract a
testable classify-error module that excludes the media-error signature
from the auth test while preserving genuine code-190 expiry detection.
Ships a negative test (5/5) proving the captured media error is MEDIA
not AUTH and a real expiry still fires AUTH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToTTHwSB27atpGoXUGymk9

Files touched

Diff

commit 8e3455587cbd0467b5f4dc691ed3fef231030aad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 17 09:30:45 2026 -0700

    IG poster: stop misclassifying code-9004 media errors as a dead token
    
    Meta stamps media-fetch rejections (code 9004 / subcode 2207052) as
    type:OAuthException, so the old bare-"OAuth" regex fired a false
    "token expired — re-auth" alert on a HEALTHY token (confirmed healthy
    by meta-token-canary) and masked the real image-URL bug. Extract a
    testable classify-error module that excludes the media-error signature
    from the auth test while preserving genuine code-190 expiry detection.
    Ships a negative test (5/5) proving the captured media error is MEDIA
    not AUTH and a real expiry still fires AUTH.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01ToTTHwSB27atpGoXUGymk9
---
 scripts/ig-poster/classify-error.js           | 48 +++++++++++++++++++++++++++
 scripts/ig-poster/post-next.js                | 17 ++++++++--
 scripts/ig-poster/test/classify-error.test.js | 37 +++++++++++++++++++++
 3 files changed, 100 insertions(+), 2 deletions(-)

diff --git a/scripts/ig-poster/classify-error.js b/scripts/ig-poster/classify-error.js
new file mode 100644
index 0000000..6ef1070
--- /dev/null
+++ b/scripts/ig-poster/classify-error.js
@@ -0,0 +1,48 @@
+'use strict';
+/**
+ * classify-error.js — classify a failed IG Graph publish error message.
+ *
+ * WHY THIS EXISTS (TK-11880 grooming cycle 2, 2026-09-17):
+ * Meta's Graph API stamps MANY non-auth failures with `type:"OAuthException"`.
+ * The poster's old inline test keyed the "token expired — re-auth" alert off the
+ * bare substring `OAuth`, so a MEDIA-FETCH rejection (code 9004 / subcode 2207052,
+ * "The media could not be fetched from this URI") was misclassified as a dead token
+ * and fired a false re-auth alert — on a token that meta-token-canary independently
+ * reads as HEALTHY (verified 2026-09-17). That is the CLAUDE.md TK-11431 "a check
+ * reported the wrong cause" class: it would send someone to rotate a healthy
+ * credential while the REAL problem (an image URL IG can't fetch/accept) stays hidden.
+ *
+ * The fix: recognize the media-error signature explicitly and EXCLUDE it from the
+ * auth test, while keeping genuine expiry detection (Meta always carries code 190 +
+ * a canonical "Session has expired" / "access token" phrase on a real expiry).
+ *
+ * Pure + side-effect free so it can be unit-tested without running the poster
+ * (the poster would otherwise attempt a live post). Ship the testability seam.
+ */
+
+// IG media-fetch / media-type rejections. NOT a token problem — the token is fine;
+// IG could not fetch or accept the image URL (dimensions, format, or transform params).
+const MEDIA_ERR_RE =
+  /"code":\s*9004|"error_subcode":\s*2207052|media could not be fetched|Only photo or video can be accepted/i;
+
+// Genuine access-token failures. Meta emits code 190 (+ subcodes 458/463/467/492)
+// with a canonical "Session has expired" / "access token" phrase on a real expiry.
+// Deliberately does NOT match the bare type string "OAuthException" alone, because
+// media (9004) and rate-limit (4/17/32/613) errors also carry that type.
+const AUTH_ERR_RE =
+  /"code":\s*190|"error_subcode":\s*(?:458|463|467|492)|access token|session has expired|not a valid access token|token is invalid/i;
+
+/**
+ * @param {string} msg  the error message (typically the JSON body Meta returned)
+ * @returns {{isMediaErr:boolean, isAuthErr:boolean}}
+ *   isAuthErr is only true when it is NOT a media error, so a code-9004 rejection
+ *   can never route to the re-auth alert.
+ */
+function classifyPostError(msg) {
+  const s = String(msg == null ? '' : msg);
+  const isMediaErr = MEDIA_ERR_RE.test(s);
+  const isAuthErr = !isMediaErr && AUTH_ERR_RE.test(s);
+  return { isMediaErr, isAuthErr };
+}
+
+module.exports = { classifyPostError, MEDIA_ERR_RE, AUTH_ERR_RE };
diff --git a/scripts/ig-poster/post-next.js b/scripts/ig-poster/post-next.js
index 39ab490..d50ed94 100644
--- a/scripts/ig-poster/post-next.js
+++ b/scripts/ig-poster/post-next.js
@@ -23,6 +23,7 @@ const fs = require('fs');
 const path = require('path');
 const { buildCaption } = require('./caption');
 const { postCncpCard } = require('./cncp-alert');
+const { classifyPostError } = require('./classify-error');
 
 const ROOT = path.resolve(__dirname, '../..');
 const PRODUCTS = path.join(ROOT, 'data/products.json');
@@ -145,9 +146,21 @@ async function publish(imageUrl, caption) {
       const msg = String(err.message || err);
       appendLog({ ...base, result: 'error', error: msg });
       console.error('POST FAILED:', msg);
-      // If this is a token/auth failure, alert LOUDLY so the poster can never
+      // Classify the failure. Meta stamps media-fetch rejections (code 9004 /
+      // subcode 2207052) as type:"OAuthException" too, so keying the re-auth alert
+      // off the bare word "OAuth" fired a FALSE "token expired" alert on a healthy
+      // token and masked the real image-URL bug (TK-11880 grooming, 2026-09-17).
+      const { isMediaErr, isAuthErr } = classifyPostError(msg);
+      if (isMediaErr) {
+        // NOT a token problem — the token is fine; IG could not fetch/accept the
+        // image URL. Report the real cause; do NOT fire the re-auth alert.
+        console.error('MEDIA-URI REJECTED (code 9004) — Instagram could not fetch/accept the image URL; '
+          + 'the token is unaffected. Check the source image (dimensions/format/transform params): '
+          + `${msg.slice(0, 200)}`);
+      }
+      // If this is a genuine token/auth failure, alert LOUDLY so the poster can never
       // silently stop after the token expires — post a CNCP parking-lot card.
-      if (/OAuth|expired|code":?\s*190|access token|not valid|session/i.test(msg)) {
+      if (isAuthErr) {
         // CLAUDE.md TK-11431 amendment 2 — ALERT DELIVERY IS A FINDING, NOT A
         // LOG LINE. The previous arm was a hand-rolled fetch wrapped in
         // `.catch(() => {})` inside `try {} catch (_) {}`, and then printed
diff --git a/scripts/ig-poster/test/classify-error.test.js b/scripts/ig-poster/test/classify-error.test.js
new file mode 100644
index 0000000..eda9896
--- /dev/null
+++ b/scripts/ig-poster/test/classify-error.test.js
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * Negative test for classify-error.js (TK-11431 amendment 3: ship a test that
+ * proves the check goes the RIGHT way on an injected fault).
+ *
+ * Run: node scripts/ig-poster/test/classify-error.test.js   (exit 0 = pass)
+ */
+const assert = require('node:assert');
+const { classifyPostError } = require('../classify-error');
+
+let n = 0, fail = 0;
+function check(name, got, want) {
+  n++;
+  try { assert.deepStrictEqual(got, want); console.log(`  ok  ${name}`); }
+  catch (e) { fail++; console.error(`  FAIL ${name}: got ${JSON.stringify(got)} want ${JSON.stringify(want)}`); }
+}
+
+// (1) The EXACT media-fetch rejection captured in goldleaf-ig-poster.log 2026-09-17.
+//     This is the regression the fix exists to prevent: it must NOT be auth.
+const CAPTURED_MEDIA = 'container failed: {"error":{"message":"Only photo or video can be accepted as media type.","type":"OAuthException","code":9004,"error_subcode":2207052,"is_transient":false,"error_user_title":"Media download has failed. The media URI doesn\'t meet our requirements.","error_user_msg":"The media could not be fetched from this URI: https://cdn.shopify.com/...jpg?width=500&height=400&crop=bottom"}}';
+check('captured 9004 media error is MEDIA, not AUTH', classifyPostError(CAPTURED_MEDIA), { isMediaErr: true, isAuthErr: false });
+
+// (2) A genuine token expiry MUST still classify as auth (fix must not blind the real alarm).
+const REAL_EXPIRY = '{"error":{"message":"Error validating access token: Session has expired on ...","type":"OAuthException","code":190,"error_subcode":463,"fbtrace_id":"x"}}';
+check('real code-190 expiry is AUTH', classifyPostError(REAL_EXPIRY), { isMediaErr: false, isAuthErr: true });
+
+// (3) A bare OAuthException type with NO auth signal and NO media signal must NOT
+//     fire the re-auth alert (the old bare-`OAuth` match is exactly the bug).
+const RATE_LIMIT = '{"error":{"message":"Application request limit reached","type":"OAuthException","code":4,"is_transient":true,"fbtrace_id":"y"}}';
+check('rate-limit OAuthException is neither media nor auth', classifyPostError(RATE_LIMIT), { isMediaErr: false, isAuthErr: false });
+
+// (4) Empty / non-error input is inert.
+check('empty msg is inert', classifyPostError(''), { isMediaErr: false, isAuthErr: false });
+check('null msg is inert', classifyPostError(null), { isMediaErr: false, isAuthErr: false });
+
+console.log(`\n${n - fail}/${n} passed`);
+process.exit(fail ? 1 : 0);

← 7e80969 IG poster now verifies its token-expiry alert actually lande  ·  back to Goldleafwallpaper  ·  auto-data-snapshot: 2026-09-19T01:29:58 (1 data files) — dat f8e8316 →