Skip to content

Commit 68a5228

Browse files
authored
Add ky.retry() to force retries from afterResponse hooks (#779)
1 parent 5d3684e commit 68a5228

File tree

10 files changed

+1677
-23
lines changed

10 files changed

+1677
-23
lines changed

readme.md

Lines changed: 152 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+
code: '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) {
@@ -842,6 +858,142 @@ const response = await ky.post('https://example.com', options);
842858
const text = await ky('https://example.com', options).text();
843859
```
844860
861+
### ky.retry(options?)
862+
863+
Force a retry from an `afterResponse` hook.
864+
865+
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'`.
866+
867+
#### options
868+
869+
Type: `object`
870+
871+
##### delay
872+
873+
Type: `number`
874+
875+
Custom delay in milliseconds before retrying. If not provided, uses the default retry delay calculation based on `retry.delay` configuration.
876+
877+
**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.
878+
879+
##### code
880+
881+
Type: `string`
882+
883+
Error code for the retry.
884+
885+
This machine-readable identifier will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries.
886+
887+
```js
888+
return ky.retry({code: 'RATE_LIMIT'});
889+
// Resulting error message: 'Forced retry: RATE_LIMIT'
890+
```
891+
892+
##### cause
893+
894+
Type: `Error`
895+
896+
Original error that caused the retry. This allows you to preserve the error chain when forcing a retry based on caught exceptions. The error will be set as the `cause` of the `ForceRetryError`, enabling proper error chain traversal.
897+
898+
```js
899+
try {
900+
const data = await response.clone().json();
901+
validateBusinessLogic(data);
902+
} catch (error) {
903+
return ky.retry({
904+
code: 'VALIDATION_FAILED',
905+
cause: error // Preserves original error in chain
906+
});
907+
}
908+
```
909+
910+
##### request
911+
912+
Type: `Request`
913+
914+
Custom request to use for the retry.
915+
916+
This allows you to modify or completely replace the request during a forced retry. The custom request becomes the starting point for the retry - `beforeRetry` hooks can still further modify it if needed.
917+
918+
**Note:** The custom request's `signal` will be replaced with Ky's managed signal to handle timeouts and user-provided abort signals correctly. If the original request body has been consumed, you must provide a new body or clone the request before consuming.
919+
920+
#### Example
921+
922+
```js
923+
import ky, {isForceRetryError} from 'ky';
924+
925+
const api = ky.extend({
926+
hooks: {
927+
afterResponse: [
928+
async (request, options, response) => {
929+
// Retry based on response body content
930+
if (response.status === 200) {
931+
const data = await response.clone().json();
932+
933+
// Simple retry with default delay
934+
if (data.error?.code === 'TEMPORARY_ERROR') {
935+
return ky.retry();
936+
}
937+
938+
// Retry with custom delay from API response
939+
if (data.error?.code === 'RATE_LIMIT') {
940+
return ky.retry({
941+
delay: data.error.retryAfter * 1000,
942+
code: 'RATE_LIMIT'
943+
});
944+
}
945+
946+
// Retry with a modified request (e.g., fallback endpoint)
947+
if (data.error?.code === 'FALLBACK_TO_BACKUP') {
948+
return ky.retry({
949+
request: new Request('https://backup-api.com/endpoint', {
950+
method: request.method,
951+
headers: request.headers,
952+
}),
953+
code: 'BACKUP_ENDPOINT'
954+
});
955+
}
956+
957+
// Retry with refreshed authentication
958+
if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) {
959+
return ky.retry({
960+
request: new Request(request, {
961+
headers: {
962+
...Object.fromEntries(request.headers),
963+
'Authorization': `Bearer ${data.newToken}`
964+
}
965+
}),
966+
code: 'TOKEN_REFRESHED'
967+
});
968+
}
969+
970+
// Retry with cause to preserve error chain
971+
try {
972+
validateResponse(data);
973+
} catch (error) {
974+
return ky.retry({
975+
code: 'VALIDATION_FAILED',
976+
cause: error
977+
});
978+
}
979+
}
980+
}
981+
],
982+
beforeRetry: [
983+
({error, retryCount}) => {
984+
// Observable in beforeRetry hooks
985+
if (isForceRetryError(error)) {
986+
console.log(`Forced retry #${retryCount}: ${error.message}`);
987+
// Example output: "Forced retry #1: Forced retry: RATE_LIMIT"
988+
}
989+
}
990+
]
991+
}
992+
});
993+
994+
const response = await api.get('https://example.com/api');
995+
```
996+
845997
### HTTPError
846998
847999
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: 61 additions & 16 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);
@@ -90,8 +106,9 @@ export class Ky {
90106
return response;
91107
};
92108

93-
const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase());
94-
const result = (isRetriableMethod ? ky.#retry(function_) : function_())
109+
// Always wrap in #retry to catch forced retries from afterResponse hooks
110+
// Method retriability is checked in #calculateRetryDelay for non-forced retries
111+
const result = ky.#retry(function_)
95112
.finally(async () => {
96113
const originalRequest = ky.#originalRequest;
97114
const cleanupPromises = [];
@@ -166,7 +183,7 @@ export class Ky {
166183
readonly #options: InternalOptions;
167184
#originalRequest?: Request;
168185
readonly #userProvidedAbortSignal?: AbortSignal;
169-
#cachedNormalizedOptions?: NormalizedOptions;
186+
#cachedNormalizedOptions: NormalizedOptions | undefined;
170187

171188
// eslint-disable-next-line complexity
172189
constructor(input: Input, options: Options = {}) {
@@ -262,11 +279,7 @@ export class Ky {
262279
throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
263280
}
264281

265-
const originalBody = this.request.body;
266-
if (originalBody) {
267-
// Pass original body to calculate size correctly (before it becomes a stream)
268-
this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body);
269-
}
282+
this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
270283
}
271284
}
272285

@@ -297,6 +310,16 @@ export class Ky {
297310
// Wrap non-Error throws to ensure consistent error handling
298311
const errorObject = error instanceof Error ? error : new NonError(error);
299312

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+
300323
// User-provided shouldRetry function takes precedence over all other checks
301324
if (this.#options.retry.shouldRetry !== undefined) {
302325
const result = await this.#options.retry.shouldRetry({error: errorObject, retryCount: this.#retryCount});
@@ -371,6 +394,16 @@ export class Ky {
371394
// Only use user-provided signal for delay, not our internal abortController
372395
await delay(ms, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {});
373396

397+
// Apply custom request from forced retry before beforeRetry hooks
398+
// Ensure the custom request has the correct managed signal for timeouts and user aborts
399+
if (error instanceof ForceRetryError && error.customRequest) {
400+
const managedRequest = this.#options.signal
401+
? new globalThis.Request(error.customRequest, {signal: this.#options.signal})
402+
: new globalThis.Request(error.customRequest);
403+
404+
this.#assignRequest(managedRequest);
405+
}
406+
374407
for (const hook of this.#options.hooks.beforeRetry) {
375408
// eslint-disable-next-line no-await-in-loop
376409
const hookResult = await hook({
@@ -380,9 +413,8 @@ export class Ky {
380413
retryCount: this.#retryCount,
381414
});
382415

383-
// If a Request is returned, use it for the retry
384416
if (hookResult instanceof globalThis.Request) {
385-
this.request = hookResult;
417+
this.#assignRequest(hookResult);
386418
break;
387419
}
388420

@@ -418,14 +450,14 @@ export class Ky {
418450
{retryCount: this.#retryCount},
419451
);
420452

421-
if (result instanceof Request) {
422-
this.request = result;
423-
break;
424-
}
425-
426453
if (result instanceof Response) {
427454
return result;
428455
}
456+
457+
if (result instanceof globalThis.Request) {
458+
this.#assignRequest(result);
459+
break;
460+
}
429461
}
430462

431463
const nonRequestOptions = findUnknownOptions(this.request, this.#options);
@@ -449,4 +481,17 @@ export class Ky {
449481

450482
return this.#cachedNormalizedOptions;
451483
}
484+
485+
#assignRequest(request: Request): void {
486+
this.#cachedNormalizedOptions = undefined;
487+
this.request = this.#wrapRequestWithUploadProgress(request);
488+
}
489+
490+
#wrapRequestWithUploadProgress(request: Request, originalBody?: BodyInit): Request {
491+
if (!this.#options.onUploadProgress || !request.body) {
492+
return request;
493+
}
494+
495+
return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
496+
}
452497
}

0 commit comments

Comments
 (0)