feat(utils): scheduleAction reject action gracefully when aborted

This commit is contained in:
Szymon Lesisz
2025-08-27 17:06:57 +02:00
committed by Marek Polák
parent 1735721be4
commit 55ba457ac4
2 changed files with 47 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ export type ScheduleActionParams = {
| number // How many attempts before failure (default = one, or infinite when deadline is set)
| readonly AttemptParams[]; // Array of timeouts and gaps for every attempt (length = attempt count)
signal?: AbortSignal;
graceful?: boolean; // Abort signalling will not throw immediately but let the action handle it instead (default = false)
attemptFailureHandler?: (error: Error) => Error | void; // break attemptLoop if `Error` is set
} & AttemptParams; // Ignored when attempts is AttemptParams[]
@@ -153,13 +154,28 @@ export const scheduleAction = async <T>(
const getParams = isArray(attempts)
? (attempt: number) => attempts[attempt]
: () => ({ timeout, gap });
const errorDeadline = new ScheduleActionDeadlineError();
const errorTimeout = new ScheduleActionTimeoutError();
const graceful = params.graceful && signal;
const actionAborter = new AbortController();
if (graceful) {
if (signal.aborted) {
actionAborter.abort();
} else {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
clear.removeEventListener('abort', onAbort);
actionAborter.abort();
};
signal.addEventListener('abort', onAbort);
clear.addEventListener('abort', onAbort);
}
}
try {
return await Promise.race([
rejectWhenAborted(signal, clear),
...(graceful ? [] : [rejectWhenAborted(signal, clear)]),
...maybeRejectAfterMs(deadlineMs, errorDeadline, clear),
resolveAfterMs(delay, clear).then(() =>
attemptLoop(
@@ -176,7 +192,7 @@ export const scheduleAction = async <T>(
? Promise.reject(errorHandlerResult)
: resolveAfterMs(getParams(attempt).gap ?? 0, clear);
},
clear,
graceful ? actionAborter.signal : clear,
),
),
]);

View File

@@ -239,6 +239,34 @@ describe('scheduleAction', () => {
expect(checkListeners()).toBeUndefined();
});
it("don't reject after abort (graceful: true)", async () => {
let ctrl = new AbortController();
const action = (sig?: AbortSignal) =>
new Promise(resolve => {
sig?.addEventListener('abort', () => {
setTimeout(() => resolve({ success: false }), 1000);
});
});
let resultPromise = scheduleAction(action, {
signal: ctrl.signal,
});
new Promise(resolve => setTimeout(resolve, 1));
ctrl.abort();
await expect(() => resultPromise).rejects.toThrow(ERR_SIGNAL);
ctrl = new AbortController();
resultPromise = scheduleAction(action, {
signal: ctrl.signal,
graceful: true,
timeout: 2000,
});
await new Promise(resolve => setTimeout(resolve, 1));
ctrl.abort();
const result = await resultPromise;
expect(result).toEqual({ success: false });
});
it('variable timeouts', async () => {
const TIMEOUTS = [50, 150, 100];
const MARGIN = 10;