Skip to content

[Bug?]: an async serverFunctions.onError handler is not awaited #2268

Description

@adipascu

Duplicates

  • I have searched the existing issues

Latest version

  • I have tested the latest version

Current behavior

ServerFunctionErrorHandler is typed (thrown: unknown) => unknown, so an async export typechecks. applyServerFunctionErrorHandler does not await it:

return onServerFunctionError?.(thrown) ?? thrown;

The promise is passed to seroval, which streams it. Two things follow:

  1. The client rejects with a Promise, not the error. fetchServerFunction does const result = await extractBody(...) then throw result, and result is the deserialized promise, so a caller's catch receives a Promise object.
  2. X-Error degrades from the error message to "true", because a promise is neither Error nor string.

It does not fail loudly. It half-works.

Expected behavior

Either the result is awaited, or an async handler is refused up front.

Steps to reproduce

Steps:

  1. In apps/tests, make the module named by serverFunctions.onError export an async handler:

    const onServerFunctionError: ServerFunctionErrorHandler = async thrown => {
    	await new Promise(resolve => setTimeout(resolve, 50));
    	return new Error("replaced by async onError");
    };
  2. Run pnpm dev and read the server function id from curl localhost:3000/src/<module>.ts.

  3. Call a throwing server function over the network:

    curl -i -X POST localhost:3000/_server -H "X-Server-Id: <id>" -H "X-Server-Instance: server-fn:1"
    
  4. The response is 200 with x-error: true and a streamed promise:

    ;0x0000002d;{"t":22,"i":0,"s":1,...}
    ;0x0000016a;{"t":23,"i":1,"a":[...,{"t":13,"m":"replaced by async onError"}]}
    

    With a sync handler the same call returns x-error: replaced by onError.

Context

I added this option in #2262, so this is my own sharp edge. Reporting to a monitoring service is the option's main use, and several SDKs want an await to flush before a serverless process is torn down, so reaching for async is the natural first attempt.

Two directions, happy to send a PR for whichever you prefer:

  1. Await it. handleServerFunction is already async, so x = await applyServerFunctionErrorHandler(x) is a one-line change and makes await flush() work. Cost: a handler can no longer return a promise as the error value.

  2. Refuse it. Keep the sync contract and make the mistake impossible:

    type NotPromise<T> = T extends PromiseLike<unknown> ? never : T;
    export type ServerFunctionErrorHandler = <T>(thrown: unknown) => NotPromise<T>;

    plus a dev-only runtime guard in applyServerFunctionErrorHandler that throws when the result is thenable, so a JavaScript user or a cast is told rather than shipping the streamed promise.

Your environment

System:
  OS: macOS (Darwin 24.6.0)
Binaries:
  Node: 24.14.1
  pnpm: 11.17.0
npmPackages:
  @solidjs/start: 2.0.0-rc.6 (solid-start main @ d34cb89)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Start 2.xtargeting SolidStart 2.x versions

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions