diff --git a/packages/web-api/src/WebClient.spec.ts b/packages/web-api/src/WebClient.spec.ts index 15dcb3fbe..fa3879279 100644 --- a/packages/web-api/src/WebClient.spec.ts +++ b/packages/web-api/src/WebClient.spec.ts @@ -1899,6 +1899,62 @@ describe('WebClient', () => { } }); }); + + describe('accepts an AbortSignal to cancel requests', () => { + let scope: nock.Scope; + beforeEach(() => { + scope = nock('https://slack.com').post(/api/).delay(50).reply(200, { ok: true }); + }); + + it('cancels the request when the signal is aborted', async () => { + const client = new WebClient(token); + const controller = new AbortController(); + const { signal } = controller; + + setTimeout(() => { + controller.abort(); + }, 1); + + try { + await client.conversations.list({}, { signal }); + assert.fail('Expected error to be thrown'); + } catch (error) { + scope.done(); + } + }); + + it('completes the request when the signal is not aborted', async () => { + const client = new WebClient(token); + const controller = new AbortController(); + const { signal } = controller; + + try { + const response = await client.conversations.list({}, { signal }); + assert.equal(response.ok, true); + scope.done(); + } catch (error) { + assert.fail(`Did not expect an error to be thrown: ${(error as Error).message}`); + } + }); + + it('uses the AbortSignal reason', async () => { + const client = new WebClient(token); + const controller = new AbortController(); + const { signal } = controller; + const abortReason = new Error('Abort reason'); + setTimeout(() => { + controller.abort(abortReason); + }, 1); + + try { + await client.conversations.list({}, { signal }); + assert.fail('Expected error to be thrown'); + } catch (error) { + assert.equal(error, abortReason); + scope.done(); + } + }); + }); }); // Helpers diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 916f52233..0e9538ab4 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -344,7 +344,11 @@ export class WebClient extends Methods { * @param method - the Web API method to call {@link https://docs.slack.dev/reference/methods} * @param options - options */ - public async apiCall(method: string, options: Record = {}): Promise { + public async apiCall( + method: string, + options: Record = {}, + config?: { signal?: AbortSignal }, + ): Promise { this.logger.debug(`apiCall('${method}') start`); warnDeprecations(method, this.logger); @@ -370,6 +374,7 @@ export class WebClient extends Methods { ...options, }, headers, + config, ); const result = await this.buildResult(response); this.logger.debug(`http request result: ${JSON.stringify(result)}`); @@ -677,6 +682,7 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, + options?: { signal?: AbortSignal }, ): Promise { // TODO: better input types - remove any const task = () => @@ -685,6 +691,7 @@ export class WebClient extends Methods { // biome-ignore lint/suspicious/noExplicitAny: TODO: type this const config: any = { headers, + signal: options?.signal, ...this.tlsConfig, }; // admin.analytics.getFile returns a binary response @@ -762,6 +769,9 @@ export class WebClient extends Methods { // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; this.logger.warn('http request failed', e.message); + if (e.name === 'CanceledError' && options?.signal?.reason) { + throw new pRetry.AbortError(options.signal.reason); + } if (e.request) { throw requestErrorWithOriginal(e, this.attachOriginalToWebAPIRequestError); } diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index 6c7452694..22ffce8bc 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -529,9 +529,11 @@ import { type WebAPICallResult, WebClient, type WebClientEvent } from './WebClie */ type MethodWithRequiredArgument = ( options: MethodArguments, + config?: { signal?: AbortSignal }, ) => Promise; type MethodWithOptionalArgument = ( options?: MethodArguments, + config?: { signal?: AbortSignal }, ) => Promise; export default MethodWithOptionalArgument; @@ -578,7 +580,11 @@ export abstract class Methods extends EventEmitter { } } - public abstract apiCall(method: string, options?: Record): Promise; + public abstract apiCall( + method: string, + options?: Record, + config?: { signal?: AbortSignal }, + ): Promise; public abstract filesUploadV2(options: FilesUploadV2Arguments): Promise; public readonly admin = {