From 4a0eae765048df3fa52bb3b887fe21f203815523 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:50:59 +0200 Subject: [PATCH 1/3] fix(http-fetcher): fall back to reloadInterval after retries exhausted When networkErrorCount or serverErrorCount reaches maxRetries, calculateBackoffDelay produced a fixed short delay instead of respecting the user's configured reloadInterval. Skip backoff once retries are exhausted and use reloadInterval directly. Both network and server error paths now use the same calculateBackoffDelay strategy for consistency. --- js/http_fetcher.js | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/js/http_fetcher.js b/js/http_fetcher.js index 0766168401..e58ee82ab3 100644 --- a/js/http_fetcher.js +++ b/js/http_fetcher.js @@ -209,8 +209,15 @@ class HTTPFetcher extends EventEmitter { } else if (status >= 500) { errorType = "SERVER_ERROR"; this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries); - delay = this.reloadInterval * Math.pow(2, this.serverErrorCount); - message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`; + if (this.serverErrorCount >= this.maxRetries) { + delay = this.reloadInterval; + message = `Server error (${status}). Max retries reached, retrying at configured interval (${Math.round(delay / 1000)}s).`; + } else { + delay = HTTPFetcher.calculateBackoffDelay(this.serverErrorCount, { + maxDelay: this.reloadInterval + }); + message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 1000)}s.`; + } Log.error(`${this.logContext}${this.url} - ${message}`); } else if (status >= 400) { errorType = "CLIENT_ERROR"; @@ -295,10 +302,15 @@ class HTTPFetcher extends EventEmitter { // Apply exponential backoff for network errors this.networkErrorCount = Math.min(this.networkErrorCount + 1, this.maxRetries); - const backoffDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, { - maxDelay: this.reloadInterval - }); - nextDelay = backoffDelay; + + if (this.networkErrorCount >= this.maxRetries) { + // After exhausting retries, fall back to reloadInterval to avoid tight retry loops + nextDelay = this.reloadInterval; + } else { + nextDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, { + maxDelay: this.reloadInterval + }); + } // Truncate URL for cleaner logs let shortUrl = this.url; @@ -310,11 +322,12 @@ class HTTPFetcher extends EventEmitter { } // Gradual log-level escalation: WARN for first 2 attempts, ERROR after - const retryMessage = `Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`; - if (this.networkErrorCount <= 2) { - Log.warn(`${this.logContext}${shortUrl} - ${message} ${retryMessage}`); + if (this.networkErrorCount >= this.maxRetries) { + Log.error(`${this.logContext}${shortUrl} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); + } else if (this.networkErrorCount <= 2) { + Log.warn(`${this.logContext}${shortUrl} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`); } else { - Log.error(`${this.logContext}${shortUrl} - ${message} ${retryMessage}`); + Log.error(`${this.logContext}${shortUrl} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`); } const errorInfo = this.#createErrorInfo( From d0213e111d0c167f6b98639a839497edfd06a81d Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:51:29 +0200 Subject: [PATCH 2/3] refactor(http-fetcher): simplify error handling and logging - Extract URL truncation into private #shortenUrl() method - Use #shortenUrl() consistently in all log messages - Merge duplicate networkErrorCount >= maxRetries checks into a single if/else block that handles both delay calculation and logging - Replace logFn.call(Log, ...) with explicit if/else for clarity - Remove redundant inline JSDoc for error event --- js/http_fetcher.js | 65 +++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/js/http_fetcher.js b/js/http_fetcher.js index e58ee82ab3..7ec1d9a155 100644 --- a/js/http_fetcher.js +++ b/js/http_fetcher.js @@ -183,6 +183,19 @@ class HTTPFetcher extends EventEmitter { return null; } + /** + * Returns a shortened version of the URL for log messages. + * @returns {string} Shortened URL + */ + #shortenUrl () { + try { + const urlObj = new URL(this.url); + return `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`; + } catch { + return this.url; + } + } + /** * Determines the retry delay for a non-ok response * @param {Response} response - The fetch Response object @@ -198,14 +211,14 @@ class HTTPFetcher extends EventEmitter { errorType = "AUTH_FAILURE"; delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`; - Log.error(`${this.logContext}${this.url} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); } else if (status === 429) { errorType = "RATE_LIMITED"; const retryAfter = response.headers.get("retry-after"); const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null; delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.warn(`${this.logContext}${this.url} - ${message}`); + Log.warn(`${this.logContext}${this.#shortenUrl()} - ${message}`); } else if (status >= 500) { errorType = "SERVER_ERROR"; this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries); @@ -218,15 +231,15 @@ class HTTPFetcher extends EventEmitter { }); message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 1000)}s.`; } - Log.error(`${this.logContext}${this.url} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); } else if (status >= 400) { errorType = "CLIENT_ERROR"; delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`; - Log.error(`${this.logContext}${this.url} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); } else { message = `Unexpected HTTP status ${status}.`; - Log.error(`${this.logContext}${this.url} - ${message}`); + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); } return { @@ -300,34 +313,22 @@ class HTTPFetcher extends EventEmitter { const isTimeout = error.name === "AbortError"; const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`; - // Apply exponential backoff for network errors this.networkErrorCount = Math.min(this.networkErrorCount + 1, this.maxRetries); + const exhausted = this.networkErrorCount >= this.maxRetries; - if (this.networkErrorCount >= this.maxRetries) { - // After exhausting retries, fall back to reloadInterval to avoid tight retry loops + if (exhausted) { nextDelay = this.reloadInterval; + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); } else { nextDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, { maxDelay: this.reloadInterval }); - } - - // Truncate URL for cleaner logs - let shortUrl = this.url; - try { - const urlObj = new URL(this.url); - shortUrl = `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`; - } catch { - // If URL parsing fails, use original URL - } - - // Gradual log-level escalation: WARN for first 2 attempts, ERROR after - if (this.networkErrorCount >= this.maxRetries) { - Log.error(`${this.logContext}${shortUrl} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); - } else if (this.networkErrorCount <= 2) { - Log.warn(`${this.logContext}${shortUrl} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`); - } else { - Log.error(`${this.logContext}${shortUrl} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`); + const retryMsg = `${this.logContext}${this.#shortenUrl()} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`; + if (this.networkErrorCount <= 2) { + Log.warn(retryMsg); + } else { + Log.error(retryMsg); + } } const errorInfo = this.#createErrorInfo( @@ -337,18 +338,6 @@ class HTTPFetcher extends EventEmitter { nextDelay, error ); - - /** - * Error event - fired when fetch fails - * @event HTTPFetcher#error - * @type {object} - * @property {string} message - Error description - * @property {number|null} statusCode - HTTP status or null for network errors - * @property {number} retryDelay - Ms until next retry - * @property {number} retryCount - Number of consecutive server errors - * @property {string} url - The URL that was fetched - * @property {Error|null} originalError - The original error - */ this.emit("error", errorInfo); } finally { clearTimeout(timeoutId); From a635514928ac915e0b94ea5b93af9ac756fc7974 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:51:55 +0200 Subject: [PATCH 3/3] test(http-fetcher): add retry exhaustion and backoff progression tests Verify that both network and server errors fall back to reloadInterval after maxRetries is reached, and that error counts reset on success. --- tests/unit/functions/http_fetcher_spec.js | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/unit/functions/http_fetcher_spec.js b/tests/unit/functions/http_fetcher_spec.js index 0d8d41be50..be71c77028 100644 --- a/tests/unit/functions/http_fetcher_spec.js +++ b/tests/unit/functions/http_fetcher_spec.js @@ -469,3 +469,83 @@ describe("selfSignedCert dispatcher", () => { expect(options.dispatcher).toBeUndefined(); }); }); + +describe("Retry exhaustion fallback", () => { + it("should fall back to reloadInterval after network retries exhausted", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + // Trigger maxRetries + 1 fetches to reach exhaustion + for (let i = 0; i < 4; i++) { + await fetcher.fetch(); + } + + // First retries should use backoff (< reloadInterval) + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + // Third retry hits maxRetries, should fall back to reloadInterval + expect(errors[2].retryAfter).toBe(300000); + // Subsequent errors stay at reloadInterval + expect(errors[3].retryAfter).toBe(300000); + }); + + it("should fall back to reloadInterval after server error retries exhausted", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 503 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + for (let i = 0; i < 4; i++) { + await fetcher.fetch(); + } + + // First retries should use backoff (< reloadInterval) + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + // Third retry hits maxRetries, should fall back to reloadInterval + expect(errors[2].retryAfter).toBe(300000); + // Subsequent errors stay at reloadInterval + expect(errors[3].retryAfter).toBe(300000); + }); + + it("should reset network error count on success", async () => { + let requestCount = 0; + server.use( + http.get(TEST_URL, () => { + requestCount++; + if (requestCount <= 2) return HttpResponse.error(); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + // Two failures with backoff + await fetcher.fetch(); + await fetcher.fetch(); + expect(errors).toHaveLength(2); + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + + // Success resets counter + await fetcher.fetch(); + expect(fetcher.networkErrorCount).toBe(0); + }); +});