Skip to content

Commit efa97f0

Browse files
committed
Add request parameter to ky.retry() for custom retry requests
1 parent 909d786 commit efa97f0

File tree

6 files changed

+851
-15
lines changed

6 files changed

+851
-15
lines changed

readme.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,16 @@ Type: `string`
880880
881881
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.
882882
883+
##### request
884+
885+
Type: `Request`
886+
887+
Custom request to use for the retry.
888+
889+
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.
890+
891+
**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.
892+
883893
#### Example
884894
885895
```js
@@ -905,6 +915,30 @@ const api = ky.extend({
905915
reason: 'RATE_LIMIT'
906916
});
907917
}
918+
919+
// Retry with a modified request (e.g., fallback endpoint)
920+
if (data.error?.code === 'FALLBACK_TO_BACKUP') {
921+
return ky.retry({
922+
request: new Request('https://backup-api.com/endpoint', {
923+
method: request.method,
924+
headers: request.headers,
925+
}),
926+
reason: 'Switching to backup endpoint'
927+
});
928+
}
929+
930+
// Retry with refreshed authentication
931+
if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) {
932+
return ky.retry({
933+
request: new Request(request, {
934+
headers: {
935+
...Object.fromEntries(request.headers),
936+
'Authorization': `Bearer ${data.newToken}`
937+
}
938+
}),
939+
reason: 'Retrying with refreshed token'
940+
});
941+
}
908942
}
909943
}
910944
],

source/core/Ky.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export class Ky {
179179
readonly #options: InternalOptions;
180180
#originalRequest?: Request;
181181
readonly #userProvidedAbortSignal?: AbortSignal;
182-
#cachedNormalizedOptions?: NormalizedOptions;
182+
#cachedNormalizedOptions: NormalizedOptions | undefined;
183183

184184
// eslint-disable-next-line complexity
185185
constructor(input: Input, options: Options = {}) {
@@ -275,11 +275,7 @@ export class Ky {
275275
throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
276276
}
277277

278-
const originalBody = this.request.body;
279-
if (originalBody) {
280-
// Pass original body to calculate size correctly (before it becomes a stream)
281-
this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body);
282-
}
278+
this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
283279
}
284280
}
285281

@@ -394,6 +390,16 @@ export class Ky {
394390
// Only use user-provided signal for delay, not our internal abortController
395391
await delay(ms, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {});
396392

393+
// Apply custom request from forced retry before beforeRetry hooks
394+
// Ensure the custom request has the correct managed signal for timeouts and user aborts
395+
if (error instanceof ForceRetryError && error.customRequest) {
396+
const managedRequest = this.#options.signal
397+
? new globalThis.Request(error.customRequest, {signal: this.#options.signal})
398+
: new globalThis.Request(error.customRequest);
399+
400+
this.#assignRequest(managedRequest);
401+
}
402+
397403
for (const hook of this.#options.hooks.beforeRetry) {
398404
// eslint-disable-next-line no-await-in-loop
399405
const hookResult = await hook({
@@ -403,9 +409,8 @@ export class Ky {
403409
retryCount: this.#retryCount,
404410
});
405411

406-
// If a Request is returned, use it for the retry
407412
if (hookResult instanceof globalThis.Request) {
408-
this.request = hookResult;
413+
this.#assignRequest(hookResult);
409414
break;
410415
}
411416

@@ -441,14 +446,14 @@ export class Ky {
441446
{retryCount: this.#retryCount},
442447
);
443448

444-
if (result instanceof Request) {
445-
this.request = result;
446-
break;
447-
}
448-
449449
if (result instanceof Response) {
450450
return result;
451451
}
452+
453+
if (result instanceof globalThis.Request) {
454+
this.#assignRequest(result);
455+
break;
456+
}
452457
}
453458

454459
const nonRequestOptions = findUnknownOptions(this.request, this.#options);
@@ -472,4 +477,17 @@ export class Ky {
472477

473478
return this.#cachedNormalizedOptions;
474479
}
480+
481+
#assignRequest(request: Request): void {
482+
this.#cachedNormalizedOptions = undefined;
483+
this.request = this.#wrapRequestWithUploadProgress(request);
484+
}
485+
486+
#wrapRequestWithUploadProgress(request: Request, originalBody?: BodyInit): Request {
487+
if (!this.#options.onUploadProgress || !request.body) {
488+
return request;
489+
}
490+
491+
return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
492+
}
475493
}

source/core/constants.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,39 @@ export type ForceRetryOptions = {
8181
This will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries.
8282
*/
8383
reason?: string;
84+
85+
/**
86+
Custom request to use for the retry.
87+
88+
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.
89+
90+
**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.
91+
92+
@example
93+
```
94+
// Fallback to a different endpoint
95+
return ky.retry({
96+
request: new Request('https://backup-api.com/endpoint', {
97+
method: request.method,
98+
headers: request.headers,
99+
}),
100+
reason: 'Falling back to backup API'
101+
});
102+
103+
// Retry with refreshed authentication token
104+
const data = await response.clone().json();
105+
return ky.retry({
106+
request: new Request(request, {
107+
headers: {
108+
...Object.fromEntries(request.headers),
109+
'Authorization': `Bearer ${data.newToken}`
110+
}
111+
}),
112+
reason: 'Retrying with refreshed token'
113+
});
114+
```
115+
*/
116+
request?: Request;
84117
};
85118

86119
/**
@@ -121,6 +154,30 @@ const api = ky.extend({
121154
reason: 'RATE_LIMIT'
122155
});
123156
}
157+
158+
// Retry with a modified request (e.g., fallback endpoint)
159+
if (data.error?.code === 'FALLBACK_TO_BACKUP') {
160+
return ky.retry({
161+
request: new Request('https://backup-api.com/endpoint', {
162+
method: request.method,
163+
headers: request.headers,
164+
}),
165+
reason: 'Switching to backup endpoint'
166+
});
167+
}
168+
169+
// Retry with refreshed authentication
170+
if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) {
171+
return ky.retry({
172+
request: new Request(request, {
173+
headers: {
174+
...Object.fromEntries(request.headers),
175+
'Authorization': `Bearer ${data.newToken}`
176+
}
177+
}),
178+
reason: 'Retrying with refreshed token'
179+
});
180+
}
124181
}
125182
}
126183
],

source/errors/ForceRetryError.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ export class ForceRetryError extends Error {
88
override name = 'ForceRetryError' as const;
99
customDelay: number | undefined;
1010
reason: string | undefined;
11+
customRequest: Request | undefined;
1112

1213
constructor(options?: ForceRetryOptions) {
1314
super(options?.reason ? `Forced retry: ${options.reason}` : 'Forced retry');
1415
this.customDelay = options?.delay;
1516
this.reason = options?.reason;
17+
this.customRequest = options?.request;
1618
}
1719
}

0 commit comments

Comments
 (0)