Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@ Default: `[]`

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).

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.

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).

```js
Expand Down Expand Up @@ -518,6 +520,20 @@ const response = await ky('https://example.com', {
}
},

// Or force retry based on response body content
async (request, options, response) => {
if (response.status === 200) {
const data = await response.clone().json();
if (data.error?.code === 'RATE_LIMIT') {
// Retry with custom delay from API response
return ky.retry({
delay: data.error.retryAfter * 1000,
code: 'RATE_LIMIT'
});
}
}
},

// Or show a notification only on the last retry for 5xx errors
(request, options, response, {retryCount}) => {
if (response.status >= 500 && response.status <= 599) {
Expand Down Expand Up @@ -842,6 +858,142 @@ const response = await ky.post('https://example.com', options);
const text = await ky('https://example.com', options).text();
```
### ky.retry(options?)
Force a retry from an `afterResponse` hook.
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'`.
#### options
Type: `object`
##### delay
Type: `number`
Custom delay in milliseconds before retrying. If not provided, uses the default retry delay calculation based on `retry.delay` configuration.
**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.
##### code
Type: `string`
Error code for the retry.
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.
```js
return ky.retry({code: 'RATE_LIMIT'});
// Resulting error message: 'Forced retry: RATE_LIMIT'
```
##### cause
Type: `Error`
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.
```js
try {
const data = await response.clone().json();
validateBusinessLogic(data);
} catch (error) {
return ky.retry({
code: 'VALIDATION_FAILED',
cause: error // Preserves original error in chain
});
}
```
##### request
Type: `Request`
Custom request to use for the retry.
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.
**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.
#### Example
```js
import ky, {isForceRetryError} from 'ky';

const api = ky.extend({
hooks: {
afterResponse: [
async (request, options, response) => {
// Retry based on response body content
if (response.status === 200) {
const data = await response.clone().json();

// Simple retry with default delay
if (data.error?.code === 'TEMPORARY_ERROR') {
return ky.retry();
}

// Retry with custom delay from API response
if (data.error?.code === 'RATE_LIMIT') {
return ky.retry({
delay: data.error.retryAfter * 1000,
code: 'RATE_LIMIT'
});
}

// Retry with a modified request (e.g., fallback endpoint)
if (data.error?.code === 'FALLBACK_TO_BACKUP') {
return ky.retry({
request: new Request('https://backup-api.com/endpoint', {
method: request.method,
headers: request.headers,
}),
code: 'BACKUP_ENDPOINT'
});
}

// Retry with refreshed authentication
if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) {
return ky.retry({
request: new Request(request, {
headers: {
...Object.fromEntries(request.headers),
'Authorization': `Bearer ${data.newToken}`
}
}),
code: 'TOKEN_REFRESHED'
});
}

// Retry with cause to preserve error chain
try {
validateResponse(data);
} catch (error) {
return ky.retry({
code: 'VALIDATION_FAILED',
cause: error
});
}
}
}
],
beforeRetry: [
({error, retryCount}) => {
// Observable in beforeRetry hooks
if (isForceRetryError(error)) {
console.log(`Forced retry #${retryCount}: ${error.message}`);
// Example output: "Forced retry #1: Forced retry: RATE_LIMIT"
}
}
]
}
});

