← 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
M src/adapters/arcgis.jsA test/arcgis-retry.test.mjs
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 →