Skip to content

Commit 7f8d687

Browse files
engineer-bot: address @mention on PR #600
Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
1 parent 5c6d5c5 commit 7f8d687

5 files changed

Lines changed: 128 additions & 15 deletions

File tree

‎lib/connection/connections/HttpRetryPolicy.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@ import { HttpTransactionDetails } from '../contracts/IConnectionProvider';
33
import IClientContext from '../../contracts/IClientContext';
44
import RetryError, { RetryErrorCode } from '../../errors/RetryError';
55

6-
function delay(milliseconds: number): Promise<void> {
7-
return new Promise<void>((resolve) => {
8-
setTimeout(() => resolve(), milliseconds);
6+
// Sleeps for `milliseconds`; aborting `signal` clears the timer and rejects with
7+
// `signal.reason`, so a cancelled backoff does not keep the process alive.
8+
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
9+
return new Promise<void>((resolve, reject) => {
10+
if (signal?.aborted) {
11+
reject(signal.reason);
12+
return;
13+
}
14+
let timer: NodeJS.Timeout | undefined;
15+
const onAbort = () => {
16+
clearTimeout(timer);
17+
reject(signal?.reason);
18+
};
19+
timer = setTimeout(() => {
20+
signal?.removeEventListener('abort', onAbort);
21+
resolve();
22+
}, milliseconds);
23+
signal?.addEventListener('abort', onAbort, { once: true });
924
});
1025
}
1126

@@ -52,7 +67,10 @@ export default class HttpRetryPolicy implements IRetryPolicy<HttpTransactionDeta
5267
return { shouldRetry: false };
5368
}
5469

55-
public async invokeWithRetry(operation: RetryableOperation<HttpTransactionDetails>): Promise<HttpTransactionDetails> {
70+
public async invokeWithRetry(
71+
operation: RetryableOperation<HttpTransactionDetails>,
72+
signal?: AbortSignal,
73+
): Promise<HttpTransactionDetails> {
5674
for (;;) {
5775
// Capture either the resolved response or the thrown error so the
5876
// retry-decision logic below can flow without an early `continue` and
@@ -73,7 +91,7 @@ export default class HttpRetryPolicy implements IRetryPolicy<HttpTransactionDeta
7391
return outcome.details;
7492
}
7593
// eslint-disable-next-line no-await-in-loop
76-
await delay(status.retryAfter);
94+
await delay(status.retryAfter, signal);
7795
} else {
7896
// The operation threw before producing a response. This is typically a
7997
// transient network failure (stale keep-alive socket reset by a load
@@ -91,7 +109,7 @@ export default class HttpRetryPolicy implements IRetryPolicy<HttpTransactionDeta
91109
throw outcome.error;
92110
}
93111
// eslint-disable-next-line no-await-in-loop
94-
await delay(status.retryAfter);
112+
await delay(status.retryAfter, signal);
95113
}
96114
}
97115
}

‎lib/connection/contracts/IRetryPolicy.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ export type RetryableOperation<R> = () => Promise<R>;
1212
export default interface IRetryPolicy<R> {
1313
shouldRetry(details: R): Promise<ShouldRetryResult>;
1414

15-
invokeWithRetry(operation: RetryableOperation<R>): Promise<R>;
15+
// `signal` cancels a pending backoff sleep: aborting it rejects with `signal.reason`
16+
// instead of waiting for the sleep to finish (the operation itself must honour the signal).
17+
invokeWithRetry(operation: RetryableOperation<R>, signal?: AbortSignal): Promise<R>;
1618
}

‎lib/result/CloudFetchResultHandler.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,11 @@ export default class CloudFetchResultHandler implements IResultsProvider<ArrowBa
159159
// The retry policy checks `retriesTimeout` only between attempts, so a stalled attempt
160160
// (headers or body never arrive) would block forever. Enforce it as a deadline over the
161161
// whole download - every attempt plus the backoff between them - and abort the in-flight
162-
// request when it expires (#520). Non-positive values keep the deadline off.
162+
// request and any pending backoff sleep when it expires (#520). Non-positive values keep
163+
// the deadline off.
163164
const deadlineMs = retriesTimeout > 0 ? Math.min(retriesTimeout, MAX_TIMER_DELAY_MS) : undefined;
164-
let controller: AbortController | undefined;
165+
// Aborted only once, by the deadline, so a single controller serves every attempt and backoff.
166+
const controller = deadlineMs === undefined ? undefined : new AbortController();
165167
let timeoutError: RetryError | undefined;
166168
let lastFailure: unknown;
167169
let timer: NodeJS.Timeout | undefined;
@@ -181,7 +183,6 @@ export default class CloudFetchResultHandler implements IResultsProvider<ArrowBa
181183
throw timeoutError;
182184
}
183185
downloaded = undefined;
184-
controller = deadlineMs === undefined ? undefined : new AbortController();
185186
const request = new Request(url, {
186187
...requestConfig,
187188
signal: controller?.signal as unknown as RequestInit['signal'],
@@ -201,15 +202,16 @@ export default class CloudFetchResultHandler implements IResultsProvider<ArrowBa
201202
lastFailure = error;
202203
throw error;
203204
}
204-
});
205+
}, controller?.signal);
205206

