Skip to content

Commit 2536dfc

Browse files
woai3cvasilev-alex
andauthored
feat(client-core): Add signal option to support abort fetch (#9539)
* feat(client-core): add signal option to support abort fetch * test(client-core): add test cases for signal * test(client-core): merge two HttpTransport.test.js files * chore: change the license to MIT Co-authored-by: Alex Vasilev <[email protected]> --------- Co-authored-by: Alex Vasilev <[email protected]>
1 parent 83339a2 commit 2536dfc

File tree

6 files changed

+489
-8
lines changed

6 files changed

+489
-8
lines changed

docs/pages/product/apis-integrations/javascript-sdk/reference/cubejs-client-core.mdx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,27 @@ const cubeApi = cube(
2626
);
2727
```
2828

29+
If you need to set up cancellation for all requests made by this API instance:
30+
31+
```js
32+
import cube from '@cubejs-client/core';
33+
34+
// Create a controller for managing request cancellation
35+
const controller = new AbortController();
36+
const { signal } = controller;
37+
38+
const cubeApi = cube(
39+
'CUBE-API-TOKEN',
40+
{
41+
apiUrl: 'http://localhost:4000/cubejs-api/v1',
42+
signal: signal
43+
}
44+
);
45+
46+
// Later when you need to cancel all pending requests:
47+
// controller.abort();
48+
```
49+
2950
**Parameters:**
3051

3152
Name | Type | Description |
@@ -87,6 +108,41 @@ const context = document.getElementById('myChart');
87108
new Chart(context, chartjsConfig(resultSet));
88109
```
89110

111+
You can also use AbortController to cancel a request:
112+
113+
```js
114+
import cube from '@cubejs-client/core';
115+
116+
const cubeApi = cube('CUBE_TOKEN');
117+
118+
// Create an AbortController instance
119+
const controller = new AbortController();
120+
const { signal } = controller;
121+
122+
try {
123+
// Pass the signal to the load method
124+
const resultSetPromise = cubeApi.load(
125+
{
126+
measures: ['Orders.count'],
127+
dimensions: ['Orders.status']
128+
},
129+
{ signal }
130+
);
131+
132+
// To cancel the request at any time:
133+
// controller.abort();
134+
135+
const resultSet = await resultSetPromise;
136+
// Process the result
137+
} catch (error) {
138+
if (error.name === 'AbortError') {
139+
console.log('Request was cancelled');
140+
} else {
141+
console.error('Error loading data:', error);
142+
}
143+
}
144+
```
145+
90146
**Parameters:**
91147

92148
Name | Type | Description |
@@ -728,6 +784,7 @@ headers? | Record‹string, string› | - |
728784
parseDateMeasures? | boolean | - |
729785
pollInterval? | number | - |
730786
transport? | [ITransport](#i-transport) | Transport implementation to use. [HttpTransport](#http-transport) will be used by default. |
787+
signal? | AbortSignal | AbortSignal to cancel requests |
731788

732789
### `DateRange`
733790

@@ -757,6 +814,7 @@ Name | Type | Optional? | Description |
757814
`mutexObj` | `Object` | ✅ Yes | Object to store MUTEX |
758815
`progressCallback` | | ✅ Yes | |
759816
`subscribe` | `boolean` | ✅ Yes | Pass `true` to use continuous fetch behavior. |
817+
`signal` | `AbortSignal` | ✅ Yes | AbortSignal to cancel the request. This allows you to manually abort requests using AbortController. |
760818

761819
### `LoadResponse`
762820

@@ -1035,6 +1093,7 @@ apiUrl | string | path to `/cubejs-api/v1` |
10351093
authorization | string | [jwt auth token][ref-security] |
10361094
credentials? | "omit" &#124; "same-origin" &#124; "include" | - |
10371095
headers? | Record‹string, string› | custom headers |
1096+
signal? | AbortSignal | AbortSignal to cancel requests |
10381097

10391098
### `UnaryFilter`
10401099

packages/cubejs-client-core/index.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ declare module '@cubejs-client/core' {
2727
* Fetch timeout in milliseconds. Would be passed as AbortSignal.timeout()
2828
*/
2929
fetchTimeout?: number;
30+
/**
31+
* AbortSignal to cancel requests
32+
*/
33+
signal?: AbortSignal;
3034
};
3135

3236
export interface ITransportResponse<R> {
@@ -64,6 +68,10 @@ declare module '@cubejs-client/core' {
6468
* @hidden
6569
*/
6670
protected credentials: TransportOptions['credentials'];
71+
/**
72+
* @hidden
73+
*/
74+
protected signal?: TransportOptions['signal'];
6775

6876
constructor(options: TransportOptions);
6977

@@ -89,6 +97,10 @@ declare module '@cubejs-client/core' {
8997
* How many network errors would be retried before returning to users. Default to 0.
9098
*/
9199
networkErrorRetries?: number;
100+
/**
101+
* AbortSignal to cancel requests
102+
*/
103+
signal?: AbortSignal;
92104
};
93105

94106
export type LoadMethodOptions = {
@@ -116,6 +128,10 @@ declare module '@cubejs-client/core' {
116128
* Function that receives `ProgressResult` on each `Continue wait` message.
117129
*/
118130
progressCallback?(result: ProgressResult): void;
131+
/**
132+
* AbortSignal to cancel the request
133+
*/
134+
signal?: AbortSignal;
119135
};
120136

121137
export type LoadMethodCallback<T> = (error: Error | null, resultSet: T) => void;

packages/cubejs-client-core/src/HttpTransport.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import fetch from 'cross-fetch';
22
import 'url-search-params-polyfill';
33

44
class HttpTransport {
5-
constructor({ authorization, apiUrl, method, headers = {}, credentials, fetchTimeout }) {
5+
constructor({ authorization, apiUrl, method, headers = {}, credentials, fetchTimeout, signal }) {
66
this.authorization = authorization;
77
this.apiUrl = apiUrl;
88
this.method = method;
99
this.headers = headers;
1010
this.credentials = credentials;
1111
this.fetchTimeout = fetchTimeout;
12+
this.signal = signal;
1213
}
1314

14-
request(method, { baseRequestId, ...params }) {
15+
request(method, { baseRequestId, signal, ...params }) {
1516
let spanCounter = 1;
1617
const searchParams = new URLSearchParams(
1718
params && Object.keys(params)
@@ -38,7 +39,7 @@ class HttpTransport {
3839
},
3940
credentials: this.credentials,
4041
body: requestMethod === 'POST' ? JSON.stringify(params) : null,
41-
signal: this.fetchTimeout ? AbortSignal.timeout(this.fetchTimeout) : undefined,
42+
signal: signal || this.signal || (this.fetchTimeout ? AbortSignal.timeout(this.fetchTimeout) : undefined),
4243
});
4344

4445
return {

packages/cubejs-client-core/src/HttpTransport.test.js

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable import/first */
22
/* eslint-disable import/newline-after-import */
3-
/* globals describe,test,expect,jest,afterEach,beforeAll */
3+
/* globals describe,test,expect,jest,afterEach,beforeAll,beforeEach */
44
import '@babel/runtime/regenerator';
55
jest.mock('cross-fetch');
66
import fetch from 'cross-fetch';
@@ -114,4 +114,178 @@ describe('HttpTransport', () => {
114114
body: largeQueryJson
115115
});
116116
});
117+
118+
// Signal tests from src/tests/HttpTransport.test.js
119+
describe('Signal functionality', () => {
120+
beforeEach(() => {
121+
fetch.mockClear();
122+
// Default mock implementation for signal tests
123+
fetch.mockImplementation(() => Promise.resolve({
124+
json: () => Promise.resolve({ data: 'test data' }),
125+
ok: true,
126+
status: 200
127+
}));
128+
});
129+
130+
test('should pass the signal to fetch when provided in constructor', async () => {
131+
const controller = new AbortController();
132+
const { signal } = controller;
133+
134+
const transport = new HttpTransport({
135+
authorization: 'token',
136+
apiUrl: 'http://localhost:4000/cubejs-api/v1',
137+
signal
138+
});
139+
140+
const request = transport.request('load', { query: { measures: ['Orders.count'] } });
141+
142+
// Start the request
143+
const promise = request.subscribe((result) => result);
144+
145+
// Wait for fetch to be called
146+
await Promise.resolve();
147+
148+
// Ensure fetch was called with the signal
149+
expect(fetch).toHaveBeenCalledTimes(1);
150+
expect(fetch.mock.calls[0][1].signal).toBe(signal);
151+
152+
await promise;
153+
});
154+
155+
test('should pass the signal to fetch when provided in request method', async () => {
156+
const controller = new AbortController();
157+
const { signal } = controller;
158+
159+
const transport = new HttpTransport({
160+
authorization: 'token',
161+
apiUrl: 'http://localhost:4000/cubejs-api/v1'
162+
});
163+
164+
const request = transport.request('load', {
165+
query: { measures: ['Orders.count'] },
166+
signal
167+
});
168+
169+
// Start the request
170+
const promise = request.subscribe((result) => result);
171+
172+
// Wait for fetch to be called
173+
await Promise.resolve();
174+
175+
// Ensure fetch was called with the signal
176+
expect(fetch).toHaveBeenCalledTimes(1);
177+
expect(fetch.mock.calls[0][1].signal).toBe(signal);
178+
179+
await promise;
180+
});
181+
182+
test('should prioritize request signal over constructor signal', async () => {
183+
const controller1 = new AbortController();
184+
const controller2 = new AbortController();
185+
186+
const transport = new HttpTransport({
187+
authorization: 'token',
188+
apiUrl: 'http://localhost:4000/cubejs-api/v1',
189+
signal: controller1.signal
190+
});
191+
192+
const request = transport.request('load', {
193+
query: { measures: ['Orders.count'] },
194+
signal: controller2.signal
195+
});
196+
197+
// Start the request
198+
const promise = request.subscribe((result) => result);
199+
200+
// Wait for fetch to be called
201+
await Promise.resolve();
202+
203+
// Ensure fetch was called with the request signal, not the constructor signal
204+
expect(fetch).toHaveBeenCalledTimes(1);
205+
expect(fetch.mock.calls[0][1].signal).toBe(controller2.signal);
206+
expect(fetch.mock.calls[0][1].signal).not.toBe(controller1.signal);
207+
208+
await promise;
209+
});
210+
211+
test('should create AbortSignal.timeout from fetchTimeout if signal not provided', async () => {
212+
// Mock AbortSignal.timeout
213+
const originalTimeout = AbortSignal.timeout;
214+
const mockTimeoutSignal = {};
215+
AbortSignal.timeout = jest.fn().mockReturnValue(mockTimeoutSignal);
216+
217+
const transport = new HttpTransport({
218+
authorization: 'token',
219+
apiUrl: 'http://localhost:4000/cubejs-api/v1',
220+
fetchTimeout: 5000
221+
});
222+
223+
const request = transport.request('load', {
224+
query: { measures: ['Orders.count'] }
225+
});
226+
227+
// Start the request
228+
const promise = request.subscribe((result) => result);
229+
230+
// Wait for fetch to be called
231+
await Promise.resolve();
232+
233+
// Ensure fetch was called with the timeout signal
234+
expect(fetch).toHaveBeenCalledTimes(1);
235+
expect(fetch.mock.calls[0][1].signal).toBe(mockTimeoutSignal);
236+
expect(AbortSignal.timeout).toHaveBeenCalledWith(5000);
237+
238+
// Restore original implementation
239+
AbortSignal.timeout = originalTimeout;
240+
241+
await promise;
242+
});
243+
244+
test('should handle request abortion', async () => {
245+
// Create a mock Promise and resolver function to control Promise completion
246+
let resolveFetch;
247+
const fetchPromise = new Promise(resolve => {
248+
resolveFetch = resolve;
249+
});
250+
251+
// Mock fetch to return our controlled Promise
252+
fetch.mockImplementationOnce(() => fetchPromise);
253+
254+
const controller = new AbortController();
255+
const { signal } = controller;
256+
257+
const transport = new HttpTransport({
258+
authorization: 'token',
259+
apiUrl: 'http://localhost:4000/cubejs-api/v1'
260+
});
261+
262+
const request = transport.request('load', {
263+
query: { measures: ['Orders.count'] },
264+
signal
265+
});
266+
267+
// Start the request but don't wait for it to complete
268+
const requestPromise = request.subscribe((result) => result);
269+
270+
// Wait for fetch to be called
271+
await Promise.resolve();
272+
273+
// Ensure fetch was called with the signal
274+
expect(fetch).toHaveBeenCalledTimes(1);
275+
expect(fetch.mock.calls[0][1].signal).toBe(signal);
276+
277+
// Abort the request
278+
controller.abort();
279+
280+
// Resolve the fetch Promise, simulating request completion
281+
resolveFetch({
282+
json: () => Promise.resolve({ data: 'aborted data' }),
283+
ok: true,
284+
status: 200
285+
});
286+
287+
// Wait for the request Promise to complete
288+
await requestPromise;
289+
}, 10000); // Set 10-second timeout
290+
});
117291
});

0 commit comments

Comments
 (0)