Files
MagicMirror/js/http_fetcher.js
Karsten Hassel d05ea751d9 Release 2.35.0 (#4072)
## Release Notes
Thanks to: @angeldeejay, @in-voker, @JHWelch, @khassel,
@KristjanESPERANTO, @rejas, @sdetweil
> ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change
to previous release)

[Compare to previous Release
v2.34.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.34.0...v2.25.0)

> ⚠️ We introduced some internal changes with this release, please read
[this forum
post](https://forum.magicmirror.builders/topic/20138/upcoming-release-april-1-2026-breaking-changes-some-operational-changes)
before upgrading!

### [core]
- Prepare Release 2.35.0 (#4071)
- docs: add security policy and vulnerability reporting guidelines
(#4069)
- refactor: simplify internal `require()` calls (#4056)
- allow environment variables in cors urls (#4033)
- fix cors proxy getting binary data (e.g. png, webp) (#4030)
- fix: correct secret redaction and optimize loadConfig (#4031)
- change loading config.js, allow variables in config.js and try to
protect sensitive data (#4029)
- remove kioskmode (#4027)
- Add dark theme logo (#4026)
- move custom.css from css to config (#4020)
- move default modules from /modules/default to /defaultmodules (#4019)
- update node versions in workflows (#4018)
- [core] refactor: extract and centralize HTTP fetcher (#4016)
- fix systeminformation not displaying electron version (#4012)
- Update node-ical and support it's rrule-temporal changes (#4010)
- Change default start scripts from X11 to Wayland (#4011)
- refactor: unify favicon for index.html and Electron (#4006)
- [core] run systeminformation in subprocess so the info is always
displayed (#4002)
- set next release dev number (#4000)

### [dependencies]
- update dependencies (#4068)
- update dependencies incl. electron to v41 (#4058)
- chore: upgrade ESLint to v10 and fix newly surfaced issues (#4057)
- chore: update ESLint and plugins, simplify config, apply new rules
(#4052)
- chore: update dependencies + add exports, files, and sideEffects
fields to package.json (#4040)
- [core] refactor: enable ESLint rule require-await and handle detected
issues (#4038)
- Update node-ical and other deps (#4025)
- chore: update dependencies (#4021)
- chore(eslint): migrate from eslint-plugin-vitest to
@vitest/eslint-plugin and run rules only on test files (#4014)
- Update deps as requested by dependabot (#4008)
- update Collaboration.md and dependencies (#4001)

### [logging]
- refactor: further logger clean-up (#4050)
- Fix Node.js v25 logging prefix and modernize logger (#4049)

### [modules/calendar]
- fix(calendar): make showEnd behavior more consistent across time
formats (#4059)
- test(calendar): fix hardcoded date in event shape test (#4055)
- [calendar] refactor: delegate event expansion to node-ical's
expandRecurringEvent (#4047)
- calendar.js: remove useless hasCalendarURL function (#4028)
- fix(calendar): update to node-ical 0.23.1 and fix full-day recurrence
lookup (#4013)
- fix(calendar): correct day-of-week for full-day recurring events
across all timezones (#4004)

### [modules/newsfeed]
- fix(newsfeed): fix full article view and add framing check (#4039)
- [newsfeed] refactor: migrate to centralized HTTPFetcher (#4023)

### [modules/weather]
- fix(weather): fix openmeteo forecast stuck in the past (#4064)
- fix(weather): fix weathergov forecast day labels off by one (#4065)
- weather: fixes for templates (#4054)
- weather: add possibility to override njk's and css (#4051)
- Use getDateString in openmeteo (#4046)
- [weather] refactor: migrate to server-side providers with centralized
HTTPFetcher (#4032)
- [weather] feat: add Weather API Provider  (#4036)

### [testing]
- chore: remove obsolete Jest config and unit test global setup (#4044)
- replace template_spec test with config_variables test (#4034)
- refactor(clientonly): modernize code structure and add comprehensive
tests (#4022)
- Switch to undici Agent for HTTPS requests (#4015)
- chore: migrate CI workflows to ubuntu-slim for faster startup times
(#4007)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: dathbe <github@beffa.us>
Co-authored-by: Marcel <m-idler@users.noreply.github.com>
Co-authored-by: Kevin G. <crazylegstoo@gmail.com>
Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com>
Co-authored-by: Jboucly <contact@jboucly.fr>
Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com>
Co-authored-by: Jordan Welch <JordanHWelch@gmail.com>
Co-authored-by: Blackspirits <blackspirits@gmail.com>
Co-authored-by: Samed Ozdemir <samed@xsor.io>
Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com>
Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com>
2026-04-01 12:35:16 +02:00

344 lines
11 KiB
JavaScript

const { EventEmitter } = require("node:events");
const { Agent } = require("undici");
const Log = require("logger");
const { getUserAgent } = require("#server_functions");
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const THIRTY_MINUTES = 30 * 60 * 1000;
const MAX_SERVER_BACKOFF = 3;
const DEFAULT_TIMEOUT = 30000; // 30 seconds
/**
* Maps errorType to MagicMirror translation keys.
* This allows HTTPFetcher to provide ready-to-use translation keys,
* eliminating the need to call NodeHelper.checkFetchError().
*/
const ERROR_TYPE_TO_TRANSLATION = {
AUTH_FAILURE: "MODULE_ERROR_UNAUTHORIZED",
RATE_LIMITED: "MODULE_ERROR_RATE_LIMITED",
SERVER_ERROR: "MODULE_ERROR_SERVER_ERROR",
CLIENT_ERROR: "MODULE_ERROR_CLIENT_ERROR",
NETWORK_ERROR: "MODULE_ERROR_NO_CONNECTION",
UNKNOWN_ERROR: "MODULE_ERROR_UNSPECIFIED"
};
/**
* HTTPFetcher - Centralized HTTP fetching with intelligent error handling
*
* Features:
* - Automatic retry strategies based on HTTP status codes
* - Exponential backoff for server errors
* - Retry-After header parsing for rate limiting
* - Authentication support (Basic, Bearer)
* - Self-signed certificate support
* @augments EventEmitter
* @fires HTTPFetcher#response - When fetch succeeds with ok response
* @fires HTTPFetcher#error - When fetch fails or returns non-ok response
* @example
* const fetcher = new HTTPFetcher(url, { reloadInterval: 60000 });
* fetcher.on('response', (response) => { ... });
* fetcher.on('error', (errorInfo) => { ... });
* fetcher.startPeriodicFetch();
*/
class HTTPFetcher extends EventEmitter {
/**
* Calculates exponential backoff delay for retries
* @param {number} attempt - Attempt number (1-based)
* @param {object} options - Configuration options
* @param {number} [options.baseDelay] - Initial delay in ms (default: 15s)
* @param {number} [options.maxDelay] - Maximum delay in ms (default: 5min)
* @returns {number} Delay in milliseconds
* @example
* HTTPFetcher.calculateBackoffDelay(1) // 15000 (15s)
* HTTPFetcher.calculateBackoffDelay(2) // 30000 (30s)
* HTTPFetcher.calculateBackoffDelay(3) // 60000 (60s)
* HTTPFetcher.calculateBackoffDelay(6) // 300000 (5min, capped)
*/
static calculateBackoffDelay (attempt, { baseDelay = 15000, maxDelay = 300000 } = {}) {
return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
}
/**
* Creates a new HTTPFetcher instance
* @param {string} url - The URL to fetch
* @param {object} options - Configuration options
* @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min)
* @param {object} [options.auth] - Authentication options
* @param {string} [options.auth.method] - 'basic' or 'bearer'
* @param {string} [options.auth.user] - Username for basic auth
* @param {string} [options.auth.pass] - Password or token
* @param {boolean} [options.selfSignedCert] - Accept self-signed certificates
* @param {object} [options.headers] - Additional headers to send
* @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3)
* @param {number} [options.timeout] - Request timeout in ms (default: 30000)
* @param {string} [options.logContext] - Optional context for log messages (e.g., provider name)
*/
constructor (url, options = {}) {
super();
this.url = url;
this.reloadInterval = options.reloadInterval || 5 * 60 * 1000;
this.auth = options.auth || null;
this.selfSignedCert = options.selfSignedCert || false;
this.customHeaders = options.headers || {};
this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF;
this.timeout = options.timeout || DEFAULT_TIMEOUT;
this.logContext = options.logContext ? `[${options.logContext}] ` : "";
this.reloadTimer = null;
this.serverErrorCount = 0;
this.networkErrorCount = 0;
}
/**
* Clears any pending reload timer
*/
clearTimer () {
if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
this.reloadTimer = null;
}
}
/**
* Schedules the next fetch.
* If no delay is provided, uses reloadInterval.
* If delay is provided but very short (< 1 second), clamps to reloadInterval
* to prevent hammering servers.
* @param {number} [delay] - Delay in milliseconds
*/
scheduleNextFetch (delay) {
let nextDelay = delay ?? this.reloadInterval;
// Only clamp if delay is unreasonably short (< 1 second)
// This allows respecting Retry-After headers while preventing abuse
if (nextDelay < 1000) {
nextDelay = this.reloadInterval;
}
// Don't schedule in test mode
if (process.env.mmTestMode === "true") {
return;
}
this.reloadTimer = setTimeout(() => this.fetch(), nextDelay);
}
/**
* Starts periodic fetching
*/
startPeriodicFetch () {
this.fetch();
}
/**
* Builds the options object for fetch
* @returns {object} Options object containing headers (and dispatcher if needed)
*/
getRequestOptions () {
const headers = {
"User-Agent": getUserAgent(),
...this.customHeaders
};
const options = { headers };
if (this.selfSignedCert) {
options.dispatcher = new Agent({
connect: {
rejectUnauthorized: false
}
});
}
if (this.auth) {
if (this.auth.method === "bearer") {
headers.Authorization = `Bearer ${this.auth.pass}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
}
}
return options;
}
/**
* Parses the Retry-After header value
* @param {string} retryAfter - The Retry-After header value
* @returns {number|null} Milliseconds to wait or null if parsing failed
*/
#parseRetryAfter (retryAfter) {
// Try parsing as seconds
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}
// Try parsing as HTTP-date
const retryDate = Date.parse(retryAfter);
if (!Number.isNaN(retryDate)) {
return Math.max(0, retryDate - Date.now());
}
return null;
}
/**
* Determines the retry delay for a non-ok response
* @param {Response} response - The fetch Response object
* @returns {{delay: number, errorInfo: object}} Computed retry delay and error info
*/
#getDelayForResponse (response) {
const { status } = response;
let delay = this.reloadInterval;
let message;
let errorType = "UNKNOWN_ERROR";
if (status === 401 || status === 403) {
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}`);
} 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}`);
} 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.`;
Log.error(`${this.logContext}${this.url} - ${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}`);
} else {
message = `Unexpected HTTP status ${status}.`;
Log.error(`${this.logContext}${this.url} - ${message}`);
}
return {
delay,
errorInfo: this.#createErrorInfo(message, status, errorType, delay)
};
}
/**
* Creates a standardized error info object
* @param {string} message - Error message
* @param {number|null} status - HTTP status code or null for network errors
* @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR
* @param {number} retryAfter - Delay until next retry in ms
* @param {Error} [originalError] - The original error if any
* @returns {object} Error info object with translationKey for direct use
*/
#createErrorInfo (message, status, errorType, retryAfter, originalError = null) {
return {
message,
status,
errorType,
translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED",
retryAfter,
retryCount: errorType === "NETWORK_ERROR" ? this.networkErrorCount : this.serverErrorCount,
url: this.url,
originalError
};
}
/**
* Performs the HTTP fetch and emits appropriate events
* @fires HTTPFetcher#response
* @fires HTTPFetcher#error
*/
async fetch () {
this.clearTimer();
let nextDelay = this.reloadInterval;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(this.url, {
...this.getRequestOptions(),
signal: controller.signal
});
if (!response.ok) {
const { delay, errorInfo } = this.#getDelayForResponse(response);
nextDelay = delay;
this.emit("error", errorInfo);
} else {
// Reset error counts on success
this.serverErrorCount = 0;
this.networkErrorCount = 0;
/**
* Response event - fired when fetch succeeds
* @event HTTPFetcher#response
* @type {Response}
*/
this.emit("response", response);
}
} catch (error) {
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 backoffDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, {
maxDelay: this.reloadInterval
});
nextDelay = backoffDelay;
// 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 (urlError) {
// If URL parsing fails, use original URL
}
// 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}`);
} else {
Log.error(`${this.logContext}${shortUrl} - ${message} ${retryMessage}`);
}
const errorInfo = this.#createErrorInfo(
message,
null,
"NETWORK_ERROR",
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);
}
this.scheduleNextFetch(nextDelay);
}
}
module.exports = HTTPFetcher;