Skip to content
D
Documentation

Retry failed requests

how-to
5 min readUpdated

Use a bounded retry policy when a request can recover from a temporary network failure, rate limit, or server error. Configure ky through its retry option; you do not need a separate retry loop.

By default, non-2xx responses become HTTPError instances, and eligible failures pass through the retry policy. A successful operation returns a response; an operation that exhausts its retries rejects with the final error.

The examples run in a browser or Node.js with Fetch support. Install Ky in your project:

bash
npm install ky

The repository README describes the next version of Ky. When using the published package, consult the current-version documentation rather than assuming every README API is available.

1. Set eligibility, delays, and timeout budgets

This request fetches users from your API. If it encounters an eligible failure, look for the retry count in the console and the final response status or error. The policy allows three retries after the initial attempt, unless the overall timeout ends the operation first. Replace https://api.example.com/users with your API endpoint.

Use isHTTPError to inspect an HTTP failure and isTimeoutError to identify a timeout. The example reports the status or URL from your result, rather than assuming which failure wins.

ts
import ky, {isHTTPError, isTimeoutError} from 'ky';

(async () => {
try {
	const response = await ky.get('https://api.example.com/users', {
		timeout: 2000,
		totalTimeout: 10_000,
		retry: {
			limit: 3,
			methods: ['get'],
			statusCodes: [429, 503],
			afterStatusCodes: [429, 503],
			maxRetryAfter: 2000,
			delay: attemptCount => 300 * 2 ** (attemptCount - 1),
			backoffLimit: 1500,
			jitter: true,
			retryOnTimeout: true,
		},
		hooks: {
			beforeRetry: [({retryCount}) => {
				console.log('Starting retry:', retryCount);
			}],
		},
	});
	console.log('Response status:', response.status);
} catch (error) {
	if (isHTTPError(error)) {
		console.log('Final HTTP status:', error.response.status);
	} else if (isTimeoutError(error)) {
		console.log('Timeout for:', error.request.url);
	} else {
		throw error;
	}
}
})().catch(error => console.log('Request ended:', error));

limit counts retries, not the initial attempt. Set it to a finite, non-negative integer. Numeric shorthand such as retry: 3 changes only the limit. Automatic retries also require an eligible method; post and patch are not in the default method list.

delay receives the retry attempt count, starting at 1, and returns milliseconds. The default exponential delay starts at 300 ms, then 600 ms. Full jitter (jitter: true) randomizes the computed delay between zero and that value, and backoffLimit caps the result. You can instead pass a jitter function that receives the computed delay and returns milliseconds.

For statuses in both statusCodes and afterStatusCodes, Ky uses Retry-After or a supported rate-limit timing header when available. Retry-After accepts seconds or an HTTP-date. If it is missing, Ky checks RateLimit-Reset, X-RateLimit-Retry-After, X-RateLimit-Reset, and X-Rate-Limit-Reset. Server timing bypasses jitter and backoffLimit; use maxRetryAfter to cap it. HTTP 413 requires a retry timing header under the default retry checks.

If a retry delay consumes the remaining totalTimeout budget, Ky times out without starting another attempt; see How Ky works for per-attempt, body-read, and overall timeout boundaries.

Enable retryOnTimeout to retry timeouts before a response arrives. Timeouts while a body shortcut reads an already received response do not trigger retries.

2. Decide whether to retry a failure

Use shouldRetry to make the retry decision. It runs only after the limit and method checks pass. Return true to override the default checks, false to stop, or undefined to keep the default logic. Recognized network failures are retried by default for eligible methods; unrecognized errors are not.

This policy allows only two retries for HTTP 429, even though the configured limit is three. Other HTTP 4xx errors stop immediately, and other failures retain the default policy. Replace https://api.example.com/users with your API endpoint; a successful request returns its response without retrying.

ts
import ky, {isHTTPError} from 'ky';

(async () => {
try {
	const response = await ky.get('https://api.example.com/users', {
		retry: {
			limit: 3,
			shouldRetry: ({error, retryCount}) => {
				if (isHTTPError(error)) {
					const status = error.response.status;
					if (status === 429) {
						return retryCount <= 2;
					}
					if (status >= 400 && status < 500) {
						return false;
					}
				}
				return undefined;
			},
		},
	});
	console.log('Response status:', response.status);
} catch (error) {
	console.log('Request ended:', error);
}
})().catch(error => console.log('Request ended:', error));

Returning true bypasses status checks, retryOnTimeout, and server-timing selection; Ky uses the configured delay calculation instead. Return undefined when you want the normal status and timing rules to apply.

