From d17640e861f1d5ac11076d0568990a42aacb3e69 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sat, 5 Sep 2026 23:03:03 -0400 Subject: [PATCH 1/2] fix(netbox): retry network failures and surface fetch error cause undici reports DNS/connect/TLS failures as a bare 'fetch failed', hiding the real reason in err.cause. Log the cause and URL, and retry network-level failures (3 attempts, backoff). HTTP error responses are not retried. Fixes #469 --- create-a-container/utils/netbox.js | 33 +++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index 1661615f..c3820f72 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -21,6 +21,11 @@ const NETBOX_COMMENT = 'This container was built using opensource-server'; // for display, so convert gigabytes to MB before sending. const MB_PER_GB = 1000; +// Network-level failures (DNS blip, hairpin-NAT connect reset) are retried; +// HTTP error responses are not. +const FETCH_ATTEMPTS = 3; +const FETCH_RETRY_DELAY_MS = 1000; + /** * Build request headers for NetBox API calls. * @param {string} token - NetBox API token @@ -33,6 +38,32 @@ function headers(token) { }; } +/** + * fetch() with retries on network errors. undici reports these as a bare + * "fetch failed", so the underlying cause is surfaced in the thrown message. + * @param {string} url + * @param {object} init - fetch init options + * @returns {Promise} + */ +async function fetchWithRetry(url, init) { + let lastErr; + for (let attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++) { + try { + return await fetch(url, init); + } catch (err) { + lastErr = err; + if (attempt < FETCH_ATTEMPTS) { + await new Promise(r => setTimeout(r, FETCH_RETRY_DELAY_MS * attempt)); + } + } + } + const cause = lastErr.cause?.code || lastErr.cause?.message || lastErr.message; + throw new Error( + `NetBox unreachable after ${FETCH_ATTEMPTS} attempts (${init.method || 'GET'} ${url}): ${cause}`, + { cause: lastErr }, + ); +} + /** * Perform a fetch against the NetBox API. * Throws on non-2xx responses. Returns null for 204 No Content. @@ -44,7 +75,7 @@ function headers(token) { */ async function nbFetch(baseUrl, token, path, options = {}) { const url = `${baseUrl.replace(/\/$/, '')}/api${path}`; - const res = await fetch(url, { + const res = await fetchWithRetry(url, { ...options, headers: { ...headers(token), ...(options.headers || {}) }, }); From 6c7326e37661c09d213030c73eec990223c51906 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sat, 5 Sep 2026 23:07:05 -0400 Subject: [PATCH 2/2] fix(netbox): only retry undici network errors Rethrow non-network exceptions (bad URL, abort) immediately instead of retrying them and masking the original error. --- create-a-container/utils/netbox.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index c3820f72..3f42b296 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -39,8 +39,16 @@ function headers(token) { } /** - * fetch() with retries on network errors. undici reports these as a bare - * "fetch failed", so the underlying cause is surfaced in the thrown message. + * undici wraps DNS/connect/TLS failures as TypeError('fetch failed') with the + * real reason in err.cause. Anything else (bad URL, aborted, etc.) is not + * a transient network error. + */ +function isNetworkError(err) { + return err instanceof TypeError && err.message === 'fetch failed' && err.cause != null; +} + +/** + * fetch() with retries on network errors. Other errors are rethrown immediately. * @param {string} url * @param {object} init - fetch init options * @returns {Promise} @@ -51,13 +59,14 @@ async function fetchWithRetry(url, init) { try { return await fetch(url, init); } catch (err) { + if (!isNetworkError(err)) throw err; lastErr = err; if (attempt < FETCH_ATTEMPTS) { await new Promise(r => setTimeout(r, FETCH_RETRY_DELAY_MS * attempt)); } } } - const cause = lastErr.cause?.code || lastErr.cause?.message || lastErr.message; + const cause = lastErr.cause.code || lastErr.cause.message; throw new Error( `NetBox unreachable after ${FETCH_ATTEMPTS} attempts (${init.method || 'GET'} ${url}): ${cause}`, { cause: lastErr },