206207
if (deadlineMs === undefined) {
207208
result = await download;
208209
} else {
209210
const deadline = new Promise<never>((_, reject) => {
210211
timer = setTimeout(() => {
211212
timeoutError = new RetryError(RetryErrorCode.TimeoutExceeded, lastFailure);
212-
controller?.abort();
213+
// Aborts the in-flight request, and rejects a pending backoff sleep with `timeoutError`
214+
controller?.abort(timeoutError);
213215
reject(timeoutError);
214216
}, deadlineMs);
215217
});

‎tests/unit/connection/connections/HttpRetryPolicy.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,5 +400,78 @@ describe('HttpRetryPolicy', () => {
400400
expect(operation.callCount).to.equal(clientConfig.retryMaxAttempts);
401401
}
402402
});
403+
404+
it('should cancel a pending backoff sleep when the signal is aborted', async () => {
405+
const context = new ClientContextStub({
406+
retryDelayMin: 10000,
407+
retryDelayMax: 60000,
408+
retryMaxAttempts: 20,
409+
});
410+
const policy = new HttpRetryPolicy(context);
411+
const operation = sinon.stub().resolves({
412+
request: new Request('http://localhost'),
413+
response: new Response(undefined, { status: 503 }),
414+
});
415+
const controller = new AbortController();
416+
const reason = new Error('deadline');
417+
const clearTimeoutSpy = sinon.spy(global, 'clearTimeout');
418+
const setTimeoutSpy = sinon.spy(global, 'setTimeout');
419+
420+
try {
421+
const invocation = policy.invokeWithRetry(operation, controller.signal);
422+
// Let the first attempt complete and the 20s backoff sleep start
423+
await new Promise((resolve) => {
424+
setImmediate(resolve);
425+
});
426+
const backoffCall = setTimeoutSpy.getCalls().find((call) => call.args[1] === 20000);
427+
expect(backoffCall).to.not.be.undefined;
428+
429+
controller.abort(reason);
430+
431+
try {
432+
await invocation;
433+
expect.fail('It should reject with the abort reason');
434+
} catch (error) {
435+
if (error instanceof AssertionError) {
436+
throw error;
437+
}
438+
expect(error).to.equal(reason);
439+
}
440+
expect(clearTimeoutSpy.calledWith(backoffCall!.returnValue)).to.be.true;
441+
expect(operation.callCount).to.equal(1);
442+
} finally {
443+
setTimeoutSpy.restore();
444+
clearTimeoutSpy.restore();
445+
}
446+
});
447+
448+
it('should not start another attempt when the signal is already aborted before the backoff', async () => {
449+
const context = new ClientContextStub({
450+
retryDelayMin: 10000,
451+
retryDelayMax: 60000,
452+
retryMaxAttempts: 20,
453+
});
454+
const policy = new HttpRetryPolicy(context);
455+
const controller = new AbortController();
456+
const reason = new Error('deadline');
457+
const operation = sinon.stub().callsFake(async () => {
458+
controller.abort(reason);
459+
return {
460+
request: new Request('http://localhost'),
461+
response: new Response(undefined, { status: 503 }),
462+
};
463+
});
464+
465+
try {
466+
await policy.invokeWithRetry(operation, controller.signal);
467+
expect.fail('It should reject with the abort reason');
468+
} catch (error) {
469+
if (error instanceof AssertionError) {
470+
throw error;
471+
}
472+
expect(error).to.equal(reason);
473+
}
474+
expect(operation.callCount).to.equal(1);
475+
});
403476
});
404477
});

‎tests/unit/result/CloudFetchResultHandler.test.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -834,8 +834,26 @@ describe('CloudFetchResultHandler', () => {
834834
});
835835
// The first backoff (2 * retryDelayMin = 2s) alone outlasts the 300ms budget
836836
const handler = createHandler(url, { retriesTimeout: 300, retryDelayMin: 1000, retryMaxAttempts: 100 });
837+
const setTimeoutSpy = sinon.spy(global, 'setTimeout');
838+
const clearTimeoutSpy = sinon.spy(global, 'clearTimeout');
837839

838-
const { error, elapsedMs } = await fetchWithin(handler, 1500);
840+
let outcome: Awaited<ReturnType<typeof fetchWithin>>;
841+
let backoffTimers: Array<NodeJS.Timeout>;
842+
try {
843+
outcome = await fetchWithin(handler, 1500);
844+
backoffTimers = setTimeoutSpy
845+
.getCalls()
846+
.filter((call) => call.args[1] === 2000)
847+
.map((call) => call.returnValue);
848+
} finally {
849+
setTimeoutSpy.restore();
850+
clearTimeoutSpy.restore();
851+
}
852+
const { error, elapsedMs } = outcome;
853+
854+
// The deadline cancels the pending backoff sleep instead of leaving its timer to keep Node alive
855+
expect(backoffTimers).to.have.length(1);
856+
expect(clearTimeoutSpy.calledWith(backoffTimers[0])).to.be.true;
839857

840858
expect(error).to.be.instanceOf(RetryError);
841859
expect(error.errorCode).to.be.equal(RetryErrorCode.TimeoutExceeded);
@@ -845,9 +863,9 @@ describe('CloudFetchResultHandler', () => {
845863
expect(elapsedMs).to.be.lt(1000);
846864
expect(hits).to.be.equal(1);
847865

848-
// Once the backoff sleep ends, the retry loop must stop instead of sending another request
866+
// The cancelled backoff ends the retry loop instead of sending another request
849867
await new Promise((resolve) => {
850-
setTimeout(resolve, 2200);
868+
setTimeout(resolve, 100);
851869
});
852870
expect(hits).to.be.equal(1);
853871
});

0 commit comments

Comments
 (0)