const response = await api.get('https://example.com/api');
```
### HTTPError
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).
Expand Down
77 changes: 61 additions & 16 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {HTTPError} from '../errors/HTTPError.js';
import {NonError} from '../errors/NonError.js';
import {ForceRetryError} from '../errors/ForceRetryError.js';
import type {
Input,
InternalOptions,
Expand All @@ -21,6 +22,7 @@ import {
maxSafeTimeout,
responseTypes,
stop,
RetryMarker,
supportsAbortController,
supportsAbortSignal,
supportsFormData,
Expand All @@ -44,17 +46,31 @@ export class Ky {
let response = await ky.#fetch();

for (const hook of ky.#options.hooks.afterResponse) {
// Clone the response before passing to hook so we can cancel it if needed
const clonedResponse = ky.#decorateResponse(response.clone());

// eslint-disable-next-line no-await-in-loop
const modifiedResponse = await hook(
ky.request,
ky.#getNormalizedOptions(),
ky.#decorateResponse(response.clone()),
clonedResponse,
{retryCount: ky.#retryCount},
);

if (modifiedResponse instanceof globalThis.Response) {
response = modifiedResponse;
}

if (modifiedResponse instanceof RetryMarker) {
// Cancel both the cloned response passed to the hook and the current response
// to prevent resource leaks (especially important in Deno/Bun)
// eslint-disable-next-line no-await-in-loop
await Promise.all([
clonedResponse.body?.cancel(),
response.body?.cancel(),
]);
throw new ForceRetryError(modifiedResponse.options);
}
}

ky.#decorateResponse(response);
Expand Down Expand Up @@ -90,8 +106,9 @@ export class Ky {
return response;
};

const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase());
const result = (isRetriableMethod ? ky.#retry(function_) : function_())
// Always wrap in #retry to catch forced retries from afterResponse hooks
// Method retriability is checked in #calculateRetryDelay for non-forced retries
const result = ky.#retry(function_)
.finally(async () => {
const originalRequest = ky.#originalRequest;
const cleanupPromises = [];
Expand Down Expand Up @@ -166,7 +183,7 @@ export class Ky {
readonly #options: InternalOptions;
#originalRequest?: Request;
readonly #userProvidedAbortSignal?: AbortSignal;
#cachedNormalizedOptions?: NormalizedOptions;
#cachedNormalizedOptions: NormalizedOptions | undefined;

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

const originalBody = this.request.body;
if (originalBody) {
// Pass original body to calculate size correctly (before it becomes a stream)
this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body);
}
this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
}
}

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

// Handle forced retry from afterResponse hook - skip method check and shouldRetry
if (errorObject instanceof ForceRetryError) {
return errorObject.customDelay ?? this.#calculateDelay();
}

// Check if method is retriable for non-forced retries
if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
throw error;
}

// User-provided shouldRetry function takes precedence over all other checks
if (this.#options.retry.shouldRetry !== undefined) {
const result = await this.#options.retry.shouldRetry({error: errorObject, retryCount: this.#retryCount});
Expand Down Expand Up @@ -371,6 +394,16 @@ export class Ky {
// Only use user-provided signal for delay, not our internal abortController
await delay(ms, this.#userProvidedAbortSignal ? {signal: this.#userProvidedAbortSignal} : {});

// Apply custom request from forced retry before beforeRetry hooks
// Ensure the custom request has the correct managed signal for timeouts and user aborts
if (error instanceof ForceRetryError && error.customRequest) {
const managedRequest = this.#options.signal
? new globalThis.Request(error.customRequest, {signal: this.#options.signal})
: new globalThis.Request(error.customRequest);

this.#assignRequest(managedRequest);
}

for (const hook of this.#options.hooks.beforeRetry) {
// eslint-disable-next-line no-await-in-loop
const hookResult = await hook({
Expand All @@ -380,9 +413,8 @@ export class Ky {
retryCount: this.#retryCount,
});

// If a Request is returned, use it for the retry
if (hookResult instanceof globalThis.Request) {
this.request = hookResult;
this.#assignRequest(hookResult);
break;
}

Expand Down Expand Up @@ -418,14 +450,14 @@ export class Ky {
{retryCount: this.#retryCount},
);

if (result instanceof Request) {
this.request = result;
break;
}

if (result instanceof Response) {
return result;
}

if (result instanceof globalThis.Request) {
this.#assignRequest(result);
break;
}
}

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

return this.#cachedNormalizedOptions;
}

#assignRequest(request: Request): void {
this.#cachedNormalizedOptions = undefined;
this.request = this.#wrapRequestWithUploadProgress(request);
}

#wrapRequestWithUploadProgress(request: Request, originalBody?: BodyInit): Request {
if (!this.#options.onUploadProgress || !request.body) {
return request;
}

return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
}
}
Loading