Skip to content

Commit 0ab5adc

Browse files
authored
feat(client): Retry After Plugin (#1190)
A plugin auto retry base on `Retry-After` response header. Fixes #1181 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a Retry After Plugin for clients enabling automatic retries with configurable conditions, max attempts, and timeouts. * **Documentation** * Added Retry After Plugin docs with examples. * Updated rate-limit handler docs to note integration with the Retry After Plugin. * Navigation updated to include the new plugin docs. * **Tests** * Added comprehensive tests covering retry behavior, parsing, timeouts, custom conditions, and abort handling. * **Bug Fixes** * Aligned rate-limit status handling with shared definitions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2d5bfb8 commit 0ab5adc

File tree

7 files changed

+502
-1
lines changed

7 files changed

+502
-1
lines changed

apps/content/.vitepress/config.ts

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

apps/content/docs/helpers/ratelimit.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ const handler = new RPCHandler(router, {
224224
})
225225
```
226226

227+
::: info
228+
You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate-limiting headers.
229+
:::
230+
227231
::: info
228232
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or other custom handlers.
229233
:::
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
title: Retry After Plugin
3+
description: A plugin for oRPC that automatically retries requests based on server Retry-After headers.
4+
---
5+
6+
# Retry After Plugin
7+
8+
The **Retry After Plugin** automatically retries requests based on server `Retry-After` headers. This is particularly useful for handling rate limiting and temporary server unavailability.
9+
10+
## Usage
11+
12+
```ts
13+
import { RetryAfterPlugin } from '@orpc/client/plugins'
14+
15+
const link = new RPCLink({
16+
url: 'http://localhost:3000/rpc',
17+
plugins: [
18+
new RetryAfterPlugin({
19+
condition: (response, options) => {
20+
// Override condition to determine if a request should be retried
21+
return response.status === 429 || response.status === 503
22+
},
23+
maxAttempts: 5, // Maximum retry attempts
24+
timeout: 5 * 60 * 1000, // Maximum time to spend retrying (ms)
25+
}),
26+
],
27+
})
28+
```
29+
30+
::: info Options
31+
32+
- **`condition`**: A function to determine whether a request should be retried. Defaults to retrying on `429` (Too Many Requests) and `503` (Service Unavailable) status codes.
33+
- **`maxAttempts`**: Maximum number of retry attempts allowed. Defaults to `3`.
34+
- **`timeout`**: Maximum duration in milliseconds to spend on retries. If specified, retries will stop once this time limit is exceeded. Defaults to `5 * 60 * 1000` (5 minutes).
35+
36+
:::
37+
38+
::: info
39+
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
40+
:::
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './batch'
22
export * from './dedupe-requests'
33
export * from './retry'
4+
export * from './retry-after'
45
export * from './simple-csrf-protection'
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import type { StandardLazyResponse } from '@orpc/standard-server'
2+
import { StandardLink } from '../adapters/standard'
3+
import { RetryAfterPlugin } from './retry-after'
4+
5+
describe('retryAfterPlugin', () => {
6+
const signal1 = AbortSignal.timeout(10000)
7+
8+
const encode = vi.fn(async (path, input, { signal }): Promise<any> => ({
9+
url: new URL(`http://localhost/${path.join('/')}`),
10+
method: 'GET',
11+
headers: {},
12+
body: input,
13+
signal,
14+
}))
15+
16+
const decode = vi.fn(async (response): Promise<unknown> => response.body())
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
vi.useFakeTimers()
21+
})
22+
23+
afterEach(() => {
24+
expect(vi.getTimerCount()).toBe(0)
25+
vi.useRealTimers()
26+
})
27+
28+
describe('core behavior', () => {
29+
it('should retry on 429/503 with retry-after header and succeed', async () => {
30+
let callCount = 0
31+
const clientCall = vi.fn(async () => {
32+
callCount++
33+
if (callCount === 1) {
34+
return {
35+
status: 429,
36+
headers: { 'retry-after': '2' },
37+
body: async () => 'rate limited',
38+
} satisfies StandardLazyResponse
39+
}
40+
return {
41+
status: 200,
42+
headers: {},
43+
body: async () => 'success',
44+
} satisfies StandardLazyResponse
45+
})
46+
47+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
48+
plugins: [new RetryAfterPlugin()],
49+
})
50+
51+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
52+
await vi.runAllTimersAsync()
53+
54+
expect(await promise).toBe('success')
55+
expect(clientCall).toHaveBeenCalledTimes(2)
56+
})
57+
58+
it('should not retry without retry-after header or on non-retryable status', async () => {
59+
const testCases = [
60+
{ status: 200, headers: {}, body: 'success' },
61+
{ status: 429, headers: {}, body: 'rate limited' },
62+
{ status: 500, headers: { 'retry-after': '1' }, body: 'internal error' },
63+
]
64+
65+
for (const { status, headers, body } of testCases) {
66+
const clientCall = vi.fn(async () => ({
67+
status,
68+
headers,
69+
body: async () => body,
70+
} satisfies StandardLazyResponse))
71+
72+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
73+
plugins: [new RetryAfterPlugin()],
74+
})
75+
76+
const result = await link.call(['test'], 'input', { context: {}, signal: signal1 })
77+
78+
expect(result).toBe(body)
79+
expect(clientCall).toHaveBeenCalledTimes(1)
80+
vi.clearAllMocks()
81+
}
82+
})
83+
})
84+
85+
describe('retry-after parsing', () => {
86+
it('should parse various retry-after formats', async () => {
87+
const testCases = [
88+
{ value: '3', description: 'seconds' },
89+
{ value: new Date(Date.now() + 5000).toUTCString(), description: 'HTTP date' },
90+
{ value: ' 2 ', description: 'whitespace' },
91+
]
92+
93+
for (const { value, description } of testCases) {
94+
let callCount = 0
95+
const clientCall = vi.fn(async () => {
96+
callCount++
97+
if (callCount === 1) {
98+
return {
99+
status: 429,
100+
headers: { 'retry-after': value },
101+
body: async () => 'rate limited',
102+
} satisfies StandardLazyResponse
103+
}
104+
return {
105+
status: 200,
106+
headers: {},
107+
body: async () => 'success',
108+
} satisfies StandardLazyResponse
109+
})
110+
111+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
112+
plugins: [new RetryAfterPlugin()],
113+
})
114+
115+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
116+
await vi.runAllTimersAsync()
117+
118+
expect(await promise).toBe('success')
119+
expect(clientCall).toHaveBeenCalledTimes(2)
120+
vi.clearAllMocks()
121+
}
122+
})
123+
124+
it('should not retry on invalid retry-after values', async () => {
125+
const invalidValues = ['invalid', '']
126+
127+
for (const value of invalidValues) {
128+
const clientCall = vi.fn(async () => ({
129+
status: 429,
130+
headers: { 'retry-after': value },
131+
body: async () => 'rate limited',
132+
} satisfies StandardLazyResponse))
133+
134+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
135+
plugins: [new RetryAfterPlugin()],
136+
})
137+
138+
const result = await link.call(['test'], 'input', { context: {}, signal: signal1 })
139+
140+
expect(result).toBe('rate limited')
141+
expect(clientCall).toHaveBeenCalledTimes(1)
142+
vi.clearAllMocks()
143+
}
144+
})
145+
})
146+
147+
describe('maxAttempts', () => {
148+
it('should respect maxAttempts (static and dynamic)', async () => {
149+
const testCases = [
150+
{ maxAttempts: undefined, expected: 3, description: 'default' },
151+
{ maxAttempts: 5, expected: 5, description: 'custom' },
152+
{ maxAttempts: vi.fn(() => 2), expected: 2, description: 'dynamic' },
153+
]
154+
155+
for (const { maxAttempts, expected, description } of testCases) {
156+
const clientCall = vi.fn(async () => ({
157+
status: 429,
158+
headers: { 'retry-after': '0' },
159+
body: async () => 'rate limited',
160+
} satisfies StandardLazyResponse))
161+
162+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
163+
plugins: [new RetryAfterPlugin(maxAttempts !== undefined ? { maxAttempts } : {})],
164+
})
165+
166+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
167+
await vi.runAllTimersAsync()
168+
169+
expect(await promise).toBe('rate limited')
170+
expect(clientCall).toHaveBeenCalledTimes(expected)
171+
172+
if (typeof maxAttempts === 'function') {
173+
expect(maxAttempts).toHaveBeenCalledWith(
174+
expect.objectContaining({ status: 429 }),
175+
expect.objectContaining({ context: {} }),
176+
)
177+
}
178+
179+
vi.clearAllMocks()
180+
}
181+
})
182+
})
183+
184+
describe('timeout and custom condition', () => {
185+
it('should stop retrying after timeout', async () => {
186+
const clientCall = vi.fn(async () => ({
187+
status: 429,
188+
headers: { 'retry-after': '3' },
189+
body: async () => 'rate limited',
190+
} satisfies StandardLazyResponse))
191+
192+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
193+
plugins: [new RetryAfterPlugin({ timeout: 5000 })],
194+
})
195+
196+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
197+
await vi.runAllTimersAsync()
198+
199+
expect(await promise).toBe('rate limited')
200+
expect(clientCall).toHaveBeenCalledTimes(2)
201+
})
202+
203+
it('should support dynamic timeout function', async () => {
204+
const clientCall = vi.fn(async () => ({
205+
status: 429,
206+
headers: { 'retry-after': '2' },
207+
body: async () => 'rate limited',
208+
} satisfies StandardLazyResponse))
209+
210+
const timeoutFn = vi.fn(() => 3000)
211+
212+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
213+
plugins: [new RetryAfterPlugin({ timeout: timeoutFn })],
214+
})
215+
216+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
217+
await vi.runAllTimersAsync()
218+
219+
expect(await promise).toBe('rate limited')
220+
expect(clientCall).toHaveBeenCalledTimes(2)
221+
expect(timeoutFn).toHaveBeenCalledWith(
222+
expect.objectContaining({ status: 429 }),
223+
expect.objectContaining({ context: {}, signal: signal1 }),
224+
)
225+
})
226+
227+
it('should respect custom condition function', async () => {
228+
let callCount = 0
229+
const clientCall = vi.fn(async () => {
230+
callCount++
231+
if (callCount === 1) {
232+
return {
233+
status: 400,
234+
headers: { 'retry-after': '1' },
235+
body: async () => 'bad request',
236+
} satisfies StandardLazyResponse
237+
}
238+
return {
239+
status: 200,
240+
headers: {},
241+
body: async () => 'success',
242+
} satisfies StandardLazyResponse
243+
})
244+
245+
const condition = vi.fn((response: StandardLazyResponse) => response.status === 400)
246+
247+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
248+
plugins: [new RetryAfterPlugin({ condition })],
249+
})
250+
251+
const promise = link.call(['test'], 'input', { context: {}, signal: signal1 })
252+
await vi.runAllTimersAsync()
253+
254+
expect(await promise).toBe('success')
255+
expect(clientCall).toHaveBeenCalledTimes(2)
256+
expect(condition).toHaveBeenCalledWith(
257+
expect.objectContaining({ status: 400 }),
258+
expect.objectContaining({ context: {}, signal: signal1 }),
259+
)
260+
})
261+
})
262+
263+
describe('signal handling', () => {
264+
it('should stop retrying when signal is aborted during delay', async () => {
265+
const clientCall = vi.fn(async () => ({
266+
status: 429,
267+
headers: { 'retry-after': '5' },
268+
body: async () => 'rate limited',
269+
} satisfies StandardLazyResponse))
270+
271+
const controller = new AbortController()
272+
273+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
274+
plugins: [new RetryAfterPlugin()],
275+
})
276+
277+
const promise = link.call(['test'], 'input', { context: {}, signal: controller.signal })
278+
279+
await vi.advanceTimersByTimeAsync(2000)
280+
controller.abort()
281+
await vi.advanceTimersByTimeAsync(3000)
282+
283+
expect(await promise).toBe('rate limited')
284+
expect(clientCall).toHaveBeenCalledTimes(1)
285+
})
286+
287+
it('should not retry if signal is already aborted', async () => {
288+
const clientCall = vi.fn(async () => ({
289+
status: 429,
290+
headers: { 'retry-after': '1' },
291+
body: async () => 'rate limited',
292+
} satisfies StandardLazyResponse))
293+
294+
const controller = new AbortController()
295+
controller.abort()
296+
297+
const link = new StandardLink({ encode, decode }, { call: clientCall }, {
298+
plugins: [new RetryAfterPlugin()],
299+
})
300+
301+
const result = await link.call(['test'], 'input', { context: {}, signal: controller.signal })
302+
303+
expect(result).toBe('rate limited')
304+
expect(clientCall).toHaveBeenCalledTimes(1)
305+
})
306+
})
307+
})

0 commit comments

Comments
 (0)