Skip to content

Commit 8c37d58

Browse files
[calendar] refactor: migrate CalendarFetcher to ES6 class and improve error handling
1. Convert CalendarFetcher from legacy constructor function pattern to ES6 class, which simplifies future migration 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.
1 parent 53df20f commit 8c37d58

File tree

5 files changed

+180
-100
lines changed

5 files changed

+180
-100
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ planned for 2026-01-01
3535
- [core] configure cspell to check default modules only and fix typos (#3955)
3636
- [core] refactor: replace `XMLHttpRequest` with `fetch` in `translator.js` (#3950)
3737
- [tests] migrate e2e tests to Playwright (#3950)
38+
- [calendar] refactor: migrate CalendarFetcher to ES6 class and improve error handling (#3958)
3839

3940
### Fixed
4041

js/node_helper.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ NodeHelper.checkFetchError = function (error) {
113113
let error_type = "MODULE_ERROR_UNSPECIFIED";
114114
if (error.code === "EAI_AGAIN") {
115115
error_type = "MODULE_ERROR_NO_CONNECTION";
116-
} else if (error.message === "Unauthorized") {
117-
error_type = "MODULE_ERROR_UNAUTHORIZED";
116+
} else {
117+
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
118+
if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) {
119+
error_type = "MODULE_ERROR_UNAUTHORIZED";
120+
}
118121
}
119122
return error_type;
120123
};
Lines changed: 168 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,207 @@
11
const https = require("node:https");
22
const ical = require("node-ical");
33
const Log = require("logger");
4-
const NodeHelper = require("node_helper");
54
const CalendarFetcherUtils = require("./calendarfetcherutils");
65
const { getUserAgent } = require("#server_functions");
7-
const { scheduleTimer } = require("#module_functions");
6+
7+
const FIFTEEN_MINUTES = 15 * 60 * 1000;
8+
const THIRTY_MINUTES = 30 * 60 * 1000;
9+
const MAX_SERVER_BACKOFF = 3;
810

911
/**
10-
*
11-
* @param {string} url The url of the calendar to fetch
12-
* @param {number} reloadInterval Time in ms the calendar is fetched again
13-
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
14-
* @param {number} maximumEntries The maximum number of events fetched.
15-
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
16-
* @param {object} auth The object containing options for authentication against the calendar.
17-
* @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
18-
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
12+
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
1913
* @class
2014
*/
21-
const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
22-
let reloadTimer = null;
23-
let events = [];
15+
class CalendarFetcher {
2416

25-
let fetchFailedCallback = function () {};
26-
let eventsReceivedCallback = function () {};
17+
/**
18+
* Creates a new CalendarFetcher instance
19+
* @param {string} url - The URL of the calendar to fetch
20+
* @param {number} reloadInterval - Time in ms between fetches
21+
* @param {string[]} excludedEvents - Event titles to exclude
22+
* @param {number} maximumEntries - Maximum number of events to return
23+
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
24+
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
25+
* @param {boolean} includePastEvents - Whether to include past events
26+
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
27+
*/
28+
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
29+
this.url = url;
30+
this.reloadInterval = reloadInterval;
31+
this.excludedEvents = excludedEvents;
32+
this.maximumEntries = maximumEntries;
33+
this.maximumNumberOfDays = maximumNumberOfDays;
34+
this.auth = auth;
35+
this.includePastEvents = includePastEvents;
36+
this.selfSignedCert = selfSignedCert;
37+
38+
this.events = [];
39+
this.reloadTimer = null;
40+
this.serverErrorCount = 0;
41+
this.fetchFailedCallback = () => {};
42+
this.eventsReceivedCallback = () => {};
43+
}
2744

2845
/**
29-
* Initiates calendar fetch.
46+
* Clears any pending reload timer
3047
*/
31-
const fetchCalendar = () => {
32-
clearTimeout(reloadTimer);
33-
reloadTimer = null;
34-
let httpsAgent = null;
35-
let headers = {
36-
"User-Agent": getUserAgent()
37-
};
48+
clearReloadTimer () {
49+
if (this.reloadTimer) {
50+
clearTimeout(this.reloadTimer);
51+
this.reloadTimer = null;
52+
}
53+
}
3854

39-
if (selfSignedCert) {
40-
httpsAgent = new https.Agent({
41-
rejectUnauthorized: false
42-
});
55+
/**
56+
* Schedules the next fetch respecting MagicMirror test mode
57+
* @param {number} delay - Delay in milliseconds
58+
*/
59+
scheduleNextFetch (delay) {
60+
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
61+
if (process.env.mmTestMode === "true") {
62+
return;
4363
}
44-
if (auth) {
45-
if (auth.method === "bearer") {
46-
headers.Authorization = `Bearer ${auth.pass}`;
64+
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
65+
}
66+
67+
/**
68+
* Builds the options object for fetch
69+
* @returns {object} Options object containing headers (and agent if needed)
70+
*/
71+
getRequestOptions () {
72+
const headers = { "User-Agent": getUserAgent() };
73+
const options = { headers };
74+
75+
if (this.selfSignedCert) {
76+
options.agent = new https.Agent({ rejectUnauthorized: false });
77+
}
78+
79+
if (this.auth) {
80+
if (this.auth.method === "bearer") {
81+
headers.Authorization = `Bearer ${this.auth.pass}`;
4782
} else {
48-
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
83+
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
4984
}
5085
}
5186

52-
fetch(url, { headers: headers, agent: httpsAgent })
53-
.then(NodeHelper.checkFetchStatus)
54-
.then((response) => response.text())
55-
.then((responseData) => {
56-
let data = [];
57-
58-
try {
59-
data = ical.parseICS(responseData);
60-
Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`);
61-
events = CalendarFetcherUtils.filterEvents(data, {
62-
excludedEvents,
63-
includePastEvents,
64-
maximumEntries,
65-
maximumNumberOfDays
66-
});
67-
} catch (error) {
68-
fetchFailedCallback(this, error);
69-
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
70-
return;
71-
}
72-
this.broadcastEvents();
73-
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
74-
})
75-
.catch((error) => {
76-
fetchFailedCallback(this, error);
77-
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
78-
});
79-
};
80-
81-
/* public methods */
87+
return options;
88+
}
8289

8390
/**
84-
* Initiate fetchCalendar();
91+
* Parses the Retry-After header value
92+
* @param {string} retryAfter - The Retry-After header value
93+
* @returns {number|null} Milliseconds to wait or null if parsing failed
8594
*/
86-
this.startFetch = function () {
87-
fetchCalendar();
88-
};
95+
parseRetryAfter (retryAfter) {
96+
const seconds = Number(retryAfter);
97+
if (!Number.isNaN(seconds) && seconds >= 0) {
98+
return seconds * 1000;
99+
}
100+
101+
const retryDate = Date.parse(retryAfter);
102+
if (!Number.isNaN(retryDate)) {
103+
return Math.max(0, retryDate - Date.now());
104+
}
105+
106+
return null;
107+
}
89108

90109
/**
91-
* Broadcast the existing events.
110+
* Determines the retry delay for a non-ok response
111+
* @param {Response} response - The fetch Response object
112+
* @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay
92113
*/
93-
this.broadcastEvents = function () {
94-
Log.info(`Fetcher: Broadcasting ${events.length} events from ${url}.`);
95-
eventsReceivedCallback(this);
96-
};
114+
getDelayForResponse (response) {
115+
const { status, statusText = "" } = response;
116+
let delay = this.reloadInterval;
117+
118+
if (status === 401 || status === 403) {
119+
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
120+
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
121+
} else if (status === 429) {
122+
const retryAfter = response.headers.get("retry-after");
123+
const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;
124+
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
125+
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
126+
} else if (status >= 500) {
127+
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
128+
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
129+
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
130+
} else if (status >= 400) {
131+
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
132+
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
133+
} else {
134+
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
135+
}
136+
137+
return {
138+
delay,
139+
error: new Error(`HTTP ${status} ${statusText}`.trim())
140+
};
141+
}
97142

98143
/**
99-
* Sets the on success callback
100-
* @param {eventsReceivedCallback} callback The on success callback.
144+
* Fetches and processes calendar data
101145
*/
102-
this.onReceive = function (callback) {
103-
eventsReceivedCallback = callback;
104-
};
146+
async fetchCalendar () {
147+
this.clearReloadTimer();
148+
149+
let nextDelay = this.reloadInterval;
150+
try {
151+
const response = await fetch(this.url, this.getRequestOptions());
152+
if (!response.ok) {
153+
const { delay, error } = this.getDelayForResponse(response);
154+
nextDelay = delay;
155+
this.fetchFailedCallback(this, error);
156+
} else {
157+
this.serverErrorCount = 0;
158+
const responseData = await response.text();
159+
try {
160+
const parsed = ical.parseICS(responseData);
161+
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
162+
this.events = CalendarFetcherUtils.filterEvents(parsed, {
163+
excludedEvents: this.excludedEvents,
164+
includePastEvents: this.includePastEvents,
165+
maximumEntries: this.maximumEntries,
166+
maximumNumberOfDays: this.maximumNumberOfDays
167+
});
168+
this.broadcastEvents();
169+
} catch (error) {
170+
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
171+
this.fetchFailedCallback(this, error);
172+
}
173+
}
174+
} catch (error) {
175+
Log.error(`${this.url} - Fetch failed: ${error.message}`);
176+
this.fetchFailedCallback(this, error);
177+
}
178+
179+
this.scheduleNextFetch(nextDelay);
180+
}
105181

106182
/**
107-
* Sets the on error callback
108-
* @param {fetchFailedCallback} callback The on error callback.
183+
* Broadcasts the current events to listeners
109184
*/
110-
this.onError = function (callback) {
111-
fetchFailedCallback = callback;
112-
};
185+
broadcastEvents () {
186+
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
187+
this.eventsReceivedCallback(this);
188+
}
113189

114190
/**
115-
* Returns the url of this fetcher.
116-
* @returns {string} The url of this fetcher.
191+
* Sets the callback for successful event fetches
192+
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
117193
*/
118-
this.url = function () {
119-
return url;
120-
};
194+
onReceive (callback) {
195+
this.eventsReceivedCallback = callback;
196+
}
121197

122198
/**
123-
* Returns current available events for this fetcher.
124-
* @returns {object[]} The current available events for this fetcher.
199+
* Sets the callback for fetch failures
200+
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
125201
*/
126-
this.events = function () {
127-
return events;
128-
};
129-
};
202+
onError (callback) {
203+
this.fetchFailedCallback = callback;
204+
}
205+
}
130206

131207
module.exports = CalendarFetcher;

modules/default/calendar/debug.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Log.log("Create fetcher ...");
2626
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
2727

2828
fetcher.onReceive(function (fetcher) {
29-
Log.log(fetcher.events());
29+
Log.log(fetcher.events);
3030
process.exit(0);
3131
});
3232

modules/default/calendar/node_helper.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module.exports = NodeHelper.create({
2020
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
2121
return;
2222
}
23-
this.fetchers[key].startFetch();
23+
this.fetchers[key].fetchCalendar();
2424
}
2525
},
2626

@@ -61,7 +61,7 @@ module.exports = NodeHelper.create({
6161
});
6262

6363
fetcher.onError((fetcher, error) => {
64-
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
64+
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
6565
let error_type = NodeHelper.checkFetchError(error);
6666
this.sendSocketNotification("CALENDAR_ERROR", {
6767
id: identifier,
@@ -76,7 +76,7 @@ module.exports = NodeHelper.create({
7676
fetcher.broadcastEvents();
7777
}
7878

79-
fetcher.startFetch();
79+
fetcher.fetchCalendar();
8080
},
8181

8282
/**
@@ -87,8 +87,8 @@ module.exports = NodeHelper.create({
8787
broadcastEvents (fetcher, identifier) {
8888
this.sendSocketNotification("CALENDAR_EVENTS", {
8989
id: identifier,
90-
url: fetcher.url(),
91-
events: fetcher.events()
90+
url: fetcher.url,
91+
events: fetcher.events
9292
});
9393
}
9494
});

0 commit comments

Comments
 (0)