Skip to content

Commit ef567cd

Browse files
committed
refactor(calendar): implement retryWithBackoff utility for http requests
1 parent 929c31e commit ef567cd

3 files changed

Lines changed: 412 additions & 7 deletions

File tree

src/nodes/events-calendar/EventsCalendarController.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import CalendarItem, {
1313
} from './CalendarItem';
1414
import EventQueue from './EventQueue';
1515
import { shortenString } from './helpers';
16+
import { retryWithBackoff } from './retryWithBackoff';
1617
import SentEventCache from './SentEventCache';
1718
import Timespan from './Timespan';
1819

@@ -149,13 +150,40 @@ export default class EventsCalendarController extends ExposeAsController {
149150
* @param timespan - Time window to fetch events for
150151
*/
151152
async #fetchEvents(timespan: Timespan): Promise<QueuedCalendarEvent[]> {
152-
const rawItems: ICalendarItem[] = await this.homeAssistant.http.get(
153-
`/calendars/${this.node.config.entityId}`,
154-
{
155-
start: timespan.start.toISOString(),
156-
end: timespan.end.toISOString(),
157-
},
158-
);
153+
let rawItems: ICalendarItem[] = [];
154+
155+
try {
156+
rawItems = await retryWithBackoff(
157+
() =>
158+
this.homeAssistant.http.get(
159+
`/calendars/${this.node.config.entityId}`,
160+
{
161+
start: timespan.start.toISOString(),
162+
end: timespan.end.toISOString(),
163+
},
164+
),
165+
{
166+
retries: 5,
167+
baseMs: 1_000,
168+
maxMs: 30_000,
169+
beforeRetry: (attempt, wait) => {
170+
this.status.setFailed(
171+
`Calendar fetch failed (attempt ${attempt + 1}/5), retrying in ${Math.round(
172+
wait / 1000,
173+
)}s`,
174+
);
175+
},
176+
onGiveUp: (err) => {
177+
this.status.setError(
178+
`Calendar fetch failed (max retries): ${err?.message ?? err}`,
179+
);
180+
},
181+
},
182+
);
183+
} catch {
184+
// Give up for this poll cycle
185+
return [];
186+
}
159187
if (!Array.isArray(rawItems)) return [];
160188

161189
const offsetMs = (await this.#getOffsetMs()) ?? 0;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
export interface RetryOptions {
2+
retries: number; // max retries (not including the initial try)
3+
baseMs: number; // initial delay
4+
maxMs: number; // max delay cap
5+
factor?: number; // backoff factor (default 2)
6+
isRetriable?: (err: any) => boolean;
7+
beforeRetry?: (attempt: number, delayMs: number, err: any) => void;
8+
onGiveUp?: (err: any) => void;
9+
}
10+
11+
/**
12+
* Determines if an error is considered retriable by default.
13+
*
14+
* This function checks the error object for specific network-related error codes
15+
* or HTTP status codes that indicate a temporary issue, such as connection
16+
* problems or server unavailability.
17+
*
18+
* @param err - The error object to evaluate. It may contain properties such as
19+
* `code` (for network errors) or `response.status` (for HTTP status codes).
20+
*
21+
* @returns `true` if the error is deemed retriable, `false` otherwise.
22+
*
23+
* Retriable conditions:
24+
* - Network error codes: `ECONNREFUSED`, `ETIMEDOUT`, `ECONNRESET`, `ENETUNREACH`, `EAI_AGAIN`.
25+
* - HTTP status codes: `429` (Too Many Requests), `500` (Internal Server Error),
26+
* `502` (Bad Gateway), `503` (Service Unavailable), `504` (Gateway Timeout).
27+
*/
28+
export function isDefaultRetriable(err: any): boolean {
29+
const code = err?.code as string | undefined;
30+
if (
31+
code === 'ECONNREFUSED' ||
32+
code === 'ETIMEDOUT' ||
33+
code === 'ECONNRESET' ||
34+
code === 'ENETUNREACH' ||
35+
code === 'EAI_AGAIN'
36+
) {
37+
return true;
38+
}
39+
const status = err?.response?.status as number | undefined;
40+
if (status && [429, 500, 502, 503, 504].includes(status)) return true;
41+
return false;
42+
}
43+
44+
/**
45+
* Retries a given asynchronous function with exponential backoff.
46+
*
47+
* @template T The type of the value returned by the function.
48+
* @param fn The asynchronous function to be retried. It should return a promise.
49+
* @param opts Configuration options for the retry mechanism.
50+
* @param opts.baseMs The initial delay in milliseconds before the first retry.
51+
* @param opts.factor The multiplier for the delay between retries (default is 2).
52+
* @param opts.maxMs The maximum delay in milliseconds between retries.
53+
* @param opts.retries The maximum number of retry attempts.
54+
* @param opts.isRetriable Optional function to determine if an error is retriable.
55+
* If not provided, a default retriable check will be used.
56+
* @param opts.beforeRetry Optional callback invoked before each retry attempt.
57+
* Receives the current attempt number, the delay before the next attempt, and the error.
58+
* @param opts.onGiveUp Optional callback invoked when retries are exhausted or the error is non-retriable.
59+
* Receives the error that caused the failure.
60+
* @returns A promise that resolves with the result of the function or rejects with the last error.
61+
* @throws The last error encountered if the retries are exhausted or the error is non-retriable.
62+
*/
63+
export async function retryWithBackoff<T>(
64+
fn: () => Promise<T>,
65+
opts: RetryOptions,
66+
): Promise<T> {
67+
const factor = opts.factor ?? 2;
68+
let attempt = 0;
69+
let delay = opts.baseMs;
70+
71+
// attempt counts retries; total tries = retries + 1
72+
while (true) {
73+
try {
74+
return await fn();
75+
} catch (err) {
76+
const retriable = opts.isRetriable
77+
? opts.isRetriable(err)
78+
: isDefaultRetriable(err);
79+
const isLast = attempt >= opts.retries;
80+
81+
if (!retriable || isLast) {
82+
// Guard onGiveUp to avoid throwing from user callback
83+
try {
84+
opts.onGiveUp?.(err);
85+
} catch {
86+
// swallow callback error to preserve original rejection
87+
}
88+
throw err;
89+
}
90+
91+
const wait = Math.min(delay, opts.maxMs);
92+
93+
// Guard beforeRetry to avoid unhandled exceptions
94+
try {
95+
opts.beforeRetry?.(attempt, wait, err);
96+
} catch {
97+
// swallow callback error, proceed with retry
98+
}
99+
100+
await new Promise((resolve) => setTimeout(resolve, wait));
101+
delay = Math.min(delay * factor, opts.maxMs);
102+
attempt += 1;
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)