Skip to content

Commit 909d786

Browse files
committed
Add ky.retry() to force retries from afterResponse hooks
Fixes #778
1 parent eeaf727 commit 909d786

File tree

9 files changed

+640
-8
lines changed

9 files changed

+640
-8
lines changed

readme.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ Default: `[]`
489489

490490
This hook enables you to read and optionally modify the response. The hook function receives normalized request, options, a clone of the response, and a state object. The return value of the hook function will be used by Ky as the response object if it's an instance of [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
491491

492+
You can also force a retry by returning [`ky.retry(options)`](#kyretryoptions). This is useful when you need to retry based on the response body content, even if the response has a successful status code. The retry will respect the `retry.limit` option and be observable in `beforeRetry` hooks.
493+
492494
The `state.retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between initial requests and retries, which is useful when you need different behavior for retries (e.g., showing a notification only on the final retry).
493495

494496
```js
@@ -518,6 +520,20 @@ const response = await ky('https://example.com', {
518520
}
519521
},
520522

523+
// Or force retry based on response body content
524+
async (request, options, response) => {
525+
if (response.status === 200) {
526+
const data = await response.clone().json();
527+
if (data.error?.code === 'RATE_LIMIT') {
528+
// Retry with custom delay from API response
529+
return ky.retry({
530+
delay: data.error.retryAfter * 1000,
531+
reason: 'RATE_LIMIT'
532+
});
533+
}
534+
}
535+
},
536+
521537
// Or show a notification only on the last retry for 5xx errors
522538
(request, options, response, {retryCount}) => {
523539
if (response.status >= 500 && response.status <= 599) {
@@ -840,6 +856,73 @@ const response = await ky.post('https://example.com', options);
840856
const text = await ky('https://example.com', options).text();
841857
```
842858
859+
### ky.retry(options?)
860+
861+
Force a retry from an `afterResponse` hook.
862+
863+
This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError` with the error name `'ForceRetryError'`.
864+
865+
#### options
866+
867+
Type: `object`
868+
869+
##### delay
870+
871+
Type: `number`
872+
873+
Custom delay in milliseconds before retrying. If not provided, uses the default retry delay calculation based on `retry.delay` configuration.
874+
875+
**Note:** Custom delays bypass jitter and `backoffLimit`. This is intentional, as custom delays often come from server responses (e.g., `Retry-After` headers) and should be respected exactly as specified.
876+
877+
##### reason
878+
879+
Type: `string`
880+
881+
Reason for the retry. This will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries.
882+
883+
#### Example
884+
885+
```js
886+
import ky, {isForceRetryError} from 'ky';
887+
888+
const api = ky.extend({
889+
hooks: {
890+
afterResponse: [
891+
async (request, options, response) => {
892+
// Retry based on response body content
893+
if (response.status === 200) {
894+
const data = await response.clone().json();
895+
896+
// Simple retry with default delay
897+
if (data.error?.code === 'TEMPORARY_ERROR') {
898+
return ky.retry();
899+
}
900+
901+
// Retry with custom delay from API response
902+
if (data.error?.code === 'RATE_LIMIT') {
903+
return ky.retry({
904+
delay: data.error.retryAfter * 1000,
905+
reason: 'RATE_LIMIT'
906+
});
907+
}
908+
}
909+
}
910+
],
911+
beforeRetry: [
912+
({error, retryCount}) => {
913+
// Observable in beforeRetry hooks
914+
if (isForceRetryError(error)) {
915+
console.log(`Forced retry #${retryCount}: ${error.message}`);
916+
// Example output: "Forced retry #1: Forced retry: RATE_LIMIT"
917+
}
918+
}
919+
]
920+
}
921+
});
922+
923+
const response = await api.get('https://example.com/api');
924+
```
925+
843926
### HTTPError
844927
845928
Exposed for `instanceof` checks. The error has a `response` property with the [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response), `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request), and `options` property with normalized options (either passed to `ky` when creating an instance with `ky.create()` or directly when performing the request).

