Skip to content

Commit df9889b

Browse files
fix(fetch): optimize signals composing logic; (#6582)
1 parent ee208cf commit df9889b

File tree

4 files changed

+98
-46
lines changed

4 files changed

+98
-46
lines changed

lib/adapters/fetch.js

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,13 @@ export default isFetchSupported && (async (config) => {
113113

114114
responseType = responseType ? (responseType + '').toLowerCase() : 'text';
115115

116-
let [composedSignal, stopTimeout] = (signal || cancelToken || timeout) ?
117-
composeSignals([signal, cancelToken], timeout) : [];
116+
let composedSignal = composeSignals([signal, cancelToken && cancelToken.toAbortSignal()], timeout);
118117

119-
let finished, request;
118+
let request;
120119

121-
const onFinish = () => {
122-
!finished && setTimeout(() => {
123-
composedSignal && composedSignal.unsubscribe();
124-
});
125-
126-
finished = true;
127-
}
120+
const unsubscribe = composedSignal && composedSignal.unsubscribe && (() => {
121+
composedSignal.unsubscribe();
122+
});
128123

129124
let requestContentLength;
130125

@@ -161,7 +156,7 @@ export default isFetchSupported && (async (config) => {
161156

162157
// Cloudflare Workers throws when credentials are defined
163158
// see https://github.com/cloudflare/workerd/issues/902
164-
const isCredentialsSupported = "credentials" in Request.prototype;
159+
const isCredentialsSupported = "credentials" in Request.prototype;
165160
request = new Request(url, {
166161
...fetchOptions,
167162
signal: composedSignal,
@@ -176,7 +171,7 @@ export default isFetchSupported && (async (config) => {
176171

177172
const isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response');
178173

179-
if (supportsResponseStream && (onDownloadProgress || isStreamResponse)) {
174+
if (supportsResponseStream && (onDownloadProgress || (isStreamResponse && unsubscribe))) {
180175
const options = {};
181176

182177
['status', 'statusText', 'headers'].forEach(prop => {
@@ -193,7 +188,7 @@ export default isFetchSupported && (async (config) => {
193188
response = new Response(
194189
trackStream(response.body, DEFAULT_CHUNK_SIZE, onProgress, () => {
195190
flush && flush();
196-
isStreamResponse && onFinish();
191+
unsubscribe && unsubscribe();
197192
}, encodeText),
198193
options
199194
);
@@ -203,9 +198,7 @@ export default isFetchSupported && (async (config) => {
203198

204199
let responseData = await resolvers[utils.findKey(resolvers, responseType) || 'text'](response, config);
205200

206-
!isStreamResponse && onFinish();
207-
208-
stopTimeout && stopTimeout();
201+
!isStreamResponse && unsubscribe && unsubscribe();
209202

210203
return await new Promise((resolve, reject) => {
211204
settle(resolve, reject, {
@@ -218,7 +211,7 @@ export default isFetchSupported && (async (config) => {
218211
})
219212
})
220213
} catch (err) {
221-
onFinish();
214+
unsubscribe && unsubscribe();
222215

223216
if (err && err.name === 'TypeError' && /fetch/i.test(err.message)) {
224217
throw Object.assign(

lib/cancel/CancelToken.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ class CancelToken {
102102
}
103103
}
104104

105+
toAbortSignal() {
106+
const controller = new AbortController();
107+
108+
const abort = (err) => {
109+
controller.abort(err);
110+
};
111+
112+
this.subscribe(abort);
113+
114+
controller.signal.unsubscribe = () => this.unsubscribe(abort);
115+
116+
return controller.signal;
117+
}
118+
105119
/**
106120
* Returns an object that contains a new `CancelToken` and a function that, when called,
107121
* cancels the `CancelToken`.

lib/helpers/composeSignals.js

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,48 @@
11
import CanceledError from "../cancel/CanceledError.js";
22
import AxiosError from "../core/AxiosError.js";
3+
import utils from '../utils.js';
34

45
const composeSignals = (signals, timeout) => {
5-
let controller = new AbortController();
6+
const {length} = (signals = signals ? signals.filter(Boolean) : []);
67

7-
let aborted;
8+
if (timeout || length) {
9+
let controller = new AbortController();
810

9-
const onabort = function (cancel) {
10-
if (!aborted) {
11-
aborted = true;
12-
unsubscribe();
13-
const err = cancel instanceof Error ? cancel : this.reason;
14-
controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err));
15-
}
16-
}
11+
let aborted;
1712

18-
let timer = timeout && setTimeout(() => {
19-
onabort(new AxiosError(`timeout ${timeout} of ms exceeded`, AxiosError.ETIMEDOUT))
20-
}, timeout)
13+
const onabort = function (reason) {
14+
if (!aborted) {
15+
aborted = true;
16+
unsubscribe();
17+
const err = reason instanceof Error ? reason : this.reason;
18+
controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err));
19+
}
20+
}
2121

22-
const unsubscribe = () => {
23-
if (signals) {
24-
timer && clearTimeout(timer);
22+
let timer = timeout && setTimeout(() => {
2523
timer = null;
26-
signals.forEach(signal => {
27-
signal &&
28-
(signal.removeEventListener ? signal.removeEventListener('abort', onabort) : signal.unsubscribe(onabort));
29-
});
30-
signals = null;
24+
onabort(new AxiosError(`timeout ${timeout} of ms exceeded`, AxiosError.ETIMEDOUT))
25+
}, timeout)
26+
27+
const unsubscribe = () => {
28+
if (signals) {
29+
timer && clearTimeout(timer);
30+
timer = null;
31+
signals.forEach(signal => {
32+
signal.unsubscribe ? signal.unsubscribe(onabort) : signal.removeEventListener('abort', onabort);
33+
});
34+
signals = null;
35+
}
3136
}
32-
}
3337

34-
signals.forEach((signal) => signal && signal.addEventListener && signal.addEventListener('abort', onabort));
38+
signals.forEach((signal) => signal.addEventListener('abort', onabort));
3539

36-
const {signal} = controller;
40+
const {signal} = controller;
3741

38-
signal.unsubscribe = unsubscribe;
42+
signal.unsubscribe = () => utils.asap(unsubscribe);
3943

40-
return [signal, () => {
41-
timer && clearTimeout(timer);
42-
timer = null;
43-
}];
44+
return signal;
45+
}
4446
}
4547

4648
export default composeSignals;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import assert from 'assert';
2+
import composeSignals from '../../../lib/helpers/composeSignals.js';
3+
4+
describe('helpers::composeSignals', () => {
5+
before(function () {
6+
if (typeof AbortController !== 'function') {
7+
this.skip();
8+
}
9+
});
10+
11+
it('should abort when any of the signals abort', () => {
12+
let called;
13+
14+
const controllerA = new AbortController();
15+
const controllerB = new AbortController();
16+
17+
const signal = composeSignals([controllerA.signal, controllerB.signal]);
18+
19+
signal.addEventListener('abort', () => {
20+
called = true;
21+
});
22+
23+
controllerA.abort(new Error('test'));
24+
25+
assert.ok(called);
26+
});
27+
28+
it('should abort on timeout', async () => {
29+
const signal = composeSignals([], 100);
30+
31+
await new Promise(resolve => {
32+
signal.addEventListener('abort', resolve);
33+
});
34+
35+
assert.match(String(signal.reason), /timeout 100 of ms exceeded/);
36+
});
37+
38+
it('should return undefined if signals and timeout are not provided', async () => {
39+
const signal = composeSignals([]);
40+
41+
assert.strictEqual(signal, undefined);
42+
});
43+
});

0 commit comments

Comments
 (0)