Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions packages/core/functions-js/src/FunctionsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ export class FunctionsClient {
functionName: string,
options: FunctionInvokeOptions = {}
): Promise<FunctionsResponse<T>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
let timeoutController: AbortController | undefined

try {
const { headers, method, body: functionArgs, signal } = options
const { headers, method, body: functionArgs, signal, timeout } = options
let _headers: Record<string, string> = {}
let { region } = options
if (!region) {
Expand Down Expand Up @@ -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):
Expand All @@ -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)
})

Expand Down Expand Up @@ -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,
Expand All @@ -150,6 +163,11 @@ export class FunctionsClient {
? error.context
: undefined,
}
} finally {
// Clear the timeout if it was set
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
}
5 changes: 5 additions & 0 deletions packages/core/functions-js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions packages/core/functions-js/test/functions/slow/index.ts
Original file line number Diff line number Diff line change
@@ -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')
})
121 changes: 121 additions & 0 deletions packages/core/functions-js/test/spec/timeout.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<string>('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<string>('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<string>('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()
})
})
Loading