mirror of
https://github.com/MichMich/MagicMirror.git
synced 2025-12-12 09:52:37 +00:00
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.
208 lines
6.7 KiB
JavaScript
208 lines
6.7 KiB
JavaScript
const https = require("node:https");
|
|
const ical = require("node-ical");
|
|
const Log = require("logger");
|
|
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
|
const { getUserAgent } = require("#server_functions");
|
|
|
|
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
|
const THIRTY_MINUTES = 30 * 60 * 1000;
|
|
const MAX_SERVER_BACKOFF = 3;
|
|
|
|
/**
|
|
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
|
|
* @class
|
|
*/
|
|
class CalendarFetcher {
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(`${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) {
|
|
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 {
|
|
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) {
|
|
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
|
this.fetchFailedCallback(this, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
Log.error(`${this.url} - Fetch failed: ${error.message}`);
|
|
this.fetchFailedCallback(this, error);
|
|
}
|
|
|
|
this.scheduleNextFetch(nextDelay);
|
|
}
|
|
|
|
/**
|
|
* Broadcasts the current events to listeners
|
|
*/
|
|
broadcastEvents () {
|
|
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
|
this.eventsReceivedCallback(this);
|
|
}
|
|
|
|
/**
|
|
* Sets the callback for successful event fetches
|
|
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
|
*/
|
|
onReceive (callback) {
|
|
this.eventsReceivedCallback = callback;
|
|
}
|
|
|
|
/**
|
|
* Sets the callback for fetch failures
|
|
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
|
*/
|
|
onError (callback) {
|
|
this.fetchFailedCallback = callback;
|
|
}
|
|
}
|
|
|
|
module.exports = CalendarFetcher;
|