mirror of
https://github.com/MichMich/MagicMirror.git
synced 2025-11-30 18:11:44 +00:00
[calendar] refactor: migrate CalendarFetcher to ES6 class and improve error handling (#3959)
1. Convert CalendarFetcher from legacy constructor function pattern to ES6 class (which simplifies future migration from CommonJS to ES modules). 2. Implement targeted HTTP error handling with smart retry strategies for common calendar feed issues: - 401/403: Extended retry delay (5× interval, min 30 min) - 429: Retry-After header parsing with 15 min fallback - 5xx: Exponential backoff (2^count, max 3 retries) - 4xx: Extended retry (2× interval, min 15 min) - Add serverErrorCount tracking for exponential backoff - Error messages now include specific HTTP status codes and calculated retry delays for better debugging and user feedback Previously, CalendarFetcher did not respond appropriately to HTTP errors, continuing to hammer endpoints without backoff, potentially overloading servers and triggering rate limits. This refactoring implements respectful retry strategies that adapt to server responses and reduce unnecessary load. Maybe we could later centralize the HTTP error handling and use it for weather and newsfeed as well. The PR was inspired by having worked on the calendar fetcher for MMM-CalendarExt2, where there was already better error handling.
This commit is contained in:
committed by
GitHub
parent
53df20f313
commit
3c4d69ea84
@@ -35,6 +35,7 @@ planned for 2026-01-01
|
||||
- [core] configure cspell to check default modules only and fix typos (#3955)
|
||||
- [core] refactor: replace `XMLHttpRequest` with `fetch` in `translator.js` (#3950)
|
||||
- [tests] migrate e2e tests to Playwright (#3950)
|
||||
- [calendar] refactor: migrate CalendarFetcher to ES6 class and improve error handling (#3958)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -113,8 +113,11 @@ NodeHelper.checkFetchError = function (error) {
|
||||
let error_type = "MODULE_ERROR_UNSPECIFIED";
|
||||
if (error.code === "EAI_AGAIN") {
|
||||
error_type = "MODULE_ERROR_NO_CONNECTION";
|
||||
} else if (error.message === "Unauthorized") {
|
||||
error_type = "MODULE_ERROR_UNAUTHORIZED";
|
||||
} else {
|
||||
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||
if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) {
|
||||
error_type = "MODULE_ERROR_UNAUTHORIZED";
|
||||
}
|
||||
}
|
||||
return error_type;
|
||||
};
|
||||
|
||||
@@ -1,131 +1,207 @@
|
||||
const https = require("node:https");
|
||||
const ical = require("node-ical");
|
||||
const Log = require("logger");
|
||||
const NodeHelper = require("node_helper");
|
||||
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
const { scheduleTimer } = require("#module_functions");
|
||||
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
const MAX_SERVER_BACKOFF = 3;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url The url of the calendar to fetch
|
||||
* @param {number} reloadInterval Time in ms the calendar is fetched again
|
||||
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
|
||||
* @param {number} maximumEntries The maximum number of events fetched.
|
||||
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
|
||||
* @param {object} auth The object containing options for authentication against the calendar.
|
||||
* @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
|
||||
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
|
||||
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
|
||||
* @class
|
||||
*/
|
||||
const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
let reloadTimer = null;
|
||||
let events = [];
|
||||
|
||||
let fetchFailedCallback = function () {};
|
||||
let eventsReceivedCallback = function () {};
|
||||
class CalendarFetcher {
|
||||
|
||||
/**
|
||||
* Initiates calendar fetch.
|
||||
* Creates a new CalendarFetcher instance
|
||||
* @param {string} url - The URL of the calendar to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string[]} excludedEvents - Event titles to exclude
|
||||
* @param {number} maximumEntries - Maximum number of events to return
|
||||
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
|
||||
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
|
||||
* @param {boolean} includePastEvents - Whether to include past events
|
||||
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
|
||||
*/
|
||||
const fetchCalendar = () => {
|
||||
clearTimeout(reloadTimer);
|
||||
reloadTimer = null;
|
||||
let httpsAgent = null;
|
||||
let headers = {
|
||||
"User-Agent": getUserAgent()
|
||||
};
|
||||
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
this.url = url;
|
||||
this.reloadInterval = reloadInterval;
|
||||
this.excludedEvents = excludedEvents;
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumNumberOfDays = maximumNumberOfDays;
|
||||
this.auth = auth;
|
||||
this.includePastEvents = includePastEvents;
|
||||
this.selfSignedCert = selfSignedCert;
|
||||
|
||||
if (selfSignedCert) {
|
||||
httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
this.events = [];
|
||||
this.reloadTimer = null;
|
||||
this.serverErrorCount = 0;
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.eventsReceivedCallback = () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending reload timer
|
||||
*/
|
||||
clearReloadTimer () {
|
||||
if (this.reloadTimer) {
|
||||
clearTimeout(this.reloadTimer);
|
||||
this.reloadTimer = null;
|
||||
}
|
||||
if (auth) {
|
||||
if (auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${auth.pass}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next fetch respecting MagicMirror test mode
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
*/
|
||||
scheduleNextFetch (delay) {
|
||||
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
|
||||
if (process.env.mmTestMode === "true") {
|
||||
return;
|
||||
}
|
||||
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the options object for fetch
|
||||
* @returns {object} Options object containing headers (and agent if needed)
|
||||
*/
|
||||
getRequestOptions () {
|
||||
const headers = { "User-Agent": getUserAgent() };
|
||||
const options = { headers };
|
||||
|
||||
if (this.selfSignedCert) {
|
||||
options.agent = new https.Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
if (this.auth) {
|
||||
if (this.auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${this.auth.pass}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url, { headers: headers, agent: httpsAgent })
|
||||
.then(NodeHelper.checkFetchStatus)
|
||||
.then((response) => response.text())
|
||||
.then((responseData) => {
|
||||
let data = [];
|
||||
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) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
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, error: Error}} Error describing the issue and computed retry delay
|
||||
*/
|
||||
getDelayForResponse (response) {
|
||||
const { status, statusText = "" } = response;
|
||||
let delay = this.reloadInterval;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
|
||||
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
|
||||
} else if (status === 429) {
|
||||
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);
|
||||
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 500) {
|
||||
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
|
||||
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
|
||||
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 400) {
|
||||
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else {
|
||||
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
delay,
|
||||
error: new Error(`HTTP ${status} ${statusText}`.trim())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and processes calendar data
|
||||
*/
|
||||
async fetchCalendar () {
|
||||
this.clearReloadTimer();
|
||||
|
||||
let nextDelay = this.reloadInterval;
|
||||
try {
|
||||
const response = await fetch(this.url, this.getRequestOptions());
|
||||
if (!response.ok) {
|
||||
const { delay, error } = this.getDelayForResponse(response);
|
||||
nextDelay = delay;
|
||||
this.fetchFailedCallback(this, error);
|
||||
} else {
|
||||
this.serverErrorCount = 0;
|
||||
const responseData = await response.text();
|
||||
try {
|
||||
data = ical.parseICS(responseData);
|
||||
Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`);
|
||||
events = CalendarFetcherUtils.filterEvents(data, {
|
||||
excludedEvents,
|
||||
includePastEvents,
|
||||
maximumEntries,
|
||||
maximumNumberOfDays
|
||||
const parsed = ical.parseICS(responseData);
|
||||
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
|
||||
this.events = CalendarFetcherUtils.filterEvents(parsed, {
|
||||
excludedEvents: this.excludedEvents,
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumEntries: this.maximumEntries,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
this.broadcastEvents();
|
||||
} catch (error) {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
|
||||
return;
|
||||
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
this.broadcastEvents();
|
||||
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
|
||||
})
|
||||
.catch((error) => {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
|
||||
});
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - Fetch failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
|
||||
/* public methods */
|
||||
this.scheduleNextFetch(nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate fetchCalendar();
|
||||
* Broadcasts the current events to listeners
|
||||
*/
|
||||
this.startFetch = function () {
|
||||
fetchCalendar();
|
||||
};
|
||||
broadcastEvents () {
|
||||
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
||||
this.eventsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the existing events.
|
||||
* Sets the callback for successful event fetches
|
||||
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
||||
*/
|
||||
this.broadcastEvents = function () {
|
||||
Log.info(`Fetcher: Broadcasting ${events.length} events from ${url}.`);
|
||||
eventsReceivedCallback(this);
|
||||
};
|
||||
onReceive (callback) {
|
||||
this.eventsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the on success callback
|
||||
* @param {eventsReceivedCallback} callback The on success callback.
|
||||
* Sets the callback for fetch failures
|
||||
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
||||
*/
|
||||
this.onReceive = function (callback) {
|
||||
eventsReceivedCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the on error callback
|
||||
* @param {fetchFailedCallback} callback The on error callback.
|
||||
*/
|
||||
this.onError = function (callback) {
|
||||
fetchFailedCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the url of this fetcher.
|
||||
* @returns {string} The url of this fetcher.
|
||||
*/
|
||||
this.url = function () {
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns current available events for this fetcher.
|
||||
* @returns {object[]} The current available events for this fetcher.
|
||||
*/
|
||||
this.events = function () {
|
||||
return events;
|
||||
};
|
||||
};
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CalendarFetcher;
|
||||
|
||||
@@ -26,7 +26,7 @@ Log.log("Create fetcher ...");
|
||||
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
|
||||
|
||||
fetcher.onReceive(function (fetcher) {
|
||||
Log.log(fetcher.events());
|
||||
Log.log(fetcher.events);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ module.exports = NodeHelper.create({
|
||||
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
|
||||
return;
|
||||
}
|
||||
this.fetchers[key].startFetch();
|
||||
this.fetchers[key].fetchCalendar();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ module.exports = NodeHelper.create({
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, error) => {
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
|
||||
let error_type = NodeHelper.checkFetchError(error);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", {
|
||||
id: identifier,
|
||||
@@ -76,7 +76,7 @@ module.exports = NodeHelper.create({
|
||||
fetcher.broadcastEvents();
|
||||
}
|
||||
|
||||
fetcher.startFetch();
|
||||
fetcher.fetchCalendar();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -87,8 +87,8 @@ module.exports = NodeHelper.create({
|
||||
broadcastEvents (fetcher, identifier) {
|
||||
this.sendSocketNotification("CALENDAR_EVENTS", {
|
||||
id: identifier,
|
||||
url: fetcher.url(),
|
||||
events: fetcher.events()
|
||||
url: fetcher.url,
|
||||
events: fetcher.events
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user