Skip to content

Commit b2d00a3

Browse files
unnoqCopilot
andauthored
feat(server): rethrow handler plugin (#1286)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced the Rethrow Handler Plugin to allow selective rethrowing of errors to the host framework via a configurable filter. * **Documentation** * Added a documentation page for the plugin with usage examples. * Updated integration guides and site navigation to include the Rethrow Handler Plugin. * **Tests** * Added comprehensive tests covering rethrow behavior, filtering, context corruption cases, and normal operation. * **Chores** * Exposed the plugin in the public plugin exports. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b81d47f commit b2d00a3

File tree

6 files changed

+336
-2
lines changed

6 files changed

+336
-2
lines changed

apps/content/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export default withMermaid(defineConfig({
150150
{ text: 'Batch Requests', link: '/docs/plugins/batch-requests' },
151151
{ text: 'Client Retry', link: '/docs/plugins/client-retry' },
152152
{ text: 'Retry After', link: '/docs/plugins/retry-after' },
153+
{ text: 'Rethrow Handler', link: '/docs/plugins/rethrow-handler' },
153154
{ text: 'Compression', link: '/docs/plugins/compression' },
154155
{ text: 'Body Limit', link: '/docs/plugins/body-limit' },
155156
{ text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' },

apps/content/docs/openapi/integrations/implement-contract-in-nest.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,11 @@ Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS appli
222222
```ts
223223
import { Module } from '@nestjs/common'
224224
import { REQUEST } from '@nestjs/core'
225-
import { onError, ORPCModule } from '@orpc/nest'
225+
import { onError, ORPCError, ORPCModule } from '@orpc/nest'
226226
import { Request } from 'express' // if you use express adapter
227+
import {
228+
experimental_RethrowHandlerPlugin as RethrowHandlerPlugin,
229+
} from '@orpc/server/plugins'
227230

228231
declare module '@orpc/nest' {
229232
/**
@@ -246,7 +249,15 @@ declare module '@orpc/nest' {
246249
context: { request }, // oRPC context, accessible from middlewares, etc.
247250
eventIteratorKeepAliveInterval: 5000, // 5 seconds
248251
customJsonSerializers: [],
249-
plugins: [], // most oRPC plugins are compatible
252+
plugins: [
253+
new RethrowHandlerPlugin({
254+
filter: (error) => {
255+
// Rethrow all non-ORPCError errors
256+
// This allows unhandled exceptions to bubble up to NestJS global exception filters
257+
return !(error instanceof ORPCError)
258+
},
259+
})
260+
], // most oRPC plugins are compatible
250261
}),
251262
inject: [REQUEST],
252263
}),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: Rethrow Handler Plugin
3+
description: A plugin to catch and rethrow specific errors during request handling instead of handling them in the oRPC error flow.
4+
---
5+
6+
# Rethrow Handler Plugin
7+
8+
The `RethrowHandlerPlugin` allows you to catch and rethrow specific errors that occur during request handling. This is particularly useful when your framework has its own error handling mechanism (e.g., global exception filters in NestJS, error middleware in Express) and you want certain errors to be processed by that mechanism instead of being handled by the oRPC error handling flow.
9+
10+
## Usage
11+
12+
```ts twoslash
13+
import { ORPCError } from '@orpc/server'
14+
import { RPCHandler } from '@orpc/server/fetch'
15+
import { router } from './shared/planet'
16+
17+
// ---cut---
18+
import {
19+
experimental_RethrowHandlerPlugin as RethrowHandlerPlugin,
20+
} from '@orpc/server/plugins'
21+
22+
const handler = new RPCHandler(router, {
23+
plugins: [
24+
new RethrowHandlerPlugin({
25+
// Decide which errors should be rethrown.
26+
filter: (error) => {
27+
// Example: Rethrow all non-ORPCError errors
28+
// This allows unhandled exceptions to bubble up to your framework
29+
return !(error instanceof ORPCError)
30+
},
31+
}),
32+
],
33+
})
34+
```
35+
36+
::: info
37+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler.
38+
:::

packages/server/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './batch'
22
export * from './cors'
33
export * from './request-headers'
44
export * from './response-headers'
5+
export * from './rethrow'
56
export * from './simple-csrf-protection'
67
export * from './strict-get-method'
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { ORPCError } from '@orpc/client'
2+
import { RPCHandler } from '../adapters/fetch'
3+
import { os } from '../builder'
4+
import { experimental_RethrowHandlerPlugin as RethrowHandlerPlugin } from './rethrow'
5+
6+
beforeEach(() => {
7+
vi.clearAllMocks()
8+
})
9+
10+
describe('rethrowHandlerPlugin', () => {
11+
it('should rethrow errors when filter returns true', async () => {
12+
class CustomError extends Error {
13+
constructor(message: string, public readonly code: number) {
14+
super(message)
15+
this.name = 'CustomError'
16+
}
17+
}
18+
19+
const customError = new CustomError('Error with code', 42)
20+
21+
const handler = new RPCHandler({
22+
ping: os.handler(() => {
23+
throw customError
24+
}),
25+
}, {
26+
strictGetMethodPluginEnabled: false,
27+
plugins: [
28+
new RethrowHandlerPlugin({
29+
filter: () => true, // Always rethrow
30+
}),
31+
],
32+
})
33+
34+
await expect(
35+
handler.handle(new Request('http://localhost/ping', {
36+
method: 'POST',
37+
body: JSON.stringify({}),
38+
headers: { 'Content-Type': 'application/json' },
39+
})),
40+
).rejects.toThrow(customError)
41+
})
42+
43+
it('should not rethrow errors when filter returns false', async () => {
44+
const customError = new Error('Custom error that should not be rethrown')
45+
46+
const handler = new RPCHandler({
47+
ping: os.handler(() => {
48+
throw customError
49+
}),
50+
}, {
51+
strictGetMethodPluginEnabled: false,
52+
plugins: [
53+
new RethrowHandlerPlugin({
54+
filter: () => false, // Never rethrow
55+
}),
56+
],
57+
})
58+
59+
await expect(
60+
handler.handle(new Request('http://localhost/ping', {
61+
method: 'POST',
62+
body: JSON.stringify({}),
63+
headers: { 'Content-Type': 'application/json' },
64+
})),
65+
).resolves.toEqual({ matched: true, response: expect.toSatisfy((response: Response) => response.status === 500) })
66+
})
67+
68+
it('should rethrow non-ORPCError errors and handle ORPCError normally', async () => {
69+
const handler = new RPCHandler({
70+
throwCustom: os.handler(() => {
71+
throw new Error('Custom error')
72+
}),
73+
throwORPC: os.handler(() => {
74+
throw new ORPCError('BAD_REQUEST', { message: 'ORPC error' })
75+
}),
76+
}, {
77+
strictGetMethodPluginEnabled: false,
78+
plugins: [
79+
new RethrowHandlerPlugin({
80+
filter: error => !(error instanceof ORPCError),
81+
}),
82+
],
83+
})
84+
85+
// Non-ORPCError should be rethrown
86+
await expect(
87+
handler.handle(new Request('http://localhost/throwCustom', {
88+
method: 'POST',
89+
body: JSON.stringify({}),
90+
headers: { 'Content-Type': 'application/json' },
91+
})),
92+
).rejects.toThrow('Custom error')
93+
94+
// ORPCError should be handled normally (not rethrown)
95+
await expect(
96+
handler.handle(new Request('http://localhost/throwORPC', {
97+
method: 'POST',
98+
body: JSON.stringify({}),
99+
headers: { 'Content-Type': 'application/json' },
100+
})),
101+
).resolves.toEqual({ matched: true, response: expect.toSatisfy((response: Response) => response.status === 400) })
102+
})
103+
104+
it('should pass error and options to filter function', async () => {
105+
const filter = vi.fn(() => false)
106+
const thrownError = new Error('Test error')
107+
108+
const handler = new RPCHandler({
109+
ping: os.handler(() => {
110+
throw thrownError
111+
}),
112+
}, {
113+
strictGetMethodPluginEnabled: false,
114+
plugins: [
115+
new RethrowHandlerPlugin({ filter }),
116+
],
117+
})
118+
119+
await handler.handle(new Request('http://localhost/ping', {
120+
method: 'POST',
121+
body: JSON.stringify({}),
122+
headers: { 'Content-Type': 'application/json' },
123+
}))
124+
125+
expect(filter).toHaveBeenCalledTimes(1)
126+
expect(filter).toHaveBeenCalledWith(
127+
thrownError,
128+
expect.objectContaining({
129+
request: expect.objectContaining({
130+
method: 'POST',
131+
}),
132+
context: expect.any(Object),
133+
}),
134+
)
135+
})
136+
137+
it('should work normally without errors being thrown', async () => {
138+
const handler = new RPCHandler({
139+
ping: os.handler(() => 'pong'),
140+
}, {
141+
strictGetMethodPluginEnabled: false,
142+
plugins: [
143+
new RethrowHandlerPlugin({
144+
filter: () => true,
145+
}),
146+
],
147+
})
148+
149+
await expect(
150+
handler.handle(new Request('http://localhost/ping', {
151+
method: 'POST',
152+
body: JSON.stringify({}),
153+
headers: { 'Content-Type': 'application/json' },
154+
})),
155+
).resolves.toEqual({
156+
matched: true,
157+
response: expect.toSatisfy((response: Response) => response.status === 200),
158+
})
159+
})
160+
161+
it('should response error if other plugins or interceptors corrupt the context', async () => {
162+
const handler = new RPCHandler({
163+
ping: os.handler(() => 'pong'),
164+
}, {
165+
strictGetMethodPluginEnabled: false,
166+
plugins: [
167+
new RethrowHandlerPlugin({
168+
filter: () => true,
169+
}),
170+
{
171+
init(options) {
172+
options.rootInterceptors?.push(async (options) => {
173+
// Corrupt the context
174+
return options.next({
175+
...options,
176+
context: {},
177+
})
178+
})
179+
},
180+
},
181+
],
182+
})
183+
184+
await expect(
185+
handler.handle(new Request('http://localhost/ping', {
186+
method: 'POST',
187+
body: JSON.stringify({}),
188+
headers: { 'Content-Type': 'application/json' },
189+
})),
190+
).resolves.toEqual({
191+
matched: true,
192+
response: expect.toSatisfy((response: Response) => response.status === 500),
193+
})
194+
})
195+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { ThrowableError, Value } from '@orpc/shared'
2+
import type { StandardHandlerInterceptorOptions, StandardHandlerOptions, StandardHandlerPlugin } from '../adapters/standard'
3+
import type { Context } from '../context'
4+
import { value } from '@orpc/shared'
5+
6+
export interface experimental_RethrowHandlerPluginOptions<T extends Context> {
7+
/**
8+
* Decide which errors should be rethrown.
9+
*
10+
* @example
11+
* ```ts
12+
* const rethrowPlugin = new RethrowHandlerPlugin({
13+
* filter: (error) => {
14+
* // Rethrow all non-ORPCError errors
15+
* return !(error instanceof ORPCError)
16+
* }
17+
* })
18+
* ```
19+
*/
20+
filter: Value<boolean, [error: ThrowableError, options: StandardHandlerInterceptorOptions<T>]>
21+
}
22+
23+
interface RethrowHandlerPluginContext {
24+
error?: { value: ThrowableError }
25+
}
26+
27+
/**
28+
* The plugin allows you to catch and rethrow specific errors that occur during request handling.
29+
* This is particularly useful when your framework has its own error handling mechanism
30+
* (e.g., global exception filters in NestJS, error middleware in Express)
31+
* and you want certain errors to be processed by that mechanism instead of being handled by the
32+
* oRPC error handling flow.
33+
*
34+
* @see {@link https://orpc.dev/docs/plugins/rethrow-handler Rethrow Handler Plugin Docs}
35+
*/
36+
export class experimental_RethrowHandlerPlugin<T extends Context> implements StandardHandlerPlugin<T> {
37+
private readonly filter: experimental_RethrowHandlerPluginOptions<T>['filter']
38+
39+
CONTEXT_SYMBOL = Symbol('ORPC_RETHROW_HANDLER_PLUGIN_CONTEXT')
40+
41+
constructor(options: experimental_RethrowHandlerPluginOptions<T>) {
42+
this.filter = options.filter
43+
}
44+
45+
init(options: StandardHandlerOptions<T>): void {
46+
options.rootInterceptors ??= []
47+
options.interceptors ??= []
48+
49+
options.rootInterceptors.push(async (options) => {
50+
const pluginContext: RethrowHandlerPluginContext = {}
51+
52+
const result = await options.next({
53+
...options,
54+
context: {
55+
...options.context,
56+
[this.CONTEXT_SYMBOL]: pluginContext,
57+
},
58+
})
59+
60+
if (pluginContext.error) {
61+
throw pluginContext.error.value
62+
}
63+
64+
return result
65+
})
66+
67+
options.interceptors.unshift(async (options) => {
68+
const pluginContext = options.context[this.CONTEXT_SYMBOL] as RethrowHandlerPluginContext | undefined
69+
70+
if (!pluginContext) {
71+
throw new TypeError('[RethrowHandlerPlugin] Rethrow handler context has been corrupted or modified by another plugin or interceptor')
72+
}
73+
74+
try {
75+
// await is important here to catch both sync and async errors
76+
return await options.next()
77+
}
78+
catch (error) {
79+
if (value(this.filter, error as ThrowableError, options)) {
80+
pluginContext.error = { value: error as ThrowableError }
81+
return { matched: false, response: undefined }
82+
}
83+
84+
throw error
85+
}
86+
})
87+
}
88+
}

0 commit comments

Comments
 (0)