source/core/Ky.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {HTTPError} from '../errors/HTTPError.js';
22
import {NonError} from '../errors/NonError.js';
3+
import {ForceRetryError} from '../errors/ForceRetryError.js';
34
import type {
45
Input,
56
InternalOptions,
@@ -21,6 +22,7 @@ import {
2122
maxSafeTimeout,
2223
responseTypes,
2324
stop,
25+
RetryMarker,
2426
supportsAbortController,
2527
supportsAbortSignal,
2628
supportsFormData,
@@ -44,17 +46,31 @@ export class Ky {
4446
let response = await ky.#fetch();
4547

4648
for (const hook of ky.#options.hooks.afterResponse) {
49+
// Clone the response before passing to hook so we can cancel it if needed
50+
const clonedResponse = ky.#decorateResponse(response.clone());
51+
4752
// eslint-disable-next-line no-await-in-loop
4853
const modifiedResponse = await hook(
4954
ky.request,
5055
ky.#getNormalizedOptions(),
51-
ky.#decorateResponse(response.clone()),
56+
clonedResponse,
5257
{retryCount: ky.#retryCount},
5358
);
5459

5560
if (modifiedResponse instanceof globalThis.Response) {
5661
response = modifiedResponse;
5762
}
63+
64+
if (modifiedResponse instanceof RetryMarker) {
65+
// Cancel both the cloned response passed to the hook and the current response
66+
// to prevent resource leaks (especially important in Deno/Bun)
67+
// eslint-disable-next-line no-await-in-loop
68+
await Promise.all([
69+
clonedResponse.body?.cancel(),
70+
response.body?.cancel(),
71+
]);
72+
throw new ForceRetryError(modifiedResponse.options);
73+
}
5874
}
5975

6076
ky.#decorateResponse(response);
@@ -86,8 +102,9 @@ export class Ky {
86102
return response;
87103
};
88104

89-
const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase());
90-
const result = (isRetriableMethod ? ky.#retry(function_) : function_())
105+
// Always wrap in #retry to catch forced retries from afterResponse hooks
106+
// Method retriability is checked in #calculateRetryDelay for non-forced retries
107+
const result = ky.#retry(function_)
91108
.finally(async () => {
92109
const originalRequest = ky.#originalRequest;
93110
const cleanupPromises = [];
@@ -293,6 +310,16 @@ export class Ky {
293310
// Wrap non-Error throws to ensure consistent error handling
294311
const errorObject = error instanceof Error ? error : new NonError(error);
295312

313+
// Handle forced retry from afterResponse hook - skip method check and shouldRetry
314+
if (errorObject instanceof ForceRetryError) {
315+
return errorObject.customDelay ?? this.#calculateDelay();
316+
}
317+
318+
// Check if method is retriable for non-forced retries
319+
if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
320+
throw error;
321+
}
322+
296323
// User-provided shouldRetry function takes precedence over all other checks
297324
if (this.#options.retry.shouldRetry !== undefined) {
298325
const result = await this.#options.retry.shouldRetry({error: errorObject, retryCount: this.#retryCount});

source/core/constants.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,85 @@ export const usualFormBoundarySize = new TextEncoder().encode('------WebKitFormB
6262

6363
export const stop = Symbol('stop');
6464

65+
/**
66+
Options for forcing a retry via `ky.retry()`.
67+
*/
68+
export type ForceRetryOptions = {
69+
/**
70+
Custom delay in milliseconds before retrying.
71+
72+
If not provided, uses the default retry delay calculation based on `retry.delay` configuration.
73+
74+
**Note:** Custom delays bypass jitter and `backoffLimit`. This is intentional, as custom delays often come from server responses (e.g., `Retry-After` headers) and should be respected exactly as specified.
75+
*/
76+
delay?: number;
77+
78+
/**
79+
Reason for the retry.
80+
81+
This will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries.
82+
*/
83+
reason?: string;
84+
};
85+
86+
/**
87+
Marker returned by ky.retry() to signal a forced retry from afterResponse hooks.
88+
*/
89+
export class RetryMarker {
90+
constructor(public options?: ForceRetryOptions) {}
91+
}
92+
93+
/**
94+
Force a retry from an `afterResponse` hook.
95+
96+
This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError`.
97+
98+
@param options - Optional configuration for the retry.
99+
100+
@example
101+
```
102+
import ky, {isForceRetryError} from 'ky';
103+
104+
const api = ky.extend({
105+
hooks: {
106+
afterResponse: [
107+
async (request, options, response) => {
108+
// Retry based on response body content
109+
if (response.status === 200) {
110+
const data = await response.clone().json();
111+
112+
// Simple retry with default delay
113+
if (data.error?.code === 'TEMPORARY_ERROR') {
114+
return ky.retry();
115+
}
116+
117+
// Retry with custom delay from API response
118+
if (data.error?.code === 'RATE_LIMIT') {
119+
return ky.retry({
120+
delay: data.error.retryAfter * 1000,
121+
reason: 'RATE_LIMIT'
122+
});
123+
}
124+
}
125+
}
126+
],
127+
beforeRetry: [
128+
({error, retryCount}) => {
129+
// Observable in beforeRetry hooks
130+
if (isForceRetryError(error)) {
131+
console.log(`Forced retry #${retryCount}: ${error.message}`);
132+
// Example output: "Forced retry #1: Forced retry: RATE_LIMIT"
133+
}
134+
}
135+
]
136+
}
137+
});
138+
139+
const response = await api.get('https://example.com/api');
140+
```
141+
*/
142+
export const retry = (options?: ForceRetryOptions) => new RetryMarker(options);
143+
65144
export const kyOptionKeys: KyOptionsRegistry = {
66145
json: true,
67146
parseJson: true,

source/errors/ForceRetryError.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {ForceRetryOptions} from '../core/constants.js';
2+
3+
/**
4+
Internal error used to signal a forced retry from afterResponse hooks.
5+
This is thrown when a user returns ky.retry() from an afterResponse hook.
6+
*/
7+
export class ForceRetryError extends Error {
8+
override name = 'ForceRetryError' as const;
9+
customDelay: number | undefined;
10+
reason: string | undefined;
11+
12+
constructor(options?: ForceRetryOptions) {
13+
super(options?.reason ? `Forced retry: ${options.reason}` : 'Forced retry');
14+
this.customDelay = options?.delay;
15+
this.reason = options?.reason;
16+
}
17+
}

source/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*! MIT License © Sindre Sorhus */
22

33
import {Ky} from './core/Ky.js';
4-
import {requestMethods, stop} from './core/constants.js';
4+
import {requestMethods, stop, retry} from './core/constants.js';
55
import type {KyInstance} from './types/ky.js';
66
import type {Input, Options} from './types/options.js';
77
import {validateAndMerge} from './utils/merge.js';
@@ -26,6 +26,7 @@ const createInstance = (defaults?: Partial<Options>): KyInstance => {
2626
};
2727

2828
ky.stop = stop;
29+
ky.retry = retry;
2930

3031
return ky as KyInstance;
3132
};
@@ -63,7 +64,13 @@ export type {KyRequest} from './types/request.js';
6364
export type {KyResponse} from './types/response.js';
6465
export {HTTPError} from './errors/HTTPError.js';
6566
export {TimeoutError} from './errors/TimeoutError.js';
66-
export {isKyError, isHTTPError, isTimeoutError} from './utils/type-guards.js';
67+
export {ForceRetryError} from './errors/ForceRetryError.js';
68+
export {
69+
isKyError,
70+
isHTTPError,
71+
isTimeoutError,
72+
isForceRetryError,
73+
} from './utils/type-guards.js';
6774

6875
// Intentionally not exporting this for now as it's just an implementation detail and we don't want to commit to a certain API yet at least.
6976
// export {NonError} from './errors/NonError.js';

source/types/hooks.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {type stop} from '../core/constants.js';
1+
import {type stop, type RetryMarker} from '../core/constants.js';
22
import type {KyRequest, KyResponse, HTTPError} from '../index.js';
33
import type {NormalizedOptions} from './options.js';
44

@@ -43,7 +43,7 @@ export type AfterResponseHook = (
4343
options: NormalizedOptions,
4444
response: KyResponse,
4545
state: AfterResponseState
46-
) => Response | void | Promise<Response | void>;
46+
) => Response | RetryMarker | void | Promise<Response | RetryMarker | void>;
4747

4848
export type BeforeErrorState = {
4949
/**
@@ -164,6 +164,8 @@ export type Hooks = {
164164
/**
165165
This hook enables you to read and optionally modify the response. The hook function receives normalized request, options, a clone of the response, and a state object. The return value of the hook function will be used by Ky as the response object if it's an instance of [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
166166
167+
You can also force a retry by returning `ky.retry()` or `ky.retry(options)`. This is useful when you need to retry based on the response body content, even if the response has a successful status code. The retry will respect the retry limit and be observable in `beforeRetry` hooks.
168+
167169
@default []
168170
169171
@example
@@ -194,6 +196,20 @@ export type Hooks = {
194196
}
195197
},
196198
199+
// Or force retry based on response body content
200+
async (request, options, response) => {
201+
if (response.status === 200) {
202+
const data = await response.clone().json();
203+
if (data.error?.code === 'RATE_LIMIT') {
204+
// Force retry with custom delay from API response
205+
return ky.retry({
206+
delay: data.error.retryAfter * 1000,
207+
reason: 'RATE_LIMIT'
208+
});
209+
}
210+
}
211+
},
212+
197213
// Or show a notification only on the last retry for 5xx errors
198214
(request, options, response, {retryCount}) => {
199215
if (response.status >= 500 && response.status <= 599) {

0 commit comments

Comments
 (0)