Skip to content

Commit 6ee3546

Browse files
authored
fix(functions): add configurable timeout and normalize abort/timeout errors as FunctionsFetchError (#1837)
1 parent 9e08cc3 commit 6ee3546

File tree

4 files changed

+159
-8
lines changed

4 files changed

+159
-8
lines changed

packages/core/functions-js/src/FunctionsClient.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ export class FunctionsClient {
5050
functionName: string,
5151
options: FunctionInvokeOptions = {}
5252
): Promise<FunctionsResponse<T>> {
53+
let timeoutId: ReturnType<typeof setTimeout> | undefined
54+
let timeoutController: AbortController | undefined
55+
5356
try {
54-
const { headers, method, body: functionArgs, signal } = options
57+
const { headers, method, body: functionArgs, signal, timeout } = options
5558
let _headers: Record<string, string> = {}
5659
let { region } = options
5760
if (!region) {
@@ -94,6 +97,22 @@ export class FunctionsClient {
9497
body = functionArgs
9598
}
9699

100+
// Handle timeout by creating an AbortController
101+
let effectiveSignal = signal
102+
if (timeout) {
103+
timeoutController = new AbortController()
104+
timeoutId = setTimeout(() => timeoutController!.abort(), timeout)
105+
106+
// If user provided their own signal, we need to respect both
107+
if (signal) {
108+
effectiveSignal = timeoutController.signal
109+
// If the user's signal is aborted, abort our timeout controller too
110+
signal.addEventListener('abort', () => timeoutController!.abort())
111+
} else {
112+
effectiveSignal = timeoutController.signal
113+
}
114+
}
115+
97116
const response = await this.fetch(url.toString(), {
98117
method: method || 'POST',
99118
// headers priority is (high to low):
@@ -102,11 +121,8 @@ export class FunctionsClient {
102121
// 3. default Content-Type header
103122
headers: { ..._headers, ...this.headers, ...headers },
104123
body,
105-
signal,
124+
signal: effectiveSignal,
106125
}).catch((fetchError) => {
107-
if (fetchError.name === 'AbortError') {
108-
throw fetchError
109-
}
110126
throw new FunctionsFetchError(fetchError)
111127
})
112128

@@ -139,9 +155,6 @@ export class FunctionsClient {
139155

140156
return { data, error: null, response }
141157
} catch (error) {
142-
if (error instanceof Error && error.name === 'AbortError') {
143-
return { data: null, error: new FunctionsFetchError(error) }
144-
}
145158
return {
146159
data: null,
147160
error,
@@ -150,6 +163,11 @@ export class FunctionsClient {
150163
? error.context
151164
: undefined,
152165
}
166+
} finally {
167+
// Clear the timeout if it was set
168+
if (timeoutId) {
169+
clearTimeout(timeoutId)
170+
}
153171
}
154172
}
155173
}

packages/core/functions-js/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,9 @@ export type FunctionInvokeOptions = {
8888
* The AbortSignal to use for the request.
8989
* */
9090
signal?: AbortSignal
91+
/**
92+
* The timeout for the request in milliseconds.
93+
* If the function takes longer than this, the request will be aborted.
94+
* */
95+
timeout?: number
9196
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { serve } from 'https://deno.land/std/http/server.ts'
2+
3+
serve(async () => {
4+
// Sleep for 3 seconds
5+
await new Promise((resolve) => setTimeout(resolve, 3000))
6+
return new Response('Slow Response')
7+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import 'jest'
2+
import { nanoid } from 'nanoid'
3+
import { sign } from 'jsonwebtoken'
4+
5+
import { FunctionsClient } from '../../src/index'
6+
7+
import { Relay, runRelay } from '../relay/container'
8+
import { log } from '../utils/jest-custom-reporter'
9+
10+
describe('timeout tests (slow function)', () => {
11+
let relay: Relay
12+
const jwtSecret = nanoid(10)
13+
const apiKey = sign({ name: 'anon' }, jwtSecret)
14+
15+
beforeAll(async () => {
16+
relay = await runRelay('slow', jwtSecret)
17+
})
18+
19+
afterAll(async () => {
20+
if (relay) {
21+
await relay.stop()
22+
}
23+
})
24+
25+
test('invoke slow function without timeout should succeed', async () => {
26+
/**
27+
* @feature timeout
28+
*/
29+
log('create FunctionsClient')
30+
const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, {
31+
headers: {
32+
Authorization: `Bearer ${apiKey}`,
33+
},
34+
})
35+
36+
log('invoke slow without timeout')
37+
const { data, error } = await fclient.invoke<string>('slow', {})
38+
39+
log('assert no error')
40+
expect(error).toBeNull()
41+
log(`assert ${data} is equal to 'Slow Response'`)
42+
expect(data).toEqual('Slow Response')
43+
})
44+
45+
test('invoke slow function with short timeout should fail', async () => {
46+
/**
47+
* @feature timeout
48+
*/
49+
log('create FunctionsClient')
50+
const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, {
51+
headers: {
52+
Authorization: `Bearer ${apiKey}`,
53+
},
54+
})
55+
56+
log('invoke slow with 1000ms timeout (function takes 3000ms)')
57+
const { data, error } = await fclient.invoke<string>('slow', {
58+
timeout: 1000,
59+
})
60+
61+
log('assert error occurred')
62+
expect(error).not.toBeNull()
63+
expect(error?.name).toEqual('FunctionsFetchError')
64+
expect(data).toBeNull()
65+
})
66+
67+
test('invoke slow function with long timeout should succeed', async () => {
68+
/**
69+
* @feature timeout
70+
*/
71+
log('create FunctionsClient')
72+
const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, {
73+
headers: {
74+
Authorization: `Bearer ${apiKey}`,
75+
},
76+
})
77+
78+
log('invoke slow with 5000ms timeout (function takes 3000ms)')
79+
const { data, error } = await fclient.invoke<string>('slow', {
80+
timeout: 5000,
81+
})
82+
83+
log('assert no error')
84+
expect(error).toBeNull()
85+
log(`assert ${data} is equal to 'Slow Response'`)
86+
expect(data).toEqual('Slow Response')
87+
})
88+
89+
test('invoke slow function with timeout and custom AbortSignal', async () => {
90+
/**
91+
* @feature timeout
92+
*/
93+
log('create FunctionsClient')
94+
const fclient = new FunctionsClient(`http://localhost:${relay.container.getMappedPort(8081)}`, {
95+
headers: {
96+
Authorization: `Bearer ${apiKey}`,
97+
},
98+
})
99+
100+
const abortController = new AbortController()
101+
102+
log('invoke slow with both timeout and AbortSignal')
103+
const invokePromise = fclient.invoke<string>('slow', {
104+
timeout: 5000, // 5 second timeout
105+
signal: abortController.signal,
106+
})
107+
108+
// Abort after 500ms
109+
setTimeout(() => {
110+
log('aborting request via AbortController')
111+
abortController.abort()
112+
}, 500)
113+
114+
const { data, error } = await invokePromise
115+
116+
log('assert error occurred from abort')
117+
expect(error).not.toBeNull()
118+
expect(error?.name).toEqual('FunctionsFetchError')
119+
expect(data).toBeNull()
120+
})
121+
})

0 commit comments

Comments
 (0)