Skip to content

Commit 38df61f

Browse files
authored
feat(server): add timeout handler plugin (#1858)
Adds `TimeoutHandlerPlugin` to `@orpc/server/plugins`, the server-side counterpart of `TimeoutLinkPlugin`. When handling exceeds the configured timeout, the plugin aborts the request signal with an `AbortError`. It never preempts the procedure: abort-aware procedures stop early and logging records the timeout as a cancellation, while a procedure that ignores the signal still delivers its real result or error, so nothing is masked. ## Features - `timeout` limits producing the response; the separate `streamingTimeout` (usually higher) limits the full duration of streaming bodies, both async iterator objects and readable streams. - Both options accept a static number or a per-request function of the interceptor options; returning `null`/`undefined` disables the timeout for that request. - No timer leaks: timers are cleared when the response is produced and when a streaming body finishes or is cancelled; requests with both timeouts disabled bypass the plugin entirely. ## Testing - 15 handler tests under fake timers, each asserting zero leftover timers: abort + response via signal-honoring procedures, no preemption, unmasked late errors, dynamic values, per-request disabling, and streaming termination and cleanup for both body kinds. - Registered in the all-plugins compatibility suite; `pnpm type:check`, eslint, and `pnpm docs:validate` pass. ## Docs - `/docs/plugins/timeout` now covers both the link and handler plugins, streaming timeouts, and dynamic per-request values.
1 parent 4403853 commit 38df61f

7 files changed

Lines changed: 432 additions & 7 deletions

File tree

apps/content/docs/plugins/timeout.mdx

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
---
22
title: "Timeout Plugin"
3-
description: "Automatically abort requests that exceed a timeout with an AbortError, using a static value or a per-request dynamic timeout."
3+
description: "Abort requests that exceed a timeout on the client or the server, using a static value or a per-request dynamic timeout."
44
sidebar:
55
label: "Timeout"
66
---
77

8-
## Usage
8+
## Client
9+
10+
Use `TimeoutLinkPlugin` to abort requests that exceed the timeout with an `AbortError`:
911

1012
```ts
1113
import { TimeoutLinkPlugin } from '@orpc/client/plugins'
@@ -23,11 +25,52 @@ const link = new RPCLink({
2325
The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [OpenAPILink](/docs/openapi/link), or a custom one.
2426
:::
2527

28+
## Server
29+
30+
Use `TimeoutHandlerPlugin` to abort the request signal with an `AbortError` when handling exceeds the timeout:
31+
32+
```ts handler
33+
import { TimeoutHandlerPlugin } from '@orpc/server/plugins'
34+
35+
const handler = new RPCHandler(router, {
36+
plugins: [
37+
new TimeoutHandlerPlugin({
38+
timeout: 10_000, // 10 seconds
39+
}),
40+
],
41+
})
42+
```
43+
44+
:::warning
45+
The plugin only aborts the request signal, it does not respond right after the timeout exceeds. The procedure must honor the signal to stop early and produce the response.
46+
:::
47+
48+
### Streaming Responses
49+
50+
The `timeout` option only covers producing the response, so streaming responses can outlive it. Use `streamingTimeout`, usually higher, to limit the full duration of streaming response bodies ([async iterator objects](/docs/async-iterator-object) and readable streams):
51+
52+
```ts handler
53+
const handler = new RPCHandler(router, {
54+
plugins: [
55+
new TimeoutHandlerPlugin({
56+
timeout: 10_000, // 10 seconds to produce the response
57+
streamingTimeout: 300_000, // 5 minutes for the full stream
58+
}),
59+
],
60+
})
61+
```
62+
63+
:::info
64+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
65+
:::
66+
2667
## Dynamic Timeout
2768

28-
The `timeout` option also accepts a function, so you can resolve the timeout per request from the interceptor options, such as the procedure `path` or the [client context](/docs/client/client-side#client-context):
69+
The `timeout` and `streamingTimeout` options also accept a function, so you can resolve the timeout per request from the interceptor options. On the client these include the procedure `path` and the [client context](/docs/client/client-side#client-context), on the server the matched `procedure` and the [handler context](/docs/context):
2970

30-
```ts
71+
<CodeGroup>
72+
73+
```ts link
3174
const link = new RPCLink({
3275
plugins: [
3376
new TimeoutLinkPlugin({
@@ -37,10 +80,22 @@ const link = new RPCLink({
3780
})
3881
```
3982

83+
```ts handler
84+
const handler = new RPCHandler(router, {
85+
plugins: [
86+
new TimeoutHandlerPlugin({
87+
timeout: ({ path }) => path[0] === 'reports' ? 60_000 : 10_000,
88+
}),
89+
],
90+
})
91+
```
92+
93+
</CodeGroup>
94+
4095
:::info
4196
Return `null` or `undefined` to disable the timeout, which is useful for excluding long-lived requests. Any number always enables the timeout.
4297
:::
4398

4499
## Learn More
45100

46-
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/timeout.ts).
101+
For implementation details, see the [TimeoutLinkPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/timeout.ts) or the [TimeoutHandlerPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/timeout.ts).

