Skip to content

functions-js: invoke() ignores an already-aborted signal when timeout is set, so the function still runs #2622

Description

@serhiileniv

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

  1. npm i @supabase/functions-js@2.112.3
  2. Save the snippet above as repro.cjs
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    functions-jsRelated to the functions-js library.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions