Describe the bug
FunctionsClient.invoke() accepts both a caller signal and a timeout. When both are given, it creates an internal AbortController for the timeout and bridges the caller's abort onto it by subscribing to the caller signal:
// packages/core/functions-js/src/FunctionsClient.ts (v2.112.3, L272-281)
if (signal) {
effectiveSignal = timeoutController.signal
// If the user's signal is aborted, abort our timeout controller too.
onAbort = () => timeoutController!.abort()
signal.addEventListener('abort', onAbort)
}
An AbortSignal that is already aborted never dispatches another abort event, so that listener never fires. The signal handed to fetch is the fresh, un-aborted timeoutController.signal, so the request goes out: the Edge Function actually executes and invoke() resolves with its response as if nothing had been cancelled.
The same call without timeout behaves correctly — effectiveSignal = signal, and fetch rejects immediately without issuing a request. So the two paths disagree, and adding a timeout silently turns off pre-flight cancellation.
A second, smaller consequence of the same block: the caller's abort reason is dropped. timeoutController.abort() is called with no argument, so a caller who aborts with controller.abort(myReason) can never see myReason — it is replaced by a generic AbortError.
Library affected
functions-js
Reproduction
Self-contained, no Supabase project needed — a local HTTP server counts how many requests actually arrive.
// repro.cjs — node repro.cjs
const http = require('node:http')
const { FunctionsClient } = require('@supabase/functions-js')
let hits = 0
const server = http.createServer((req, res) => {
hits++
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
async function run(label, opts, url) {
const controller = new AbortController()
controller.abort() // aborted BEFORE invoke
const before = hits
const client = new FunctionsClient(url)
const { data, error } = await client.invoke('my-function', { ...opts, signal: controller.signal })
console.log(`${label}\n server hits: ${hits - before}\n error: ${error ? error.name : 'null'}\n data: ${JSON.stringify(data)}\n`)
}
server.listen(0, async () => {
const url = `http://127.0.0.1:${server.address().port}`
await run('no timeout (expect 0 hits, aborted)', {}, url)
await run('with timeout (expect 0 hits, aborted)', { timeout: 5000 }, url)
server.close()
})
Steps to reproduce
npm i @supabase/functions-js@2.112.3
- Save the snippet above as
repro.cjs
node repro.cjs
Actual behaviour
no timeout (expect 0 hits, aborted)
server hits: 0
error: FunctionsFetchError
data: null
with timeout (expect 0 hits, aborted)
server hits: 1 <-- the function ran
error: null <-- and invoke() reports success
data: {"ok":true}
Expected behaviour
server hits: 0 in both cases: an already-aborted signal should prevent the request, exactly as it does when timeout is not passed.
Why it matters
This is the ordinary "cancel in-flight work" pattern — a React useEffect cleanup controller, AbortSignal.timeout(...), or one request-scoped controller shared across a batch of calls. Any time the controller is aborted before control reaches invoke() (the component unmounted while an earlier await was pending, an earlier item in the batch already failed), the function is still invoked. That burns an invocation and, more importantly, runs whatever side effects the function has — writes, emails, payment calls — after the caller has cancelled. The caller sees a success result for a request they cancelled.
Suggested fix
Mirror the already-aborted state onto the timeout controller up front, and forward the reason in both branches:
if (signal.aborted) {
timeoutController.abort(signal.reason)
} else {
onAbort = () => timeoutController!.abort(signal.reason)
signal.addEventListener('abort', onAbort)
}
AbortSignal.any([signal, timeoutController.signal]) would also solve it, but it needs Chrome 116+ / Safari 17.4+ / Firefox 124+; the explicit guard above leaves the browser support matrix untouched.
I have a PR ready with this fix plus regression tests in test/FunctionsClient.test.ts, and will link it here.
System Info
@supabase/functions-js 2.112.3 (latest); code path unchanged on master @ a249594
- Node.js v25.9.0
- OS: Windows 11
Describe the bug
FunctionsClient.invoke()accepts both a callersignaland atimeout. When both are given, it creates an internalAbortControllerfor the timeout and bridges the caller's abort onto it by subscribing to the caller signal:An
AbortSignalthat is already aborted never dispatches anotherabortevent, so that listener never fires. The signal handed tofetchis the fresh, un-abortedtimeoutController.signal, so the request goes out: the Edge Function actually executes andinvoke()resolves with its response as if nothing had been cancelled.The same call without
timeoutbehaves correctly —effectiveSignal = signal, andfetchrejects immediately without issuing a request. So the two paths disagree, and adding atimeoutsilently turns off pre-flight cancellation.A second, smaller consequence of the same block: the caller's abort reason is dropped.
timeoutController.abort()is called with no argument, so a caller who aborts withcontroller.abort(myReason)can never seemyReason— it is replaced by a genericAbortError.Library affected
functions-js
Reproduction
Self-contained, no Supabase project needed — a local HTTP server counts how many requests actually arrive.
Steps to reproduce
npm i @supabase/functions-js@2.112.3repro.cjsnode repro.cjsActual behaviour
Expected behaviour
server hits: 0in both cases: an already-aborted signal should prevent the request, exactly as it does whentimeoutis not passed.Why it matters
This is the ordinary "cancel in-flight work" pattern — a React
useEffectcleanup controller,AbortSignal.timeout(...), or one request-scoped controller shared across a batch of calls. Any time the controller is aborted before control reachesinvoke()(the component unmounted while an earlierawaitwas pending, an earlier item in the batch already failed), the function is still invoked. That burns an invocation and, more importantly, runs whatever side effects the function has — writes, emails, payment calls — after the caller has cancelled. The caller sees a success result for a request they cancelled.Suggested fix
Mirror the already-aborted state onto the timeout controller up front, and forward the reason in both branches:
AbortSignal.any([signal, timeoutController.signal])would also solve it, but it needs Chrome 116+ / Safari 17.4+ / Firefox 124+; the explicit guard above leaves the browser support matrix untouched.I have a PR ready with this fix plus regression tests in
test/FunctionsClient.test.ts, and will link it here.System Info
@supabase/functions-js2.112.3 (latest); code path unchanged onmaster@a249594