packages/cloudflare/worker-configuration.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable */
22
// Generated by Wrangler by running `wrangler types` (hash: e624d76b8500cfee2091bb6c84ee404f)
3-
// Runtime types generated with workerd@1.20260730.1 2026-07-01
3+
// Runtime types generated with workerd@1.20260804.1 2026-07-01
44
interface __BaseEnv_Env {
55
RATELIMIT_3_10S: RateLimit;
66
PUBLISHER_DON: DurableObjectNamespace /* PublisherDO */;

packages/server/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ export type {
3838
ResponseHeadersHandlerPluginContext as ResponseHeadersPluginContext,
3939
} from './response-headers'
4040
export * from './rethrow'
41+
export * from './timeout'
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import type { TimeoutHandlerPluginOptions } from './timeout'
2+
import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client'
3+
import { AbortError } from '@orpc/shared'
4+
import { RPCHandler } from '../adapters/fetch'
5+
import { os } from '../builder'
6+
import { TimeoutHandlerPlugin } from './timeout'
7+
8+
beforeEach(() => {
9+
vi.clearAllMocks()
10+
vi.useFakeTimers()
11+
})
12+
13+
afterEach(() => {
14+
expect(vi.getTimerCount()).toBe(0)
15+
vi.useRealTimers()
16+
})
17+
18+
describe('timeoutHandlerPlugin', () => {
19+
const procedure_handler = vi.fn()
20+
const procedure = os.handler(procedure_handler)
21+
22+
function makeHandler(options: TimeoutHandlerPluginOptions<any>, router: any = procedure) {
23+
return new RPCHandler(router, {
24+
allowMethods: ['GET'], // tests below send GET requests
25+
plugins: [new TimeoutHandlerPlugin(options)],
26+
})
27+
}
28+
29+
/** A sleep that honors the signal by rejecting with the abort reason, or ignores it when no signal is given. */
30+
function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
31+
return new Promise((resolve, reject) => {
32+
const timer = setTimeout(resolve, ms)
33+
signal?.addEventListener('abort', () => {
34+
clearTimeout(timer)
35+
reject(signal.reason)
36+
}, { once: true })
37+
})
38+
}
39+
40+
it('should respond and clear the timeout when the procedure finishes in time', async () => {
41+
procedure_handler.mockResolvedValueOnce('success')
42+
const handler = makeHandler({ timeout: 5000 })
43+
44+
const { response } = await handler.handle(new Request('http://localhost'))
45+
expect(response?.status).toBe(200)
46+
47+
const { signal } = procedure_handler.mock.calls[0]![0]
48+
expect(signal).toBeInstanceOf(AbortSignal)
49+
expect(signal.aborted).toBe(false)
50+
})
51+
52+
it('should abort the signal with an AbortError and respond once the procedure honors it', async () => {
53+
procedure_handler.mockImplementationOnce(({ signal }) => sleepWithSignal(100_000, signal))
54+
const handler = makeHandler({ timeout: 1000 })
55+
56+
const promise = handler.handle(new Request('http://localhost'))
57+
await vi.advanceTimersByTimeAsync(1000)
58+
59+
const { response } = await promise
60+
expect(response?.status).toBe(COMMON_ERROR_STATUS_MAP.INTERNAL_SERVER_ERROR)
61+
62+
const { signal } = procedure_handler.mock.calls[0]![0]
63+
expect(signal.aborted).toBe(true)
64+
expect(signal.reason).toBeInstanceOf(AbortError)
65+
expect(signal.reason.message).toBe('Request timed out after 1000ms')
66+
})
67+
68+
it('should not preempt a procedure that ignores the abort signal', async () => {
69+
procedure_handler.mockImplementationOnce(async () => {
70+
await sleepWithSignal(100_000)
71+
return 'late success'
72+
})
73+
const handler = makeHandler({ timeout: 1000 })
74+
75+
const promise = handler.handle(new Request('http://localhost'))
76+
77+
await vi.advanceTimersByTimeAsync(1000)
78+
expect(procedure_handler.mock.calls[0]![0].signal.aborted).toBe(true)
79+
80+
await vi.advanceTimersByTimeAsync(99_000)
81+
const { response } = await promise
82+
expect(response?.status).toBe(200) // the late result is still delivered
83+
})
84+
85+
it('should not mask procedure errors thrown after the timeout', async () => {
86+
procedure_handler.mockImplementationOnce(async () => {
87+
await sleepWithSignal(100_000)
88+
throw new ORPCError('NOT_ACCEPTABLE')
89+
})
90+
const handler = makeHandler({ timeout: 1000 })
91+
92+
const promise = handler.handle(new Request('http://localhost'))
93+
await vi.advanceTimersByTimeAsync(100_000)
94+
95+
const { response } = await promise
96+
expect(response?.status).toBe(COMMON_ERROR_STATUS_MAP.NOT_ACCEPTABLE)
97+
})
98+
99+
it('should support dynamic timeout based on interceptor options', async () => {
100+
procedure_handler.mockImplementationOnce(({ signal }) => sleepWithSignal(100_000, signal))
101+
102+
const timeout = vi.fn(({ context }: any) => context.timeout)
103+
const handler = makeHandler({ timeout })
104+
105+
const promise = handler.handle(new Request('http://localhost'), { context: { timeout: 1000 } })
106+
await vi.advanceTimersByTimeAsync(1000)
107+
108+
const { response } = await promise
109+
expect(response?.status).toBe(COMMON_ERROR_STATUS_MAP.INTERNAL_SERVER_ERROR)
110+
111+
expect(timeout).toHaveBeenCalledTimes(1)
112+
expect(timeout).toHaveBeenCalledWith(expect.objectContaining({
113+
path: [],
114+
procedure,
115+
context: { timeout: 1000 },
116+
}))
117+
})
118+
119+
it.each([null, undefined])('should dynamically disable timeout when value is %s', async (timeout) => {
120+
procedure_handler.mockImplementationOnce(() => sleepWithSignal(100_000).then(() => 'success'))
121+
const handler = makeHandler({ timeout: () => timeout })
122+
123+
const promise = handler.handle(new Request('http://localhost'))
124+
await vi.advanceTimersByTimeAsync(100_000)
125+
126+
const { response } = await promise
127+
expect(response?.status).toBe(200)
128+
})
129+
130+
it.each([
131+
['not provided', {}],
132+
['null', { streamingTimeout: () => null }],
133+
['undefined', { streamingTimeout: () => undefined }],
134+
])('should let streaming responses outlive the timeout when streamingTimeout is %s', async (_, options) => {
135+
const handler = makeHandler({ timeout: 1000, ...options }, os.handler(async function* () {
136+
yield 'first'
137+
await sleepWithSignal(100_000)
138+
yield 'second'
139+
}))
140+
141+
const { response } = await handler.handle(new Request('http://localhost'))
142+
expect(response?.status).toBe(200)
143+
144+
const textPromise = response!.text()
145+
await vi.advanceTimersByTimeAsync(100_000)
146+
147+
const text = await textPromise
148+
expect(text).toContain('first')
149+
expect(text).toContain('second')
150+
expect(text).not.toContain('event: error')
151+
})
152+
153+
it('should end event iterator bodies with an error event when streamingTimeout is exceeded', async () => {
154+
let cleaned = false
155+
const handler = makeHandler({ timeout: 1000, streamingTimeout: 2000 }, os.handler(async function* ({ signal }) {
156+
try {
157+
yield 'first'
158+
await sleepWithSignal(100_000, signal)
159+
yield 'second'
160+
}
161+
finally {
162+
cleaned = true
163+
}
164+
}))
165+
166+
const { response } = await handler.handle(new Request('http://localhost'))
167+
expect(response?.status).toBe(200)
168+
169+
const textPromise = response!.text()
170+
await vi.advanceTimersByTimeAsync(2000)
171+
172+
const text = await textPromise
173+
expect(text).toContain('first')
174+
expect(text).not.toContain('second')
175+
expect(text).toContain('event: error')
176+
expect(cleaned).toBe(true)
177+
})
178+
179+
it('should end readable stream bodies once the producer honors the signal when streamingTimeout is exceeded', async () => {
180+
const handler = new RPCHandler(procedure, {
181+
allowMethods: ['GET'],
182+
interceptors: [async ({ request }) => ({
183+
status: 200,
184+
headers: {},
185+
body: new ReadableStream<Uint8Array>({
186+
start(controller) {
187+
controller.enqueue(new TextEncoder().encode('first'))
188+
request.signal!.addEventListener('abort', () => {
189+
controller.error(request.signal!.reason)
190+
}, { once: true })
191+
},
192+
}),
193+
})],
194+
plugins: [new TimeoutHandlerPlugin({ timeout: 1000, streamingTimeout: 2000 })],
195+
})
196+
197+
const { response } = await handler.handle(new Request('http://localhost'))
198+
expect(response?.status).toBe(200)
199+
200+
const reader = response!.body!.getReader()
201+
await expect(reader.read()).resolves.toEqual({ done: false, value: new TextEncoder().encode('first') })
202+
203+
const nextRead = reader.read()
204+
nextRead.catch(() => {})
205+
await vi.advanceTimersByTimeAsync(2000)
206+
207+
await expect(nextRead).rejects.toSatisfy(error =>
208+
error instanceof AbortError && error.message === 'Request timed out after 2000ms',
209+
)
210+
})
211+
212+
it('should let streaming responses finish in time and clear the streaming timeout', async () => {
213+
const handler = makeHandler({ timeout: 1000, streamingTimeout: 5000 }, os.handler(async function* () {
214+
yield 'first'
215+
await sleepWithSignal(1000)
216+
yield 'second'
217+
}))
218+
219+
const { response } = await handler.handle(new Request('http://localhost'))
220+
const textPromise = response!.text()
221+
await vi.advanceTimersByTimeAsync(1000)
222+
223+
const text = await textPromise
224+
expect(text).toContain('first')
225+
expect(text).toContain('second')
226+
expect(text).not.toContain('event: error')
227+
})
228+
229+
it('should support dynamic streamingTimeout based on interceptor options', async () => {
230+
const streamingTimeout = vi.fn(({ context }: any) => context.streamingTimeout)
231+
const handler = makeHandler({ timeout: 1000, streamingTimeout }, os.handler(async function* ({ signal }) {
232+
yield 'first'
233+
await sleepWithSignal(100_000, signal)
234+
yield 'second'
235+
}))
236+
237+
const { response } = await handler.handle(new Request('http://localhost'), {
238+
context: { streamingTimeout: 2000 },
239+
})
240+
241+
const textPromise = response!.text()
242+
await vi.advanceTimersByTimeAsync(2000)
243+
244+
const text = await textPromise
245+
expect(text).toContain('first')
246+
expect(text).not.toContain('second')
247+
expect(text).toContain('event: error')
248+
249+
expect(streamingTimeout).toHaveBeenCalledTimes(1)
250+
expect(streamingTimeout).toHaveBeenCalledWith(expect.objectContaining({
251+
context: { streamingTimeout: 2000 },
252+
}))
253+
})
254+
255+
it('should forward abort from the request signal while the timeout is still active', async () => {
256+
procedure_handler.mockImplementationOnce(({ signal }) => sleepWithSignal(100_000, signal))
257+
const handler = makeHandler({ timeout: 5000 })
258+
259+
const controller = new AbortController()
260+
const promise = handler.handle(new Request('http://localhost', { signal: controller.signal }))
261+
262+
await vi.advanceTimersByTimeAsync(500)
263+
controller.abort(new Error('user cancelled'))
264+
265+
const { response } = await promise
266+
expect(response?.status).toBe(500) // not a timeout response
267+
})
268+
})

0 commit comments

Comments
 (0)