diff --git a/packages/core/functions-js/src/FunctionsClient.ts b/packages/core/functions-js/src/FunctionsClient.ts index 5781e8a19..203a1ad3b 100644 --- a/packages/core/functions-js/src/FunctionsClient.ts +++ b/packages/core/functions-js/src/FunctionsClient.ts @@ -50,8 +50,11 @@ export class FunctionsClient { functionName: string, options: FunctionInvokeOptions = {} ): Promise> { + let timeoutId: ReturnType | undefined + let timeoutController: AbortController | undefined + try { - const { headers, method, body: functionArgs, signal } = options + const { headers, method, body: functionArgs, signal, timeout } = options let _headers: Record = {} let { region } = options if (!region) { @@ -94,6 +97,22 @@ export class FunctionsClient { body = functionArgs } + // Handle timeout by creating an AbortController + let effectiveSignal = signal + if (timeout) { + timeoutController = new AbortController() + timeoutId = setTimeout(() => timeoutController!.abort(), timeout) + + // If user provided their own signal, we need to respect both + if (signal) { + effectiveSignal = timeoutController.signal + // If the user's signal is aborted, abort our timeout controller too + signal.addEventListener('abort', () => timeoutController!.abort()) + } else { + effectiveSignal = timeoutController.signal + } + } + const response = await this.fetch(url.toString(), { method: method || 'POST', // headers priority is (high to low): @@ -102,11 +121,8 @@ export class FunctionsClient { // 3. default Content-Type header headers: { ..._headers, ...this.headers, ...headers }, body, - signal, + signal: effectiveSignal, }).catch((fetchError) => { - if (fetchError.name === 'AbortError') { - throw fetchError - } throw new FunctionsFetchError(fetchError) }) @@ -139,9 +155,6 @@ export class FunctionsClient { return { data, error: null, response } } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { data: null, error: new FunctionsFetchError(error) } - } return { data: null, error, @@ -150,6 +163,11 @@ export class FunctionsClient { ? error.context : undefined, } + } finally { + // Clear the timeout if it was set + if (timeoutId) { + clearTimeout(timeoutId) + } } } } diff --git a/packages/core/functions-js/src/types.ts b/packages/core/functions-js/src/types.ts index 208c43306..dc5f35e29 100644 --- a/packages/core/functions-js/src/types.ts +++ b/packages/core/functions-js/src/types.ts @@ -88,4 +88,9 @@ export type FunctionInvokeOptions = { * The AbortSignal to use for the request. * */ signal?: AbortSignal + /** + * The timeout for the request in milliseconds. + * If the function takes longer than this, the request will be aborted. + * */ + timeout?: number } diff --git a/packages/core/functions-js/test/functions/slow/index.ts b/packages/core/functions-js/test/functions/slow/index.ts new file mode 100644 index 000000000..25ce2bfb0 --- /dev/null +++ b/packages/core/functions-js/test/functions/slow/index.ts @@ -0,0 +1,7 @@ +import { serve } from 'https://deno.land/std/http/server.ts' + +serve(async () => { + // Sleep for 3 seconds + await new Promise((resolve) => setTimeout(resolve, 3000)) + return new Response('Slow Response') +}) diff --git a/packages/core/functions-js/test/spec/timeout.spec.ts b/packages/core/functions-js/test/spec/timeout.spec.ts new file mode 100644 index 000000000..6b7cc50d8 --- /dev/null +++ b/packages/core/functions-js/test/spec/timeout.spec.ts @@ -0,0 +1,121 @@ +import 'jest' +import { nanoid } from 'nanoid' +import { sign } from 'jsonwebtoken' + +import { FunctionsClient } from '../../src/index' + +import { Relay, runRelay } from '../relay/container' +import { log } from '../utils/jest-custom-reporter' + +describe('timeout tests (slow function)', () => { + let relay: Relay + const jwtSecret = nanoid(10) + const apiKey = sign({ name: 'anon' }, jwtSecret) + + beforeAll(async () => { + relay = await runRelay('slow', jwtSecret) + }) + + afterAll(async () => { + if (relay) { + await relay.stop() + } + }) + + test('invoke slow function without timeout should succeed', async () => { + /** + * @feature timeout + */ + log('create FunctionsClient') + const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + log('invoke slow without timeout') + const { data, error } = await fclient.invoke('slow', {}) + + log('assert no error') + expect(error).toBeNull() + log(`assert ${data} is equal to 'Slow Response'`) + expect(data).toEqual('Slow Response') + }) + + test('invoke slow function with short timeout should fail', async () => { + /** + * @feature timeout + */ + log('create FunctionsClient') + const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + log('invoke slow with 1000ms timeout (function takes 3000ms)') + const { data, error } = await fclient.invoke('slow', { + timeout: 1000, + }) + + log('assert error occurred') + expect(error).not.toBeNull() + expect(error?.name).toEqual('FunctionsFetchError') + expect(data).toBeNull() + }) + + test('invoke slow function with long timeout should succeed', async () => { + /** + * @feature timeout + */ + log('create FunctionsClient') + const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + log('invoke slow with 5000ms timeout (function takes 3000ms)') + const { data, error } = await fclient.invoke('slow', { + timeout: 5000, + }) + + log('assert no error') + expect(error).toBeNull() + log(`assert ${data} is equal to 'Slow Response'`) + expect(data).toEqual('Slow Response') + }) + + test('invoke slow function with timeout and custom AbortSignal', async () => { + /** + * @feature timeout + */ + log('create FunctionsClient') + const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + const abortController = new AbortController() + + log('invoke slow with both timeout and AbortSignal') + const invokePromise = fclient.invoke('slow', { + timeout: 5000, // 5 second timeout + signal: abortController.signal, + }) + + // Abort after 500ms + setTimeout(() => { + log('aborting request via AbortController') + abortController.abort() + }, 500) + + const { data, error } = await invokePromise + + log('assert error occurred from abort') + expect(error).not.toBeNull() + expect(error?.name).toEqual('FunctionsFetchError') + expect(data).toBeNull() + }) +})