3. Retry based on response content

Return ky.retry() from afterResponse when a successful HTTP response contains a temporary application error. The hook receives a response clone, so reading its JSON does not consume the response returned to your caller.

For an API that returns { "error": { "code": "TEMPORARY_ERROR" } } with HTTP 200, this example requests another attempt with a one-second delay. Other successful JSON responses pass through to the caller. Replace https://api.example.com/jobs/42 with your API endpoint.

Use isForceRetryError in beforeRetry to identify the forced retry and read its code.

ts
import ky, {isForceRetryError} from 'ky';

const api = ky.extend({
	retry: {limit: 2},
	totalTimeout: 15_000,
	hooks: {
		afterResponse: [async ({response}) => {
			if (response.status === 200) {
				const data = await response.json<{
					error?: {code?: string};
				}>();
				if (data.error?.code === 'TEMPORARY_ERROR') {
					return ky.retry({delay: 1000, code: 'TEMPORARY_ERROR'});
				}
			}
		}],
		beforeRetry: [({error, retryCount}) => {
			if (isForceRetryError(error)) {
				console.log('Forced retry:', retryCount, error.code);
			}
		}],
	},
});

(async () => {
try {
	const result = await api.get('https://api.example.com/jobs/42').json();
	console.log('Final JSON:', result);
} catch (error) {
	console.log('Request ended:', error);
}
})().catch(error => console.log('Request ended:', error));

Forced retries still respect retry.limit and the overall timeout, but skip the method check and shouldRetry. If the response continues to request retries after the limit, the operation rejects with a ForceRetryError.

Omit delay in ky.retry() to use the configured retry delay calculation. An explicit delay bypasses both jitter and backoffLimit. The optional code identifies your retry reason in the error passed to hooks.

4. Stop a confirmed retry early

beforeRetry runs after Ky confirms a retry and waits for its delay. Use it to modify the retry request or throw to stop the operation. If this request encounters repeated eligible failures, it sends a retry-count header on the first retry, then throws before a second retry is sent. Replace https://api.example.com/users with your API endpoint.

ts
import ky from 'ky';

(async () => {
try {
	const response = await ky.get('https://api.example.com/users', {
		retry: {limit: 3},
		hooks: {
			beforeRetry: [({request, retryCount}) => {
				if (retryCount > 1) {
					throw new Error('Stopping after one retry');
				}
				request.headers.set('X-Retry-Count', String(retryCount));
			}],
		},
	});
	console.log('Response status:', response.status);
} catch (error) {
	console.log('Request ended:', error);
}
})().catch(error => console.log('Request ended:', error));

Throwing stops the remaining beforeRetry hooks and rejects the operation. Prefer throwing to returning ky.stop: that symbol resolves the operation with undefined, so response property access and body shortcuts such as .json() are invalid. If you deliberately use ky.stop, await the request without a body shortcut and check that a response exists before accessing it.

Keep retry-attempt changes out of beforeRequest, where thrown errors are fatal rather than retried; see How Ky works for the retry decision points and hook stages.

Options at a glance

The retry.* fields belong inside the retry object; the timeout options sit alongside it.

OptionTypeDefaultWhat it does
retry.limitnumber2Bounds the number of retries; 0 disables them.
retry.methodsreadonly string[]['get', 'put', 'head', 'delete', 'options', 'trace', 'query']Selects methods eligible for automatic retries.
retry.statusCodesreadonly number[][408, 413, 429, 500, 502, 503, 504]Selects HTTP statuses eligible under the default checks.
retry.afterStatusCodesreadonly number[][413, 429, 503]Selects eligible statuses that use server retry timing.
retry.maxRetryAfternumberInfinityCaps server-provided delays in milliseconds.
retry.backoffLimitnumberInfinityCaps calculated delays in milliseconds after jitter.
retry.delay(attemptCount: number) => numberattemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000Calculates delay in milliseconds.
retry.jitterboolean | ((delay: number) => number)undefinedAdds randomness to calculated delays.
retry.retryOnTimeoutbooleanfalseAllows retries for pre-response timeouts.
retry.shouldRetry({error: Error, retryCount: number}) => boolean | void | Promise<boolean | void>undefinedOverrides or defers to default retry checks.
timeoutnumber | false10000Sets the per-attempt timeout in milliseconds; false disables it.
totalTimeoutnumber | falsefalseBounds the operation in milliseconds; false leaves it unbounded overall.

Enabled retries buffer an entire streaming request body in memory so it can be replayed. For large streaming uploads that do not need retries, set retry: {limit: 0}. See Transfer files and streams.

Was this page helpful?