[object Object]

← back to La Socrata Ingester

arcgis: retry transient body-level errors (499/CONT_0001,429,5xx) so one flaky source can't poison the nightly batch exit code (TK-10851)

a8295e43e239b1cbc1d41aa572f60d698e3a86e9 · 2026-08-26 08:41:27 -0700 · claude-run-10749

ArcGIS tunnels errors as HTTP 200 with an {error:{code}} body, so http.js's
status-based retry never saw them and cli.js:76-77 failed the whole 'all' run's
exit code on a single transient 499. Add payload-aware fetchArcgis retry with
backoff; bare 499 (Token Required) stays fail-fast, only 499+CONT_0001 retries.
+ zero-dep regression test (transient-retry / 400-fail-fast / auth-499-fail-fast).

Files touched

Diff

commit a8295e43e239b1cbc1d41aa572f60d698e3a86e9
Author: claude-run-10749 <steve@designerwallcoverings.com>
Date:   Wed Aug 26 08:41:27 2026 -0700

    arcgis: retry transient body-level errors (499/CONT_0001,429,5xx) so one flaky source can't poison the nightly batch exit code (TK-10851)
    
    ArcGIS tunnels errors as HTTP 200 with an {error:{code}} body, so http.js's
    status-based retry never saw them and cli.js:76-77 failed the whole 'all' run's
    exit code on a single transient 499. Add payload-aware fetchArcgis retry with
    backoff; bare 499 (Token Required) stays fail-fast, only 499+CONT_0001 retries.
    + zero-dep regression test (transient-retry / 400-fail-fast / auth-499-fail-fast).
---
 src/adapters/arcgis.js     | 36 +++++++++++++++++++++----
 test/arcgis-retry.test.mjs | 65 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 96 insertions(+), 5 deletions(-)

diff --git a/src/adapters/arcgis.js b/src/adapters/arcgis.js
index 0246d9f..3654e6a 100644
--- a/src/adapters/arcgis.js
+++ b/src/adapters/arcgis.js
@@ -1,4 +1,32 @@
-import { fetchJson } from './http.js';
+import { fetchJson, sleep } from './http.js';
+
+// ArcGIS tunnels errors as HTTP 200 with an {error:{code,messageCode}} BODY, so the
+// HTTP-level retry in fetchJson never sees them. Throttle/5xx codes are transient and
+// retried; anything else (e.g. 400 bad query) is permanent and thrown immediately so we
+// fail fast instead of backing off 5x. NOTE: bare 499 is NOT transient — canonically it's
+// "Token Required" (a permanent auth failure on these public sources); only 499 carrying
+// messageCode CONT_0001 ("item momentarily inaccessible") is the transient we retry.
+const TRANSIENT_ARCGIS = new Set([429, 500, 502, 503, 504]);
+const isTransientArcgisError = (e) =>
+  !!e && (TRANSIENT_ARCGIS.has(e.code) || e.messageCode === 'CONT_0001');
+
+// fetchJson + ArcGIS-payload-aware retry with capped exponential backoff.
+async function fetchArcgis(url, { tries = 5 } = {}) {
+  let attempt = 0;
+  for (;;) {
+    attempt++;
+    const data = await fetchJson(url); // HTTP-level (429/5xx/network) retry already inside
+    if (!data.error) return data;
+    if (!isTransientArcgisError(data.error) || attempt >= tries) {
+      throw new Error(`ArcGIS error: ${JSON.stringify(data.error).slice(0, 200)}`);
+    }
+    const wait = Math.min(30000, 500 * 2 ** attempt);
+    console.error(
+      `  ⟳ ArcGIS transient ${data.error.code ?? ''} ${data.error.messageCode ?? ''} — retry ${attempt}/${tries} in ${wait}ms`
+    );
+    await sleep(wait);
+  }
+}
 
 // ArcGIS FeatureServer/MapServer paginator. Yields { rows, url } per page, where
 // each row is the feature's attributes (with __geometry attached when src.geometry).
