From d1fc8fbb083efbcd635991c565a1ae332947de7a Mon Sep 17 00:00:00 2001 From: Asim Awdah Date: Sun, 25 Oct 2026 17:00:42 +0300 Subject: [PATCH] Add uncaught error handling for streaming procedures --- README.md | 11 +++++++++ __tests__/cancellation.test.ts | 44 ++++++++++++++++++++++++++++++++++ router/context.ts | 8 ++++++- router/server.ts | 15 +++++++++++- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60bbc794..d698ccb9 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,17 @@ if (!result.ok && result.payload.code === 'UNCAUGHT_ERROR') { } ``` +For errors raised later by a callback or background task, use `ctx.uncaught(error)` to end the +procedure with the same built-in error: + +```ts +async handler({ ctx }) { + setTimeout(() => { + ctx.uncaught(new Error('Something went wrong!')); + }, 1000); +} +``` + ### Logging To add logging, you can bind a logging function to a transport. diff --git a/__tests__/cancellation.test.ts b/__tests__/cancellation.test.ts index 743c10ea..33deeaab 100644 --- a/__tests__/cancellation.test.ts +++ b/__tests__/cancellation.test.ts @@ -159,6 +159,50 @@ describe.each(testMatrix())( }); }); + test('stream can report uncaught errors from background tasks', async () => { + const clientTransport = getClientTransport('client'); + const serverTransport = getServerTransport(); + addPostTestCleanup(async () => { + await cleanupTransports([clientTransport, serverTransport]); + }); + + let signal: AbortSignal | undefined; + const services = { + service: ServiceSchema.define({ + stream: Procedure.stream({ + requestInit: Type.Object({}), + requestData: Type.Object({}), + responseData: Type.Object({}), + handler: async ({ ctx }) => { + signal = ctx.signal; + setTimeout(() => { + ctx.uncaught(new Error('background task failed')); + }, 0); + }, + }), + }), + }; + + const server = createServer(serverTransport, services); + const client = createClient( + clientTransport, + serverTransport.clientId, + ); + + const { resReadable } = client.service.stream.stream({}); + + await expect(resReadable.collect()).resolves.toEqual([ + Err({ code: UNCAUGHT_ERROR_CODE, message: 'background task failed' }), + ]); + expect(signal?.aborted).toEqual(true); + + await testFinishesCleanly({ + clientTransports: [clientTransport], + serverTransport, + server, + }); + }); + test('upload', async () => { const clientTransport = getClientTransport('client'); const serverTransport = getServerTransport(); diff --git a/router/context.ts b/router/context.ts index ae4d8529..99cd8584 100644 --- a/router/context.ts +++ b/router/context.ts @@ -2,7 +2,7 @@ import { Span } from '@opentelemetry/api'; import { TransportClientId } from '../transport/message'; import { SessionId } from '../transport/sessionStateMachine/common'; import { ErrResult } from './result'; -import { CancelErrorSchema } from './errors'; +import { CancelErrorSchema, ReaderErrorSchema } from './errors'; import type { Static } from 'typebox'; /** @@ -40,6 +40,12 @@ export type ProcedureHandlerContext = * the river documentation to understand the difference between the two concepts. */ cancel: (message?: string) => ErrResult>; + /** + * End the procedure with an uncaught server error and notify the client. + * This is useful for errors raised by callbacks or background tasks that + * run after the handler has returned control to the event loop. + */ + uncaught: (error: unknown) => ErrResult>; /** * Register a cleanup function that will run after the procedure handler * completes (whether it returns normally, throws, or is cancelled). diff --git a/router/server.ts b/router/server.ts index 2ef33b96..0cc2f8ec 100644 --- a/router/server.ts +++ b/router/server.ts @@ -730,6 +730,16 @@ class RiverServer< return Err(errRes); }, + uncaught: (error: unknown) => { + const errRes = { + code: UNCAUGHT_ERROR_CODE, + message: coerceErrorString(error), + } as const; + + onServerCancel(errRes); + + return Err(errRes); + }, deferCleanup, signal: finishedController.signal, }; @@ -1217,7 +1227,10 @@ function getStreamCloseBackwardsCompat(protocolVersion: ProtocolVersion) { export interface MiddlewareContext extends Readonly< - Omit, 'cancel'> + Omit< + ProcedureHandlerContext, + 'cancel' | 'uncaught' + > > { readonly streamId: StreamId; readonly procedureName: string;