@@ -43,8 +71,7 @@ export async function* arcgisPages(src, opts = {}) {
       const count = Math.min(pageSize, maxRows - fetched);
       const p = new URLSearchParams({ ...common, where, orderByFields: oidField, resultRecordCount: String(count) });
       const url = `${base}?${p}`;
-      const data = await fetchJson(url);
-      if (data.error) throw new Error(`ArcGIS error: ${JSON.stringify(data.error).slice(0, 200)}`);
+      const data = await fetchArcgis(url);
       const feats = data.features || [];
       if (!feats.length) break;
       yield { rows: mapRows(feats), url };
@@ -67,8 +94,7 @@ export async function* arcgisPages(src, opts = {}) {
     if (!src.noOrder) params.orderByFields = src.orderBy || oidField;
     const p = new URLSearchParams(params);
     const url = `${base}?${p}`;
-    const data = await fetchJson(url);
-    if (data.error) throw new Error(`ArcGIS error: ${JSON.stringify(data.error).slice(0, 200)}`);
+    const data = await fetchArcgis(url);
     const feats = data.features || [];
     if (!feats.length) break;
     yield { rows: mapRows(feats), url };
diff --git a/test/arcgis-retry.test.mjs b/test/arcgis-retry.test.mjs
new file mode 100644
index 0000000..5d3ea91
--- /dev/null
+++ b/test/arcgis-retry.test.mjs
@@ -0,0 +1,65 @@
+// Regression test for TK-10851: ArcGIS tunnels errors as HTTP 200 with an
+// {error:{code}} body, so the HTTP-level retry never sees them. fetchArcgis must
+// RETRY transient codes (499/CONT_0001, 429, 5xx) and FAIL FAST on permanent ones (400).
+// Run: node test/arcgis-retry.test.mjs   (zero deps; ~3s due to real backoff)
+import { arcgisPages } from '../src/adapters/arcgis.js';
+
+const src = { endpoint: 'http://fake/FeatureServer/0', paginate: 'oid', oidField: 'OBJECTID', geometry: false };
+let fail = 0;
+
+// 1) transient 499 twice, then success — must retry through, not throw
+let calls = 0;
+globalThis.fetch = async () => {
+  calls++;
+  const body = calls <= 2
+    ? { error: { code: 499, messageCode: 'CONT_0001', message: 'Item does not exist or is inaccessible.' } }
+    : { features: [{ attributes: { OBJECTID: 1, APN: '1234' } }] };
+  return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
+};
+try {
+  let rows = 0;
+  for await (const page of arcgisPages(src, { maxRows: 1 })) rows += page.rows.length;
+  const ok = rows === 1 && calls === 3;
+  console.log(`TRANSIENT: ${ok ? 'PASS (retried through 2x 499, then succeeded)' : `FAIL (rows=${rows} calls=${calls})`}`);
+  if (!ok) fail++;
+} catch (e) {
+  console.log(`TRANSIENT: FAIL (threw instead of retrying: ${e.message}, calls=${calls})`);
+  fail++;
+}
+
+// 2) permanent 400 — must throw immediately, no backoff
+let calls2 = 0;
+globalThis.fetch = async () => {
+  calls2++;
+  const b = { error: { code: 400, message: 'Invalid query parameters' } };
+  return { ok: true, status: 200, json: async () => b, text: async () => JSON.stringify(b) };
+};
+try {
+  for await (const _ of arcgisPages(src, { maxRows: 1 })) { /* consume */ }
+  console.log('PERMANENT: FAIL (did not throw on 400)');
+  fail++;
+} catch {
+  const ok = calls2 === 1;
+  console.log(`PERMANENT: ${ok ? 'PASS (400 threw immediately, no retry)' : `FAIL (retried a permanent error, calls=${calls2})`}`);
+  if (!ok) fail++;
+}
+
+// 3) bare 499 "Token Required" (no CONT_0001) — a permanent auth failure, must fail fast
+let calls3 = 0;
+globalThis.fetch = async () => {
+  calls3++;
+  const b = { error: { code: 499, messageCode: 'GWM_0003', message: 'Token Required' } };
+  return { ok: true, status: 200, json: async () => b, text: async () => JSON.stringify(b) };
+};
+try {
+  for await (const _ of arcgisPages(src, { maxRows: 1 })) { /* consume */ }
+  console.log('AUTH-499: FAIL (retried/masked a Token-Required auth failure)');
+  fail++;
+} catch {
+  const ok = calls3 === 1;
+  console.log(`AUTH-499: ${ok ? 'PASS (bare 499 threw immediately, not masked)' : `FAIL (retried a 499 auth error, calls=${calls3})`}`);
+  if (!ok) fail++;
+}
+
+process.exitCode = fail ? 1 : 0;
+console.log(fail ? `\n${fail} check(s) FAILED` : '\nAll checks PASS');

← 45c0d9f chore: v0.2.2 (session close)  ·  back to La Socrata Ingester  ·  chore: v0.2.3 (session close — arcgis transient-retry fix) 6069fd2 →