Add retry.jitter option

sindresorhussindresorhus/ky #760+272−1Merged Oct 15, 2025

Overview

Ky can now add random jitter to retry delays, so a crowd of clients that failed together no longer retries at the same instant and knocks the server over again.

  1. A new retry.jitter option is off by default and accepts true or a (delay) => number function.
  2. After the backoff delay is computed, true picks a random delay between zero and that value.
  3. A custom function can reshape the delay; non-finite or negative results fall back to the original.
  4. backoffLimit still caps the final value, and a server Retry-After skips jitter entirely.
Before / after+4−1
How a retry delay is chosen
calculateRetryDelay(error)
if (retryAfter) return afterserver timing still wins, never jittered
retryDelay = retry.delay(retryCount)
− return min(backoffLimit, retryDelay)every client waited exactly as long01
+ if (jitter === true) delay = random() * retryDelayfull jitter spreads retries out01
+ else if (jitter) delay = jitter(retryDelay)bad results fall back to retryDelay01
+ return min(backoffLimit, delay)the cap still applies after jitter01
+defaults.retry.jitter = undefinedoff unless you opt in02

In this guide

Randomize the retry delay

01 / 04

The delay calculation in Ky used to return the exponential backoff value directly, so every client that failed at the same moment slept for the same time and retried together. It now passes that value through retry.jitter first: with true it picks Math.random() * retryDelay ("full jitter"); with a function it lets the caller decide.

A custom function's result is only trusted if it is a finite, non-negative number; anything else falls back to the un-jittered delay. The existing backoffLimit cap is applied after jitter, and the earlier Retry-After branch returns before any of this runs.

Ky.tssource/core/+13−1
Ky.tssource/core/+13−1
11import {HTTPError} from '../errors/HTTPError.js';
22import type {
33 Input,
44 InternalOptions,
55 NormalizedOptions,
66 Options,
77 SearchParamsInit,
88 SearchParamsOption,
99} from '../types/options.js';
1010import {type ResponsePromise} from '../types/ResponsePromise.js';
1111import {streamRequest, streamResponse} from '../utils/body.js';
1212import {mergeHeaders, mergeHooks} from '../utils/merge.js';
1313import {normalizeRequestMethod, normalizeRetryOptions} from '../utils/normalize.js';
1414import timeout, {type TimeoutOptions} from '../utils/timeout.js';
1515import delay from '../utils/delay.js';
1616import {type ObjectEntries} from '../utils/types.js';
1717import {findUnknownOptions, hasSearchParameters} from '../utils/options.js';
1818import {isHTTPError, isTimeoutError} from '../utils/type-guards.js';
1919import {
2020 maxSafeTimeout,
2121 responseTypes,
2222 stop,
2323 supportsAbortController,
2424 supportsAbortSignal,
2525 supportsFormData,
2626 supportsResponseStreams,
2727 supportsRequestStreams,
2828} from './constants.js';
2929
3030export class Ky {
3131 static create(input: Input, options: Options): ResponsePromise {
3232 const ky = new Ky(input, options);
3333
3434 const function_ = async (): Promise<Response> => {
3535 if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) {
3636 throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
3737 }
3838
3939 // Delay the fetch so that body method shortcuts can set the Accept header
4040 await Promise.resolve();
4141 // Before using ky.request, _fetch clones it and saves the clone for future retries to use.
4242 // If retry is not needed, close the cloned request's ReadableStream for memory safety.
4343 let response = await ky.#fetch();
4444
4545 for (const hook of ky.#options.hooks.afterResponse) {
4646 // eslint-disable-next-line no-await-in-loop
4747 const modifiedResponse = await hook(
4848 ky.request,
4949 ky.#getNormalizedOptions(),
5050 ky.#decorateResponse(response.clone()),
5151 {retryCount: ky.#retryCount},
5252 );
5353
5454 if (modifiedResponse instanceof globalThis.Response) {
5555 response = modifiedResponse;
5656 }
5757 }
5858
5959 ky.#decorateResponse(response);
6060
6161 if (!response.ok && ky.#options.throwHttpErrors) {
6262 let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions());
6363
6464 for (const hook of ky.#options.hooks.beforeError) {
6565 // eslint-disable-next-line no-await-in-loop
6666 error = await hook(error, {retryCount: ky.#retryCount});
6767 }
6868
6969 throw error;
7070 }
7171
7272 // If `onDownloadProgress` is passed, it uses the stream API internally
7373 if (ky.#options.onDownloadProgress) {
7474 if (typeof ky.#options.onDownloadProgress !== 'function') {
7575 throw new TypeError('The `onDownloadProgress` option must be a function');
7676 }
7777
7878 if (!supportsResponseStreams) {
7979 throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
8080 }
8181
8282 return streamResponse(response.clone(), ky.#options.onDownloadProgress);
8383 }
8484
8585 return response;
8686 };
8787
8888 const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase());
8989 const result = (isRetriableMethod ? ky.#retry(function_) : function_())
9090 .finally(async () => {
9191 const originalRequest = ky.#originalRequest;
9292 const cleanupPromises = [];
9393
9494 if (originalRequest && !originalRequest.bodyUsed) {
9595 cleanupPromises.push(originalRequest.body?.cancel());
9696 }
9797
9898 if (!ky.request.bodyUsed) {
9999 cleanupPromises.push(ky.request.body?.cancel());
100100 }
101101
102102 await Promise.all(cleanupPromises);
103103 }) as ResponsePromise;
104104
105105 for (const [type, mimeType] of Object.entries(responseTypes) as ObjectEntries<typeof responseTypes>) {
106106 // Only expose `.bytes()` when the environment implements it.
107107 if (
108108 type === 'bytes'
109109 && typeof (globalThis.Response?.prototype as unknown as {bytes?: unknown})?.bytes !== 'function'
110110 ) {
111111 continue;
112112 }
113113
114114 result[type] = async () => {
115115 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
116116 ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
117117
118118 const response = await result;
119119
120120 if (type === 'json') {
121121 if (response.status === 204) {
122122 return '';
123123 }
124124
125125 const text = await response.text();
126126 if (text === '') {
127127 return '';
128128 }
129129
130130 if (options.parseJson) {
131131 return options.parseJson(text);
132132 }
133133
134134 return JSON.parse(text);
135135 }
136136
137137 return response[type]();
138138 };
139139 }
140140
141141 return result;
142142 }
143143
144144 // eslint-disable-next-line unicorn/prevent-abbreviations
145145 static #normalizeSearchParams(searchParams: SearchParamsOption): SearchParamsOption {
146146 // Filter out undefined values from plain objects
147147 if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) {
148148 return Object.fromEntries(
149149 Object.entries(searchParams).filter(([, value]) => value !== undefined),
150150 );
151151 }
152152
153153 return searchParams;
154154 }
155155
156156 public request: Request;
157157 readonly #abortController?: AbortController;
158158 #retryCount = 0;
159159 // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202
160160 #input: Input;
161161 readonly #options: InternalOptions;
162162 #originalRequest?: Request;
163163 #cachedNormalizedOptions?: NormalizedOptions;
164164
165165 // eslint-disable-next-line complexity
166166 constructor(input: Input, options: Options = {}) {
167167 this.#input = input;
168168
169169 this.#options = {
170170 ...options,
171171 headers: mergeHeaders((this.#input as Request).headers, options.headers),
172172 hooks: mergeHooks(
173173 {
174174 beforeRequest: [],
175175 beforeRetry: [],
176176 beforeError: [],
177177 afterResponse: [],
178178 },
179179 options.hooks,
180180 ),
181181 method: normalizeRequestMethod(options.method ?? (this.#input as Request).method ?? 'GET'),
182182 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
183183 prefixUrl: String(options.prefixUrl || ''),
184184 retry: normalizeRetryOptions(options.retry),
185185 throwHttpErrors: options.throwHttpErrors !== false,
186186 timeout: options.timeout ?? 10_000,
187187 fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
188188 };
189189
190190 if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) {
191191 throw new TypeError('`input` must be a string, URL, or Request');
192192 }
193193
194194 if (this.#options.prefixUrl && typeof this.#input === 'string') {
195195 if (this.#input.startsWith('/')) {
196196 throw new Error('`input` must not begin with a slash when using `prefixUrl`');
197197 }
198198
199199 if (!this.#options.prefixUrl.endsWith('/')) {
200200 this.#options.prefixUrl += '/';
201201 }
202202
203203 this.#input = this.#options.prefixUrl + this.#input;
204204 }
205205
206206 if (supportsAbortController && supportsAbortSignal) {
207207 const originalSignal = this.#options.signal ?? (this.#input as Request).signal;
208208 this.#abortController = new globalThis.AbortController();
209209 this.#options.signal = originalSignal ? AbortSignal.any([originalSignal, this.#abortController.signal]) : this.#abortController.signal;
210210 }
211211
212212 if (supportsRequestStreams) {
213213 // @ts-expect-error - Types are outdated.
214214 this.#options.duplex = 'half';
215215 }
216216
217217 if (this.#options.json !== undefined) {
218218 this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json);
219219 this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json');
220220 }
221221
222222 // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body
223223 // Only delete if user didn't explicitly provide a custom content-type
224224 const userProvidedContentType = options.headers && new globalThis.Headers(options.headers as HeadersInit).has('content-type');
225225 if (
226226 this.#input instanceof globalThis.Request
227227 && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams)
228228 && !userProvidedContentType
229229 ) {
230230 this.#options.headers.delete('content-type');
231231 }
232232
233233 this.request = new globalThis.Request(this.#input, this.#options);
234234
235235 if (hasSearchParameters(this.#options.searchParams)) {
236236 // eslint-disable-next-line unicorn/prevent-abbreviations
237237 const textSearchParams = typeof this.#options.searchParams === 'string'
238238 ? this.#options.searchParams.replace(/^\?/, '')
239239 : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams) as unknown as SearchParamsInit).toString();
240240 // eslint-disable-next-line unicorn/prevent-abbreviations
241241 const searchParams = '?' + textSearchParams;
242242 const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
243243
244244 // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
245245 this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this.#options as RequestInit);
246246 }
247247
248248 // If `onUploadProgress` is passed, it uses the stream API internally
249249 if (this.#options.onUploadProgress) {
250250 if (typeof this.#options.onUploadProgress !== 'function') {
251251 throw new TypeError('The `onUploadProgress` option must be a function');
252252 }
253253
254254 if (!supportsRequestStreams) {
255255 throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
256256 }
257257
258258 const originalBody = this.request.body;
259259 if (originalBody) {
260260 // Pass original body to calculate size correctly (before it becomes a stream)
261261 this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body);
262262 }
263263 }
264264 }
265265
266266 #calculateRetryDelay(error: unknown) {
267267 this.#retryCount++;
268268
269269 if (this.#retryCount > this.#options.retry.limit || isTimeoutError(error)) {
270270 throw error;
271271 }
272272
273273 if (isHTTPError(error)) {
274274 if (!this.#options.retry.statusCodes.includes(error.response.status)) {
275275 throw error;
276276 }
277277
278278 const retryAfter = error.response.headers.get('Retry-After')
279279 ?? error.response.headers.get('RateLimit-Reset')
280280 ?? error.response.headers.get('X-RateLimit-Reset') // GitHub
281281 ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter
282282 if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) {
283283 let after = Number(retryAfter) * 1000;
284284 if (Number.isNaN(after)) {
285285 after = Date.parse(retryAfter) - Date.now();
286286 } else if (after >= Date.parse('2024-01-01')) {
287287 // A large number is treated as a timestamp (fixed threshold protects against clock skew)
288288 after -= Date.now();
289289 }
290290
291291 const max = this.#options.retry.maxRetryAfter ?? after;
292292 return after < max ? after : max;
293293 }
294294
295295 if (error.response.status === 413) {
296296 throw error;
297297 }
298298 }
299299
300300 const retryDelay = this.#options.retry.delay(this.#retryCount);
301− return Math.min(this.#options.retry.backoffLimit, retryDelay);
301+
302+ let jitteredDelay = retryDelay;
303+ if (this.#options.retry.jitter === true) {
304+ jitteredDelay = Math.random() * retryDelay;
305+ } else if (typeof this.#options.retry.jitter === 'function') {
306+ jitteredDelay = this.#options.retry.jitter(retryDelay);
307+
308+ if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) {
309+ jitteredDelay = retryDelay;
310+ }
311+ }
312+
313+ return Math.min(this.#options.retry.backoffLimit, jitteredDelay);
302314 }
303315
304316 #decorateResponse(response: Response): Response {
305317 if (this.#options.parseJson) {
306318 response.json = async () => this.#options.parseJson!(await response.text());
307319 }
308320
309321 return response;
310322 }
311323
312324 async #retry<T extends (...arguments_: any) => Promise<any>>(function_: T): Promise<ReturnType<T> | void> {
313325 try {
314326 return await function_();
315327 } catch (error) {
316328 const ms = Math.min(this.#calculateRetryDelay(error), maxSafeTimeout);
317329 if (this.#retryCount < 1) {
318330 throw error;
319331 }
320332
321333 await delay(ms, {signal: this.#options.signal});
322334
323335 for (const hook of this.#options.hooks.beforeRetry) {
324336 // eslint-disable-next-line no-await-in-loop
325337 const hookResult = await hook({
326338 request: this.request,
327339 options: this.#getNormalizedOptions(),
328340 error: error as Error,
329341 retryCount: this.#retryCount,
330342 });
331343
332344 // If `stop` is returned from the hook, the retry process is stopped
333345 if (hookResult === stop) {
334346 return;
335347 }
336348 }
337349
338350 return this.#retry(function_);
339351 }
340352 }
341353
342354 async #fetch(): Promise<Response> {
343355 for (const hook of this.#options.hooks.beforeRequest) {
344356 // eslint-disable-next-line no-await-in-loop
345357 const result = await hook(
346358 this.request,
347359 this.#getNormalizedOptions(),
348360 {retryCount: this.#retryCount},
349361 );
350362
351363 if (result instanceof Request) {
352364 this.request = result;
353365 break;
354366 }
355367
356368 if (result instanceof Response) {
357369 return result;
358370 }
359371 }
360372
361373 const nonRequestOptions = findUnknownOptions(this.request, this.#options);
362374
363375 // Cloning is done here to prepare in advance for retries
364376 this.#originalRequest = this.request;
365377 this.request = this.#originalRequest.clone();
366378
367379 if (this.#options.timeout === false) {
368380 return this.#options.fetch(this.#originalRequest, nonRequestOptions);
369381 }
370382
371383 return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options as TimeoutOptions);
372384 }
373385
374386 #getNormalizedOptions(): NormalizedOptions {
375387 if (!this.#cachedNormalizedOptions) {
376388 const {hooks, ...normalizedOptions} = this.#options;
377389 this.#cachedNormalizedOptions = Object.freeze(normalizedOptions) as NormalizedOptions;
378390 }
379391
380392 return this.#cachedNormalizedOptions;
381393 }
382394}

Declare the jitter option

02 / 04

RetryOptions gains jitter?: boolean | ((delay: number) => number), with a doc comment that explains the thundering-herd problem and shows full, percentage and absolute jitter strategies.

The normalized defaults add jitter: undefined, which keeps the Required<RetryOptions> type complete without changing behavior for anyone who doesn't opt in.

retry.tssource/types/+33
normalize.tssource/utils/+1
retry.tssource/types/+33
11export type RetryOptions = {
22 /**
33 The number of times to retry failed requests.
44
55 @default 2
66 */
77 limit?: number;
88
99 /**
1010 The HTTP methods allowed to retry.
1111
1212 @default ['get', 'put', 'head', 'delete', 'options', 'trace']
1313 */
1414 methods?: string[];
1515
1616 /**
1717 The HTTP status codes allowed to retry.
1818
1919 @default [408, 413, 429, 500, 502, 503, 504]
2020 */
2121 statusCodes?: number[];
2222
2323 /**
2424 The HTTP status codes allowed to retry with a `Retry-After` header.
2525
2626 @default [413, 429, 503]
2727 */
2828 afterStatusCodes?: number[];
2929
3030 /**
3131 If the `Retry-After` header is greater than `maxRetryAfter`, the request will be canceled.
3232
3333 @default Infinity
3434 */
3535 maxRetryAfter?: number;
3636
3737 /**
3838 The upper limit of the delay per retry in milliseconds.
3939 To clamp the delay, set `backoffLimit` to 1000, for example.
4040
4141 By default, the delay is calculated in the following way:
4242
4343 ```
4444 0.3 * (2 ** (attemptCount - 1)) * 1000
4545 ```
4646
4747 The delay increases exponentially.
4848
4949 @default Infinity
5050 */
5151 backoffLimit?: number;
5252
5353 /**
5454 A function to calculate the delay between retries given `attemptCount` (starts from 1).
5555
5656 @default attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000
5757 */
5858 delay?: (attemptCount: number) => number;
59+
60+ /**
61+ Add random jitter to retry delays to prevent thundering herd problems.
62+
63+ When many clients retry simultaneously (e.g., after hitting a rate limit), they can overwhelm the server again. Jitter adds randomness to break this synchronization.
64+
65+ Set to `true` to use full jitter, which randomizes the delay between 0 and the computed delay.
66+
67+ Alternatively, pass a function to implement custom jitter strategies.
68+
69+ @default undefined (no jitter)
70+
71+ @example
72+ ```
73+ import ky from 'ky';
74+
75+ const json = await ky('https://example.com', {
76+ retry: {
77+ limit: 5,
78+
79+ // Full jitter (randomizes delay between 0 and computed value)
80+ jitter: true
81+
82+ // Percentage jitter (80-120% of delay)
83+ // jitter: delay => delay * (0.8 + Math.random() * 0.4)
84+
85+ // Absolute jitter (±100ms)
86+ // jitter: delay => delay + (Math.random() * 200 - 100)
87+ }
88+ }).json();
89+ ```
90+ */
91+ jitter?: boolean | ((delay: number) => number) | undefined;
5992};
normalize.tssource/utils/+1
11import {requestMethods} from '../core/constants.js';
22import type {HttpMethod} from '../types/options.js';
33import type {RetryOptions} from '../types/retry.js';
44
55export const normalizeRequestMethod = (input: string): string =>
66 requestMethods.includes(input as HttpMethod) ? input.toUpperCase() : input;
77
88const retryMethods = ['get', 'put', 'head', 'delete', 'options', 'trace'];
99
1010const retryStatusCodes = [408, 413, 429, 500, 502, 503, 504];
1111
1212const retryAfterStatusCodes = [413, 429, 503];
1313
1414const defaultRetryOptions: Required<RetryOptions> = {
1515 limit: 2,
1616 methods: retryMethods,
1717 statusCodes: retryStatusCodes,
1818 afterStatusCodes: retryAfterStatusCodes,
1919 maxRetryAfter: Number.POSITIVE_INFINITY,
2020 backoffLimit: Number.POSITIVE_INFINITY,
2121 delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000,
22+ jitter: undefined,
2223};
2324
2425export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Required<RetryOptions> => {
2526 if (typeof retry === 'number') {
2627 return {
2728 ...defaultRetryOptions,
2829 limit: retry,
2930 };
3031 }
3132
3233 if (retry.methods && !Array.isArray(retry.methods)) {
3334 throw new Error('retry.methods must be an array');
3435 }
3536
3637 if (retry.statusCodes && !Array.isArray(retry.statusCodes)) {
3738 throw new Error('retry.statusCodes must be an array');
3839 }
3940
4041 return {
4142 ...defaultRetryOptions,
4243 ...retry,
4344 };
4445};

Test jitter bounds and precedence

03 / 04

Five new tests stub Math.random or measure elapsed time to check that full jitter stays within the computed delay, that a custom function's result is used, that backoffLimit still caps a jittered value, and that jitter composes with a custom delay.

The last test sends a Retry-After header and confirms the server's timing is honored without jitter.

retry.tstest/+201
retry.tstest/+201
11import test from 'ava';
22import ky from '../source/index.js';
33import {createHttpTestServer} from './helpers/create-http-test-server.js';
44import {withPerformance} from './helpers/with-performance.js';
55
66const fixture = 'fixture';
77const defaultRetryCount = 2;
88const retryAfterOn500 = 2;
99const retryAfterOn413 = 2;
1010
1111test('network error', async t => {
1212 let requestCount = 0;
1313
1414 const server = await createHttpTestServer();
1515 server.get('/', (_request, response) => {
1616 requestCount++;
1717
1818 if (requestCount === defaultRetryCount + 1) {
1919 response.end(fixture);
2020 } else {
2121 response.status(99_999).end();
2222 }
2323 });
2424
2525 t.is(await ky(server.url).text(), fixture);
2626 t.is(requestCount, defaultRetryCount + 1);
2727
2828 await server.close();
2929});
3030
3131test('status code 500', async t => {
3232 let requestCount = 0;
3333
3434 const server = await createHttpTestServer();
3535 server.get('/', (_request, response) => {
3636 requestCount++;
3737
3838 if (requestCount === defaultRetryCount + 1) {
3939 response.end(fixture);
4040 } else {
4141 response.sendStatus(500);
4242 }
4343 });
4444
4545 t.is(await ky(server.url).text(), fixture);
4646 t.is(requestCount, defaultRetryCount + 1);
4747
4848 await server.close();
4949});
5050
5151test('only on defined status codes', async t => {
5252 let requestCount = 0;
5353
5454 const server = await createHttpTestServer();
5555 server.get('/', (_request, response) => {
5656 requestCount++;
5757
5858 if (requestCount === defaultRetryCount + 1) {
5959 response.end(fixture);
6060 } else {
6161 response.sendStatus(400);
6262 }
6363 });
6464
6565 await t.throwsAsync(ky(server.url).text(), {message: /Bad Request/});
6666 t.is(requestCount, 1);
6767
6868 await server.close();
6969});
7070
7171test('not on POST', async t => {
7272 let requestCount = 0;
7373
7474 const server = await createHttpTestServer();
7575 server.post('/', (_request, response) => {
7676 requestCount++;
7777
7878 if (requestCount === defaultRetryCount + 1) {
7979 response.end(fixture);
8080 } else {
8181 response.sendStatus(500);
8282 }
8383 });
8484
8585 await t.throwsAsync(ky.post(server.url).text(), {
8686 message: /Internal Server Error/,
8787 });
8888 t.is(requestCount, 1);
8989
9090 await server.close();
9191});
9292
9393test('respect Retry-After: 0 and retry immediately', async t => {
9494 const retryCount = 4;
9595 let requestCount = 0;
9696
9797 const server = await createHttpTestServer();
9898 server.get('/', (_request, response) => {
9999 requestCount++;
100100
101101 if (requestCount === retryCount + 1) {
102102 response.end(fixture);
103103 } else {
104104 response.writeHead(413, {
105105 'Retry-After': 0,
106106 });
107107
108108 response.end('');
109109 }
110110 });
111111
112112 await withPerformance({
113113 t,
114114 expectedDuration: 4 + 4 + 4 + 4,
115115 async test() {
116116 t.is(await ky(server.url, {
117117 retry: retryCount,
118118 }).text(), fixture);
119119 },
120120 });
121121
122122 t.is(requestCount, 5);
123123
124124 await server.close();
125125});
126126
127127test('RateLimit-Reset is treated the same as Retry-After', async t => {
128128 let requestCount = 0;
129129
130130 const server = await createHttpTestServer();
131131 server.get('/', (_request, response) => {
132132 requestCount++;
133133
134134 if (requestCount === defaultRetryCount + 1) {
135135 response.end(fixture);
136136 } else {
137137 const header = (requestCount < 2) ? 'RateLimit-Reset' : 'Retry-After';
138138 response.writeHead(429, {
139139 [header]: 1,
140140 });
141141
142142 response.end('');
143143 }
144144 });
145145
146146 await withPerformance({
147147 t,
148148 expectedDuration: 1000 + 1000,
149149 async test() {
150150 t.is(await ky(server.url).text(), fixture);
151151 },
152152 });
153153
154154 t.is(requestCount, 3);
155155
156156 await server.close();
157157});
158158
159159test('RateLimit-Reset with time since epoch', async t => {
160160 let requestCount = 0;
161161
162162 const server = await createHttpTestServer();
163163 server.get('/', (_request, response) => {
164164 requestCount++;
165165
166166 if (requestCount === defaultRetryCount + 1) {
167167 response.end(fixture);
168168 } else {
169169 const twoSecondsByDelta = 2;
170170 const oneSecondByEpoch = (Date.now() / 1000) + 1;
171171 response.writeHead(429, {
172172 'RateLimit-Reset': (requestCount < 2) ? twoSecondsByDelta : oneSecondByEpoch,
173173 });
174174
175175 response.end('');
176176 }
177177 });
178178
179179 await withPerformance({
180180 t,
181181 expectedDuration: 2000 + 1000,
182182 async test() {
183183 t.is(await ky(server.url).text(), fixture);
184184 },
185185 });
186186
187187 t.is(requestCount, 3);
188188
189189 await server.close();
190190});
191191
192192test('respect 413 Retry-After', async t => {
193193 const startTime = Date.now();
194194 let requestCount = 0;
195195
196196 const server = await createHttpTestServer();
197197 server.get('/', (_request, response) => {
198198 requestCount++;
199199
200200 if (requestCount === defaultRetryCount + 1) {
201201 response.end((Date.now() - startTime).toString());
202202 } else {
203203 response.writeHead(413, {
204204 'Retry-After': retryAfterOn413,
205205 });
206206 response.end('');
207207 }
208208 });
209209
210210 const timeElapsedInMs = Number(await ky(server.url).text());
211211 t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
212212 t.is(requestCount, retryAfterOn413 + 1);
213213
214214 await server.close();
215215});
216216
217217test('respect 413 Retry-After with timestamp', async t => {
218218 const startTime = Date.now();
219219 let requestCount = 0;
220220
221221 const server = await createHttpTestServer({bodyParser: false});
222222 server.get('/', (_request, response) => {
223223 requestCount++;
224224 if (requestCount === defaultRetryCount + 1) {
225225 response.end((Date.now() - startTime).toString());
226226 } else {
227227 // @NOTE we need to round up to the next second due to http-date resolution
228228 const date = new Date(Date.now() + ((retryAfterOn413 + 1) * 1000)).toUTCString();
229229 response.writeHead(413, {
230230 'Retry-After': date,
231231 });
232232 response.end('');
233233 }
234234 });
235235
236236 const timeElapsedInMs = Number(await ky(server.url).text());
237237 t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
238238 t.is(requestCount, retryAfterOn413 + 1);
239239
240240 await server.close();
241241});
242242
243243test('doesn\'t retry on 413 without Retry-After header', async t => {
244244 let requestCount = 0;
245245
246246 const server = await createHttpTestServer();
247247 server.get('/', (_request, response) => {
248248 requestCount++;
249249 response.sendStatus(413);
250250 });
251251
252252 await t.throwsAsync(ky(server.url).text(), {message: /Payload Too Large/});
253253 t.is(requestCount, 1);
254254 await ky(server.url, {throwHttpErrors: false}).text();
255255 t.is(requestCount, 2);
256256
257257 await server.close();
258258});
259259
260260test('respect custom `afterStatusCodes` (500) with Retry-After header', async t => {
261261 const startTime = Date.now();
262262 let requestCount = 0;
263263
264264 const server = await createHttpTestServer();
265265 server.get('/', (_request, response) => {
266266 requestCount++;
267267
268268 if (requestCount === defaultRetryCount + 1) {
269269 response.end((Date.now() - startTime).toString());
270270 } else {
271271 response.writeHead(500, {
272272 'Retry-After': retryAfterOn500,
273273 });
274274 response.end('');
275275 }
276276 });
277277
278278 const timeElapsedInMs = Number(await ky(server.url, {retry: {afterStatusCodes: [500]}}).text());
279279 t.true(timeElapsedInMs >= retryAfterOn500 * 1000);
280280 t.is(requestCount, retryAfterOn500 + 1);
281281
282282 await server.close();
283283});
284284
285285test('respect number of retries', async t => {
286286 let requestCount = 0;
287287
288288 const server = await createHttpTestServer();
289289 server.get('/', (_request, response) => {
290290 requestCount++;
291291 response.sendStatus(408);
292292 });
293293
294294 await t.throwsAsync(
295295 ky(server.url, {
296296 retry: {
297297 limit: 3,
298298 },
299299 }).text(),
300300 {
301301 message: /Request Timeout/,
302302 },
303303 );
304304 t.is(requestCount, 4);
305305
306306 await server.close();
307307});
308308
309309test('respect retry methods', async t => {
310310 let requestCount = 0;
311311
312312 const server = await createHttpTestServer();
313313 server.post('/', (_request, response) => {
314314 requestCount++;
315315 response.sendStatus(408);
316316 });
317317
318318 server.get('/', (_request, response) => {
319319 requestCount++;
320320 response.sendStatus(408);
321321 });
322322
323323 await t.throwsAsync(
324324 ky(server.url, {
325325 method: 'post',
326326 retry: {
327327 limit: 3,
328328 methods: ['get'],
329329 },
330330 }).text(),
331331 {
332332 message: /Request Timeout/,
333333 },
334334 );
335335 t.is(requestCount, 1);
336336
337337 requestCount = 0;
338338 await t.throwsAsync(
339339 ky(server.url, {
340340 retry: {
341341 limit: 2,
342342 methods: ['get'],
343343 },
344344 }).text(),
345345 {
346346 message: /Request Timeout/,
347347 },
348348 );
349349 t.is(requestCount, defaultRetryCount + 1);
350350
351351 await server.close();
352352});
353353
354354test('respect maxRetryAfter', async t => {
355355 const retryCount = 4;
356356 let requestCount = 0;
357357
358358 const server = await createHttpTestServer();
359359 server.get('/', (_request, response) => {
360360 requestCount++;
361361
362362 if (requestCount === retryCount + 1) {
363363 response.end(fixture);
364364 } else {
365365 response.writeHead(413, {
366366 'Retry-After': 1,
367367 });
368368
369369 response.end('');
370370 }
371371 });
372372
373373 await withPerformance({
374374 t,
375375 expectedDuration: 420 + 420 + 420 + 420,
376376 async test() {
377377 t.is(await ky(server.url, {
378378 retry: {
379379 limit: retryCount,
380380 maxRetryAfter: 420,
381381 },
382382 }).text(), fixture);
383383 },
384384 });
385385
386386 t.is(requestCount, 5);
387387
388388 requestCount = 0;
389389
390390 await withPerformance({
391391 t,
392392 expectedDuration: 1000 + 1000 + 1000 + 1000,
393393 async test() {
394394 t.is(await ky(server.url, {
395395 retry: {
396396 limit: retryCount,
397397 maxRetryAfter: 2000,
398398 },
399399 }).text(), fixture);
400400 },
401401 });
402402
403403 t.is(requestCount, 5);
404404
405405 await server.close();
406406});
407407
408408test('retry - can provide retry as number', async t => {
409409 let requestCount = 0;
410410
411411 const server = await createHttpTestServer();
412412 server.get('/', async (_request, response) => {
413413 requestCount++;
414414 response.sendStatus(408);
415415 });
416416
417417 await t.throwsAsync(ky(server.url, {retry: 4}).text(), {
418418 message: /Request Timeout/,
419419 });
420420 t.is(requestCount, 5);
421421
422422 await server.close();
423423});
424424
425425test('doesn\'t retry on 413 with empty statusCodes and methods', async t => {
426426 let requestCount = 0;
427427
428428 const server = await createHttpTestServer();
429429
430430 server.get('/', async (_request, response) => {
431431 requestCount++;
432432 response.sendStatus(413);
433433 });
434434
435435 await t.throwsAsync(
436436 ky(server.url, {
437437 retry: {
438438 limit: 10,
439439 statusCodes: [],
440440 methods: [],
441441 },
442442 }).text(),
443443 {
444444 message: /Payload Too Large/,
445445 },
446446 );
447447
448448 t.is(requestCount, 1);
449449
450450 await server.close();
451451});
452452
453453test('doesn\'t retry on 413 with empty methods', async t => {
454454 let requestCount = 0;
455455
456456 const server = await createHttpTestServer();
457457 server.get('/', async (_request, response) => {
458458 requestCount++;
459459 response.sendStatus(413);
460460 });
461461
462462 await t.throwsAsync(
463463 ky(server.url, {
464464 retry: {
465465 limit: 10,
466466 methods: [],
467467 },
468468 }).text(),
469469 {
470470 message: /Payload Too Large/,
471471 },
472472 );
473473
474474 t.is(requestCount, 1);
475475
476476 await server.close();
477477});
478478
479479test('does retry on 408 with methods provided as array', async t => {
480480 let requestCount = 0;
481481
482482 const server = await createHttpTestServer();
483483 server.get('/', async (_request, response) => {
484484 requestCount++;
485485 response.sendStatus(408);
486486 });
487487
488488 await t.throwsAsync(
489489 ky(server.url, {
490490 retry: {
491491 limit: 3,
492492 methods: ['get'],
493493 },
494494 }).text(),
495495 {
496496 message: /Request Timeout/,
497497 },
498498 );
499499
500500 t.is(requestCount, 4);
501501
502502 await server.close();
503503});
504504
505505test('does retry on 408 with statusCodes provided as array', async t => {
506506 let requestCount = 0;
507507
508508 const server = await createHttpTestServer();
509509 server.get('/', async (_request, response) => {
510510 requestCount++;
511511 response.sendStatus(408);
512512 });
513513
514514 await t.throwsAsync(
515515 ky(server.url, {
516516 retry: {
517517 limit: 3,
518518 statusCodes: [408],
519519 },
520520 }).text(),
521521 {
522522 message: /Request Timeout/,
523523 },
524524 );
525525
526526 t.is(requestCount, 4);
527527
528528 await server.close();
529529});
530530
531531test('doesn\'t retry when retry.limit is set to 0', async t => {
532532 let requestCount = 0;
533533
534534 const server = await createHttpTestServer();
535535 server.get('/', (_request, response) => {
536536 requestCount++;
537537 response.sendStatus(408);
538538 });
539539
540540 await t.throwsAsync(
541541 ky(server.url, {
542542 retry: {
543543 limit: 0,
544544 },
545545 }).text(),
546546 {
547547 message: /Request Timeout/,
548548 },
549549 );
550550
551551 t.is(requestCount, 1);
552552
553553 await server.close();
554554});
555555
556556test('throws when retry.methods is not an array', async t => {
557557 const server = await createHttpTestServer();
558558
559559 t.throws(() => {
560560 void ky(server.url, {
561561 retry: {
562562 // @ts-expect-error
563563 methods: 'get',
564564 },
565565 });
566566 });
567567
568568 await server.close();
569569});
570570
571571test('throws when retry.statusCodes is not an array', async t => {
572572 const server = await createHttpTestServer();
573573
574574 t.throws(() => {
575575 void ky(server.url, {
576576 retry: {
577577 // @ts-expect-error
578578 statusCodes: 403,
579579 },
580580 });
581581 });
582582
583583 await server.close();
584584});
585585
586586test('respect maximum backoffLimit', async t => {
587587 const retryCount = 4;
588588 let requestCount = 0;
589589
590590 const server = await createHttpTestServer();
591591 server.get('/', (_request, response) => {
592592 requestCount++;
593593
594594 if (requestCount === retryCount + 1) {
595595 response.end(fixture);
596596 } else {
597597 response.sendStatus(500);
598598 }
599599 });
600600
601601 await withPerformance({
602602 t,
603603 expectedDuration: 300 + 600 + 1200 + 2400,
604604 async test() {
605605 t.is(await ky(server.url, {
606606 retry: retryCount,
607607 }).text(), fixture);
608608 },
609609 });
610610
611611 t.is(requestCount, 5);
612612
613613 requestCount = 0;
614614
615615 await withPerformance({
616616 t,
617617 expectedDuration: 300 + 600 + 1000 + 1000,
618618 async test() {
619619 t.is(await ky(server.url, {
620620 retry: {
621621 limit: retryCount,
622622 backoffLimit: 1000,
623623 },
624624 }).text(), fixture);
625625 },
626626 });
627627
628628 t.is(requestCount, 5);
629629
630630 await server.close();
631631});
632632
633633test('respect custom retry.delay', async t => {
634634 const retryCount = 4;
635635 let requestCount = 0;
636636
637637 const server = await createHttpTestServer();
638638 server.get('/', (_request, response) => {
639639 requestCount++;
640640
641641 if (requestCount === retryCount + 1) {
642642 response.end(fixture);
643643 } else {
644644 response.sendStatus(500);
645645 }
646646 });
647647
648648 await withPerformance({
649649 t,
650650 expectedDuration: 200 + 300 + 400 + 500,
651651 async test() {
652652 t.is(await ky(server.url, {
653653 retry: {
654654 limit: retryCount,
655655 delay: n => 100 * (n + 1),
656656 },
657657 }).text(), fixture);
658658 },
659659 });
660660
661661 t.is(requestCount, 5);
662662
663663 await server.close();
664664});
665+
666+test('jitter: true applies full jitter to delay', async t => {
667+ const retryCount = 3;
668+ let requestCount = 0;
669+ const delays: number[] = [];
670+ let lastTime = Date.now();
671+
672+ const server = await createHttpTestServer();
673+ server.get('/', (_request, response) => {
674+ const now = Date.now();
675+ if (requestCount > 0) {
676+ delays.push(now - lastTime);
677+ }
678+
679+ lastTime = now;
680+ requestCount++;
681+
682+ if (requestCount === retryCount + 1) {
683+ response.end(fixture);
684+ } else {
685+ response.sendStatus(500);
686+ }
687+ });
688+
689+ await ky(server.url, {
690+ retry: {
691+ limit: retryCount,
692+ jitter: true,
693+ },
694+ }).text();
695+
696+ t.is(requestCount, 4);
697+
698+ // Full jitter should produce delays between 0 and the computed delay
699+ // Add 20% tolerance for system overhead
700+ t.true(delays[0] >= 0 && delays[0] <= 360);
701+ t.true(delays[1] >= 0 && delays[1] <= 720);
702+ t.true(delays[2] >= 0 && delays[2] <= 1440);
703+
704+ await server.close();
705+});
706+
707+test('jitter: custom function applies custom jitter', async t => {
708+ const retryCount = 3;
709+ let requestCount = 0;
710+ const jitterCalls: number[] = [];
711+
712+ const customJitter = (delay: number) => {
713+ jitterCalls.push(delay);
714+ return delay * 0.5; // Half the delay
715+ };
716+
717+ const server = await createHttpTestServer();
718+ server.get('/', (_request, response) => {
719+ requestCount++;
720+
721+ if (requestCount === retryCount + 1) {
722+ response.end(fixture);
723+ } else {
724+ response.sendStatus(500);
725+ }
726+ });
727+
728+ await ky(server.url, {
729+ retry: {
730+ limit: retryCount,
731+ jitter: customJitter,
732+ },
733+ }).text();
734+
735+ t.is(requestCount, 4);
736+ t.is(jitterCalls.length, 3); // Called for each retry
737+
738+ // Verify the jitter function received the correct delays
739+ t.is(jitterCalls[0], 300); // First retry
740+ t.is(jitterCalls[1], 600); // Second retry
741+ t.is(jitterCalls[2], 1200); // Third retry
742+
743+ await server.close();
744+});
745+
746+test('jitter respects backoffLimit', async t => {
747+ const retryCount = 3;
748+ let requestCount = 0;
749+ const delays: number[] = [];
750+ let lastTime = Date.now();
751+
752+ const server = await createHttpTestServer();
753+ server.get('/', (_request, response) => {
754+ const now = Date.now();
755+ if (requestCount > 0) {
756+ delays.push(now - lastTime);
757+ }
758+
759+ lastTime = now;
760+ requestCount++;
761+
762+ if (requestCount === retryCount + 1) {
763+ response.end(fixture);
764+ } else {
765+ response.sendStatus(500);
766+ }
767+ });
768+
769+ await ky(server.url, {
770+ retry: {
771+ limit: retryCount,
772+ backoffLimit: 500,
773+ jitter: true,
774+ },
775+ }).text();
776+
777+ t.is(requestCount, 4);
778+
779+ // With backoffLimit of 500, all delays should be <= 500ms
780+ // Even though the computed delays would be 300, 600, 1200
781+ // After jitter and backoffLimit, they should all be <= 500
782+ // Add 20% tolerance for system overhead
783+ t.true(delays[0] >= 0 && delays[0] <= 600);
784+ t.true(delays[1] >= 0 && delays[1] <= 600);
785+ t.true(delays[2] >= 0 && delays[2] <= 600);
786+
787+ await server.close();
788+});
789+
790+test('jitter works with custom delay function', async t => {
791+ const retryCount = 2;
792+ let requestCount = 0;
793+ const jitterCalls: number[] = [];
794+
795+ const customJitter = (delay: number) => {
796+ jitterCalls.push(delay);
797+ return delay * 0.5;
798+ };
799+
800+ const server = await createHttpTestServer();
801+ server.get('/', (_request, response) => {
802+ requestCount++;
803+
804+ if (requestCount === retryCount + 1) {
805+ response.end(fixture);
806+ } else {
807+ response.sendStatus(500);
808+ }
809+ });
810+
811+ await ky(server.url, {
812+ retry: {
813+ limit: retryCount,
814+ delay: n => 100 * n, // Custom delay: 100ms, 200ms, etc.
815+ jitter: customJitter,
816+ },
817+ }).text();
818+
819+ t.is(requestCount, 3);
820+ t.is(jitterCalls.length, 2);
821+ t.is(jitterCalls[0], 100); // First retry with custom delay
822+ t.is(jitterCalls[1], 200); // Second retry with custom delay
823+
824+ await server.close();
825+});
826+
827+test('jitter is not applied when Retry-After header is present', async t => {
828+ const startTime = Date.now();
829+ let requestCount = 0;
830+ const jitterCalls: number[] = [];
831+
832+ const customJitter = (delay: number) => {
833+ jitterCalls.push(delay);
834+ return delay * 0.5;
835+ };
836+
837+ const server = await createHttpTestServer();
838+ server.get('/', (_request, response) => {
839+ requestCount++;
840+
841+ if (requestCount === 3) {
842+ response.end((Date.now() - startTime).toString());
843+ } else {
844+ response.writeHead(429, {
845+ 'Retry-After': 1,
846+ });
847+ response.end('');
848+ }
849+ });
850+
851+ const timeElapsedInMs = Number(await ky(server.url, {
852+ retry: {
853+ jitter: customJitter,
854+ },
855+ }).text());
856+
857+ // Should have made 3 requests (initial + 2 retries)
858+ t.is(requestCount, 3);
859+ // Jitter function should NOT have been called when Retry-After is present
860+ t.is(jitterCalls.length, 0);
861+ // Should have waited at least 2 seconds (1s per retry)
862+ t.true(timeElapsedInMs >= 2000);
863+
864+ await server.close();
865+});

Document jitter in the readme

04 / 04

The retry section lists the new default, explains when jitter helps, notes that Retry-After bypasses it, and adds a copy-pasteable example of each strategy.

readme.md+24
readme.md+24
11<div align="center">
22 <br>
33 <div>
44 <img width="600" height="600" src="media/logo.svg" alt="ky">
55 </div>
66 <br>
77 <br>
88 <br>
99</div>
1010
1111> Ky is a tiny and elegant HTTP client based on the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
1212
1313[![Coverage Status](https://codecov.io/gh/sindresorhus/ky/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/ky)
1414[![](https://badgen.net/bundlephobia/minzip/ky)](https://bundlephobia.com/result?p=ky)
1515
1616Ky targets [modern browsers](#browser-support), Node.js, Bun, and Deno.
1717
1818It's just a tiny package with no dependencies.
1919
2020## Benefits over plain `fetch`
2121
2222- Simpler API
2323- Method shortcuts (`ky.post()`)
2424- Treats non-2xx status codes as errors (after redirects)
2525- Retries failed requests
2626- JSON option
2727- Timeout support
2828- URL prefix option
2929- Instances with custom defaults
3030- Hooks
3131- TypeScript niceties (e.g. `.json()` supports generics and defaults to `unknown`, not `any`)
3232
3333## Install
3434
3535```sh
3636npm install ky
3737```
3838
3939###### CDN
4040
4141- [jsdelivr](https://cdn.jsdelivr.net/npm/ky/+esm)
4242- [unpkg](https://unpkg.com/ky)
4343- [esm.sh](https://esm.sh/ky)
4444
4545## Usage
4646
4747```js
4848import ky from 'ky';
4949
5050const json = await ky.post('https://example.com', {json: {foo: true}}).json();
5151
5252console.log(json);
5353//=> {data: '🦄'}
5454```
5555
5656With plain `fetch`, it would be:
5757
5858```js
5959class HTTPError extends Error {}
6060
6161const response = await fetch('https://example.com', {
6262 method: 'POST',
6363 body: JSON.stringify({foo: true}),
6464 headers: {
6565 'content-type': 'application/json'
6666 }
6767});
6868
6969if (!response.ok) {
7070 throw new HTTPError(`Fetch error: ${response.statusText}`);
7171}
7272
7373const json = await response.json();
7474
7575console.log(json);
7676//=> {data: '🦄'}
7777```
7878
7979If you are using [Deno](https://github.com/denoland/deno), import Ky from a URL. For example, using a CDN:
8080
8181```js
8282import ky from 'https://esm.sh/ky';
8383```
8484
8585## API
8686
8787### ky(input, options?)
8888
8989The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with additional `options` available (see below).
9090
9191Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response) with [`Body` methods](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body) added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.Fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` will return an empty string if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.
9292
9393Available body shortcuts: `.json()`, `.text()`, `.formData()`, `.arrayBuffer()`, `.blob()`, and `.bytes()`. The `.bytes()` shortcut is only present when the runtime supports `Response.prototype.bytes()`.
9494
9595```js
9696import ky from 'ky';
9797
9898const user = await ky('/api/user').json();
9999
100100console.log(user);
101101```
102102
103103⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `.json()`.
104104
105105```ts
106106import ky from 'ky';
107107
108108// user1 is unknown
109109const user1 = await ky('/api/users/1').json();
110110// user2 is a User
111111const user2 = await ky<User>('/api/users/2').json();
112112// user3 is a User
113113const user3 = await ky('/api/users/3').json<User>();
114114
115115console.log([user1, user2, user3]);
116116
117117// Get raw bytes (when supported by the runtime)
118118const bytes = await ky('/api/file').bytes();
119119console.log(bytes instanceof Uint8Array);
120120```
121121
122122### ky.get(input, options?)
123123### ky.post(input, options?)
124124### ky.put(input, options?)
125125### ky.patch(input, options?)
126126### ky.head(input, options?)
127127### ky.delete(input, options?)
128128
129129Sets `options.method` to the method name and makes a request.
130130
131131⌨️ **TypeScript:** Accepts an optional type parameter for use with JSON responses (see [`ky()`](#kyinput-options)).
132132
133133#### input
134134
135135Type: `string` | `URL` | `Request`
136136
137137Same as [`fetch` input](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#input).
138138
139139When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefixUrl`) will be ignored.
140140
141141#### options
142142
143143Type: `object`
144144
145145Same as [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), plus the following additional options:
146146
147147##### method
148148
149149Type: `string`\
150150Default: `'get'`
151151
152152HTTP method used to make the request.
153153
154154Internally, the standard methods (`GET`, `POST`, `PUT`, `PATCH`, `HEAD` and `DELETE`) are uppercased in order to avoid server errors due to case sensitivity.
155155
156156##### json
157157
158158Type: `object` and any other value accepted by [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
159159
160160Shortcut for sending JSON. Use this instead of the `body` option. Accepts any plain object or value, which will be `JSON.stringify()`'d and sent in the body with the correct header set.
161161
162162##### searchParams
163163
164164Type: `string | object<string, string | number | boolean | undefined> | Array<Array<string | number | boolean>> | URLSearchParams`\
165165Default: `''`
166166
167167Search parameters to include in the request URL. Setting this will override all existing search parameters in the input URL.
168168
169169Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).
170170
171171When passing an object, `undefined` values are automatically filtered out, while `null` values are preserved and converted to the string `'null'`.
172172
173173##### prefixUrl
174174
175175Type: `string | URL`
176176
177177A prefix to prepend to the `input` URL when making the request. It can be any valid URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. The `input` argument cannot start with a slash `/` when using this option.
178178
179179Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances.
180180
181181```js
182182import ky from 'ky';
183183
184184// On https://example.com
185185
186186const response = await ky('unicorn', {prefixUrl: '/api'});
187187//=> 'https://example.com/api/unicorn'
188188
189189const response2 = await ky('unicorn', {prefixUrl: 'https://cats.com'});
190190//=> 'https://cats.com/unicorn'
191191```
192192
193193Notes:
194194 - After `prefixUrl` and `input` are joined, the result is resolved against the [base URL](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) of the page (if any).
195195 - Leading slashes in `input` are disallowed when using this option to enforce consistency and avoid confusion about how the `input` URL is handled, given that `input` will not follow the normal URL resolution rules when `prefixUrl` is being used, which changes the meaning of a leading slash.
196196
197197##### retry
198198
199199Type: `object | number`\
200200Default:
201201- `limit`: `2`
202202- `methods`: `get` `put` `head` `delete` `options` `trace`
203203- `statusCodes`: [`408`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413) [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) [`500`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) [`502`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) [`504`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
204204- `afterStatusCodes`: [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413), [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503)
205205- `maxRetryAfter`: `undefined`
206206- `backoffLimit`: `undefined`
207207- `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000`
208+- `jitter`: `undefined`
208209
209210An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.
210211
211212If `retry` is a number, it will be used as `limit` and other defaults will remain in place.
212213
213214If the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date, timeout, or timestamp given in the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header has passed to retry the request. If `Retry-After` is missing, the non-standard [`RateLimit-Reset`](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-05.html#section-3.3) header is used in its place as a fallback. If the provided status code is not in the list, the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header will be ignored.
214215
215216If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`. If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will use `maxRetryAfter`.
216217
217218The `backoffLimit` option is the upper limit of the delay per retry in milliseconds.
218219To clamp the delay, set `backoffLimit` to 1000, for example.
219220By default, the delay is calculated with `0.3 * (2 ** (attemptCount - 1)) * 1000`. The delay increases exponentially.
220221
221222The `delay` option can be used to change how the delay between retries is calculated. The function receives one parameter, the attempt count, starting at `1`.
222223
224+The `jitter` option adds random jitter to retry delays to prevent thundering herd problems. When many clients retry simultaneously (e.g., after hitting a rate limit), they can overwhelm the server again. Jitter adds randomness to break this synchronization. Set to `true` to use full jitter, which randomizes the delay between 0 and the computed delay. Alternatively, pass a function to implement custom jitter strategies.
225+
226+**Note:** Jitter is not applied when the server provides a `Retry-After` header, as the server's explicit timing should be respected.
227+
223228Retries are not triggered following a [timeout](#timeout).
224229
225230```js
226231import ky from 'ky';
227232
228233const json = await ky('https://example.com', {
229234 retry: {
230235 limit: 10,
231236 methods: ['get'],
232237 statusCodes: [413],
233238 backoffLimit: 3000
234239 }
235240}).json();
236241```
237242
243+```js
244+import ky from 'ky';
245+
246+const json = await ky('https://example.com', {
247+ retry: {
248+ limit: 5,
249+
250+ // Full jitter (randomizes delay between 0 and computed value)
251+ jitter: true
252+
253+ // Percentage jitter (80-120% of delay)
254+ // jitter: delay => delay * (0.8 + Math.random() * 0.4)
255+
256+ // Absolute jitter (±100ms)
257+ // jitter: delay => delay + (Math.random() * 200 - 100)
258+ }
259+}).json();
260+```
261+
238262> [!NOTE]
239263> Chromium-based browsers automatically retry `408 Request Timeout` responses at the network layer for keep-alive connections. This means requests may be retried by both the browser and ky. If you want to avoid duplicate retries, you can either set `keepalive: false` in your request options (though this may impact performance for multiple requests) or remove `408` from the retry status codes.
240264
241265##### timeout
242266
243267Type: `number | false`\
244268Default: `10000`
245269
246270Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
247271If set to `false`, there will be no timeout.
248272
249273##### hooks
250274
251275Type: `object<string, Function[]>`\
252276Default: `{beforeRequest: [], beforeRetry: [], afterResponse: []}`
253277
254278Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
255279
256280###### hooks.beforeRequest
257281
258282Type: `Function[]`\
259283Default: `[]`
260284
261285This hook enables you to modify the request right before it is sent. Ky will make no further changes to the request after this. The hook function receives the normalized request, options, and a state object. You could, for example, modify the `request.headers` here.
262286
263287The `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., avoiding overwriting headers set in `beforeRetry`).
264288
265289The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to completely avoid making an HTTP request. This can be used to mock a request, check an internal cache, etc. An **important** consideration when returning a request or response from this hook is that any remaining `beforeRequest` hooks will be skipped, so you may want to only return them from the last hook.
266290
267291```js
268292import ky from 'ky';
269293
270294const api = ky.extend({
271295 hooks: {
272296 beforeRequest: [
273297 (request, options, {retryCount}) => {
274298 // Only set default auth header on initial request, not on retries
275299 // (retries may have refreshed token set by beforeRetry)
276300 if (retryCount === 0) {
277301 request.headers.set('Authorization', 'token initial-token');
278302 }
279303 }
280304 ]
281305 }
282306});
283307
284308const response = await api.get('https://example.com/api/users');
285309```
286310
287311###### hooks.beforeRetry
288312
289313Type: `Function[]`\
290314Default: `[]`
291315
292316This hook enables you to modify the request right before retry. Ky will make no further changes to the request after this. The hook function receives an object with the normalized request and options, an error instance, and the retry count. You could, for example, modify `request.headers` here.
293317
294318The `retryCount` is always `>= 1` since this hook is only called during retries, not on the initial request.
295319
296320If the request received a response, the error will be of type `HTTPError` and the `Response` object will be available at `error.response`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
297321
298322You can prevent Ky from retrying the request by throwing an error. Ky will not handle it in any way and the error will be propagated to the request initiator. The rest of the `beforeRetry` hooks will not be called in this case. Alternatively, you can return the [`ky.stop`](#kystop) symbol to do the same thing but without propagating an error (this has some limitations, see `ky.stop` docs for details).
299323
300324```js
301325import ky from 'ky';
302326
303327const response = await ky('https://example.com', {
304328 hooks: {
305329 beforeRetry: [
306330 async ({request, options, error, retryCount}) => {
307331 const token = await ky('https://example.com/refresh-token');
308332 request.headers.set('Authorization', `token ${token}`);
309333 }
310334 ]
311335 }
312336});
313337```
314338
315339###### hooks.beforeError
316340
317341Type: `Function[]`\
318342Default: `[]`
319343
320344This hook enables you to modify the `HTTPError` right before it is thrown. The hook function receives a `HTTPError` and a state object as arguments and should return an instance of `HTTPError`.
321345
322346The `state.retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between the initial request and retries, which is useful when you need different error handling based on retry attempts (e.g., showing different error messages on the final attempt).
323347
324348```js
325349import ky from 'ky';
326350
327351await ky('https://example.com', {
328352 hooks: {
329353 beforeError: [
330354 async error => {
331355 const {response} = error;
332356 if (response) {
333357 const body = await response.json();
334358 error.name = 'GitHubError';
335359 error.message = `${body.message} (${response.status})`;
336360 }
337361
338362 return error;
339363 },
340364
341365 // Or show different message based on retry count
342366 (error, state) => {
343367 if (state.retryCount === error.options.retry.limit) {
344368 error.message = `${error.message} (failed after ${state.retryCount} retries)`;
345369 }
346370
347371 return error;
348372 }
349373 ]
350374 }
351375});
352376```
353377
354378###### hooks.afterResponse
355379
356380Type: `Function[]`\
357381Default: `[]`
358382
359383This 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).
360384
361385The `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).
362386
363387```js
364388import ky from 'ky';
365389
366390const response = await ky('https://example.com', {
367391 hooks: {
368392 afterResponse: [
369393 (_request, _options, response) => {
370394 // You could do something with the response, for example, logging.
371395 log(response);
372396
373397 // Or return a `Response` instance to overwrite the response.
374398 return new Response('A different response', {status: 200});
375399 },
376400
377401 // Or retry with a fresh token on a 403 error
378402 async (request, options, response) => {
379403 if (response.status === 403) {
380404 // Get a fresh token
381405 const token = await ky('https://example.com/token').text();
382406
383407 // Retry with the token
384408 request.headers.set('Authorization', `token ${token}`);
385409
386410 return ky(request, options);
387411 }
388412 },
389413
390414 // Or show a notification only on the last retry for 5xx errors
391415 (request, options, response, {retryCount}) => {
392416 if (response.status >= 500 && response.status <= 599) {
393417 if (retryCount === options.retry.limit) {
394418 showNotification('Request failed after all retries');
395419 }
396420 }
397421 }
398422 ]
399423 }
400424});
401425```
402426
403427##### throwHttpErrors
404428
405429Type: `boolean`\
406430Default: `true`
407431
408432Throw an `HTTPError` when, after following redirects, the response has a non-2xx status code. To also throw for redirects instead of following them, set the [`redirect`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) option to `'manual'`.
409433
410434Setting this to `false` may be useful if you are checking for resource availability and are expecting error responses.
411435
412436Note: If `false`, error responses are considered successful and the request will not be retried.
413437
414438##### onDownloadProgress
415439
416440Type: `Function`
417441
418442Download progress event handler.
419443
420444The function receives these arguments:
421445- `progress` is an object with the these properties:
422446- - `percent` is a number between 0 and 1 representing the progress percentage.
423447- - `transferredBytes` is the number of bytes transferred so far.
424448- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
425449- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the first call.
426450
427451```js
428452import ky from 'ky';
429453
430454const response = await ky('https://example.com', {
431455 onDownloadProgress: (progress, chunk) => {
432456 // Example output:
433457 // `0% - 0 of 1271 bytes`
434458 // `100% - 1271 of 1271 bytes`
435459 console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
436460 }
437461});
438462```
439463
440464##### onUploadProgress
441465
442466Type: `Function`
443467
444468Upload progress event handler.
445469
446470The function receives these arguments:
447471- `progress` is an object with the these properties:
448472- - `percent` is a number between 0 and 1 representing the progress percentage.
449473- - `transferredBytes` is the number of bytes transferred so far.
450474- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
451475- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the last call.
452476
453477```js
454478import ky from 'ky';
455479
456480const response = await ky.post('https://example.com/upload', {
457481 body: largeFile,
458482 onUploadProgress: (progress, chunk) => {
459483 // Example output:
460484 // `0% - 0 of 1271 bytes`
461485 // `100% - 1271 of 1271 bytes`
462486 console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
463487 }
464488});
465489```
466490
467491##### parseJson
468492
469493Type: `Function`\
470494Default: `JSON.parse()`
471495
472496User-defined JSON-parsing function.
473497
474498Use-cases:
4754991. Parse JSON via the [`bourne` package](https://github.com/hapijs/bourne) to protect from prototype pollution.
4765002. Parse JSON with [`reviver` option of `JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
477501
478502```js
479503import ky from 'ky';
480504import bourne from '@hapijs/bourne';
481505
482506const json = await ky('https://example.com', {
483507 parseJson: text => bourne(text)
484508}).json();
485509```
486510
487511##### stringifyJson
488512
489513Type: `Function`\
490514Default: `JSON.stringify()`
491515
492516User-defined JSON-stringifying function.
493517
494518Use-cases:
4955191. Stringify JSON with a custom `replacer` function.
496520
497521```js
498522import ky from 'ky';
499523import {DateTime} from 'luxon';
500524
501525const json = await ky('https://example.com', {
502526 stringifyJson: data => JSON.stringify(data, (key, value) => {
503527 if (key.endsWith('_at')) {
504528 return DateTime.fromISO(value).toSeconds();
505529 }
506530
507531 return value;
508532 })
509533}).json();
510534```
511535
512536##### fetch
513537
514538Type: `Function`\
515539Default: `fetch`
516540
517541User-defined `fetch` function.
518542Has to be fully compatible with the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) standard.
519543
520544Use-cases:
5215451. Use custom `fetch` implementations like [`isomorphic-unfetch`](https://www.npmjs.com/package/isomorphic-unfetch).
5225462. Use the `fetch` wrapper function provided by some frameworks that use server-side rendering (SSR).
523547
524548```js
525549import ky from 'ky';
526550import fetch from 'isomorphic-unfetch';
527551
528552const json = await ky('https://example.com', {fetch}).json();
529553```
530554
531555### ky.extend(defaultOptions)
532556
533557Create a new `ky` instance with some defaults overridden with your own.
534558
535559In contrast to `ky.create()`, `ky.extend()` inherits defaults from its parent.
536560
537561You can pass headers as a `Headers` instance or a plain object.
538562
539563You can remove a header with `.extend()` by passing the header with an `undefined` value.
540564Passing `undefined` as a string removes the header only if it comes from a `Headers` instance.
541565
542566Similarly, you can remove existing `hooks` entries by extending the hook with an explicit `undefined`.
543567
544568```js
545569import ky from 'ky';
546570
547571const url = 'https://sindresorhus.com';
548572
549573const original = ky.create({
550574 headers: {
551575 rainbow: 'rainbow',
552576 unicorn: 'unicorn'
553577 },
554578 hooks: {
555579 beforeRequest: [ () => console.log('before 1') ],
556580 afterResponse: [ () => console.log('after 1') ],
557581 },
558582});
559583
560584const extended = original.extend({
561585 headers: {
562586 rainbow: undefined
563587 },
564588 hooks: {
565589 beforeRequest: undefined,
566590 afterResponse: [ () => console.log('after 2') ],
567591 }
568592});
569593
570594const response = await extended(url).json();
571595//=> after 1
572596//=> after 2
573597
574598console.log('rainbow' in response);
575599//=> false
576600
577601console.log('unicorn' in response);
578602//=> true
579603```
580604
581605You can also refer to parent defaults by providing a function to `.extend()`.
582606
583607```js
584608import ky from 'ky';
585609
586610const api = ky.create({prefixUrl: 'https://example.com/api'});
587611
588612const usersApi = api.extend((options) => ({prefixUrl: `${options.prefixUrl}/users`}));
589613
590614const response = await usersApi.get('123');
591615//=> 'https://example.com/api/users/123'
592616
593617const response = await api.get('version');
594618//=> 'https://example.com/api/version'
595619```
596620
597621### ky.create(defaultOptions)
598622
599623Create a new Ky instance with complete new defaults.
600624
601625```js
602626import ky from 'ky';
603627
604628// On https://my-site.com
605629
606630const api = ky.create({prefixUrl: 'https://example.com/api'});
607631
608632const response = await api.get('users/123');
609633//=> 'https://example.com/api/users/123'
610634
611635const response = await api.get('/status', {prefixUrl: ''});
612636//=> 'https://my-site.com/status'
613637```
614638
615639#### defaultOptions
616640
617641Type: `object`
618642
619643### ky.stop
620644
621645A `Symbol` that can be returned by a `beforeRetry` hook to stop the retry. This will also short circuit the remaining `beforeRetry` hooks.
622646
623647Note: Returning this symbol makes Ky abort and return with an `undefined` response. Be sure to check for a response before accessing any properties on it or use [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining). It is also incompatible with body methods, such as `.json()` or `.text()`, because there is no response to parse. In general, we recommend throwing an error instead of returning this symbol, as that will cause Ky to abort and then throw, which avoids these limitations.
624648
625649A valid use-case for `ky.stop` is to prevent retries when making requests for side effects, where the returned data is not important. For example, logging client activity to the server.
626650
627651```js
628652import ky from 'ky';
629653
630654const options = {
631655 hooks: {
632656 beforeRetry: [
633657 async ({request, options, error, retryCount}) => {
634658 const shouldStopRetry = await ky('https://example.com/api');
635659 if (shouldStopRetry) {
636660 return ky.stop;
637661 }
638662 }
639663 ]
640664 }
641665};
642666
643667// Note that response will be `undefined` in case `ky.stop` is returned.
644668const response = await ky.post('https://example.com', options);
645669
646670// Using `.text()` or other body methods is not supported.
647671const text = await ky('https://example.com', options).text();
648672```
649673
650674### HTTPError
651675
652676Exposed 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).
653677
654678Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of HTTPError and will not contain a `response` property.
655679
656680> [!IMPORTANT]
657681> When catching an `HTTPError`, you must consume or cancel the `error.response` body to prevent resource leaks (especially in Deno and Bun).
658682
659683```js
660684import {isHTTPError} from 'ky';
661685
662686try {
663687 await ky('https://example.com').json();
664688} catch (error) {
665689 if (isHTTPError(error)) {
666690 // Option 1: Read the error response body
667691 const errorJson = await error.response.json();
668692
669693 // Option 2: Cancel the body if you don't need it
670694 // await error.response.body?.cancel();
671695 }
672696}
673697```
674698
675699You can also use the `beforeError` hook:
676700
677701```js
678702await ky('https://example.com', {
679703 hooks: {
680704 beforeError: [
681705 async error => {
682706 const {response} = error;
683707 if (response) {
684708 error.message = `${error.message}: ${await response.text()}`;
685709 }
686710
687711 return error;
688712 }
689713 ]
690714 }
691715});
692716```
693717
694718⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `error.response.json()`.
695719
696720### TimeoutError
697721
698722The error thrown when the request times out. It has a `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request).
699723
700724## Tips
701725
702726### Sending form data
703727
704728Sending form data in Ky is identical to `fetch`. Just pass a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) instance to the `body` option. The `Content-Type` header will be automatically set to `multipart/form-data`.
705729
706730```js
707731import ky from 'ky';
708732
709733// `multipart/form-data`
710734const formData = new FormData();
711735formData.append('food', 'fries');
712736formData.append('drink', 'icetea');
713737
714738const response = await ky.post(url, {body: formData});
715739```
716740
717741If you want to send the data in `application/x-www-form-urlencoded` format, you will need to encode the data with [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams).
718742
719743```js
720744import ky from 'ky';
721745
722746// `application/x-www-form-urlencoded`
723747const searchParams = new URLSearchParams();
724748searchParams.set('food', 'fries');
725749searchParams.set('drink', 'icetea');
726750
727751const response = await ky.post(url, {body: searchParams});
728752```
729753
730754#### Modifying FormData in hooks
731755
732756If you need to modify FormData in a `beforeRequest` hook (for example, to transform field names), delete the `Content-Type` header before creating a new `Request`:
733757
734758```js
735759import ky from 'ky';
736760
737761const response = await ky.post(url, {
738762 body: formData,
739763 hooks: {
740764 beforeRequest: [
741765 request => {
742766 const newFormData = new FormData();
743767
744768 // Modify FormData as needed
745769 for (const [key, value] of formData) {
746770 newFormData.set(key.toLowerCase(), value);
747771 }
748772
749773 // Delete `Content-Type` to let Request regenerate it with correct boundary
750774 request.headers.delete('content-type');
751775
752776 return new Request(request, {body: newFormData});
753777 }
754778 ]
755779 }
756780});
757781```
758782
759783### Setting a custom `Content-Type`
760784
761785Ky automatically sets an appropriate [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header for each request based on the data in the request body. However, some APIs require custom, non-standard content types, such as `application/x-amz-json-1.1`. Using the `headers` option, you can manually override the content type.
762786
763787```js
764788import ky from 'ky';
765789
766790const json = await ky.post('https://example.com', {
767791 headers: {
768792 'content-type': 'application/json'
769793 },
770794 json: {
771795 foo: true
772796 },
773797}).json();
774798
775799console.log(json);
776800//=> {data: '🦄'}
777801```
778802
779803### Cancellation
780804
781805Fetch (and hence Ky) has built-in support for request cancellation through the [`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). [Read more.](https://developers.google.com/web/updates/2017/09/abortable-fetch)
782806
783807Example:
784808
785809```js
786810import ky from 'ky';
787811
788812const controller = new AbortController();
789813const {signal} = controller;
790814
791815setTimeout(() => {
792816 controller.abort();
793817}, 5000);
794818
795819try {
796820 console.log(await ky(url, {signal}).text());
797821} catch (error) {
798822 if (error.name === 'AbortError') {
799823 console.log('Fetch aborted');
800824 } else {
801825 console.error('Fetch error:', error);
802826 }
803827}
804828```
805829
806830## FAQ
807831
808832#### How do I use this in Node.js?
809833
810834Node.js 18 and later supports `fetch` natively, so you can just use this package directly.
811835
812836#### How do I use this with a web app (React, Vue.js, etc.) that uses server-side rendering (SSR)?
813837
814838Same as above.
815839
816840#### How do I test a browser library that uses this?
817841
818842Either use a test runner that can run in the browser, like Mocha, or use [AVA](https://avajs.dev) with `ky-universal`. [Read more.](https://github.com/sindresorhus/ky-universal#faq)
819843
820844#### How do I use this without a bundler like Webpack?
821845
822846Make sure your code is running as a JavaScript module (ESM), for example by using a `<script type="module">` tag in your HTML document. Then Ky can be imported directly by that module without a bundler or other tools.
823847
824848```html
825849<script type="module">
826850import ky from 'https://unpkg.com/ky/distribution/index.js';
827851
828852const json = await ky('https://jsonplaceholder.typicode.com/todos/1').json();
829853
830854console.log(json.title);
831855//=> 'delectus aut autem'
832856</script>
833857```
834858
835859#### How is it different from [`got`](https://github.com/sindresorhus/got)
836860
837861Got is maintained by the same people as Ky, so you probably want Ky instead. It's smaller, works in the browser too, and is more stable since it's built on Fetch.
838862
839863#### How is it different from [`axios`](https://github.com/axios/axios)?
840864
841865See my answer [here](https://twitter.com/sindresorhus/status/1037763588826398720).
842866
843867#### How is it different from [`r2`](https://github.com/mikeal/r2)?
844868
845869See my answer in [#10](https://github.com/sindresorhus/ky/issues/10).
846870
847871#### What does `ky` mean?
848872
849873It's just a random short npm package name I managed to get. It does, however, have a meaning in Japanese:
850874
851875> A form of text-able slang, KY is an abbreviation for 空気読めない (kuuki yomenai), which literally translates into “cannot read the air.” It's a phrase applied to someone who misses the implied meaning.
852876
853877## Browser support
854878
855879The latest version of Chrome, Firefox, and Safari.
856880
857881## Node.js support
858882
859883Node.js 18 and later.
860884
861885## Related
862886
863887- [fetch-extras](https://github.com/sindresorhus/fetch-extras) - Useful utilities for working with Fetch
864888- [ky-hooks-change-case](https://github.com/alice-health/ky-hooks-change-case) - Ky hooks to modify cases on requests and responses of objects
865889
866890## Maintainers
867891
868892- [Sindre Sorhus](https://github.com/sindresorhus)
869893- [Seth Holladay](https://github.com/sholladay)
870894- [Szymon Marczak](https://github.com/szmarczak)

Add retry.jitter option

sindresorhussindresorhus/ky #760+272−1Merged Oct 15, 2025
Files 5
Ky.tssource/core/+13−1
retry.tssource/types/+33
normalize.tssource/utils/+1
retry.tstest/+201
readme.md+24
Ky.tssource/core/+13−1
11import {HTTPError} from '../errors/HTTPError.js';
22import type {
33 Input,
44 InternalOptions,
55 NormalizedOptions,
66 Options,
77 SearchParamsInit,
88 SearchParamsOption,
99} from '../types/options.js';
1010import {type ResponsePromise} from '../types/ResponsePromise.js';
1111import {streamRequest, streamResponse} from '../utils/body.js';
1212import {mergeHeaders, mergeHooks} from '../utils/merge.js';
1313import {normalizeRequestMethod, normalizeRetryOptions} from '../utils/normalize.js';
1414import timeout, {type TimeoutOptions} from '../utils/timeout.js';
1515import delay from '../utils/delay.js';
1616import {type ObjectEntries} from '../utils/types.js';
1717import {findUnknownOptions, hasSearchParameters} from '../utils/options.js';
1818import {isHTTPError, isTimeoutError} from '../utils/type-guards.js';
1919import {
2020 maxSafeTimeout,
2121 responseTypes,
2222 stop,
2323 supportsAbortController,
2424 supportsAbortSignal,
2525 supportsFormData,
2626 supportsResponseStreams,
2727 supportsRequestStreams,
2828} from './constants.js';
2929
3030export class Ky {
3131 static create(input: Input, options: Options): ResponsePromise {
3232 const ky = new Ky(input, options);
3333
3434 const function_ = async (): Promise<Response> => {
3535 if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) {
3636 throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
3737 }
3838
3939 // Delay the fetch so that body method shortcuts can set the Accept header
4040 await Promise.resolve();
4141 // Before using ky.request, _fetch clones it and saves the clone for future retries to use.
4242 // If retry is not needed, close the cloned request's ReadableStream for memory safety.
4343 let response = await ky.#fetch();
4444
4545 for (const hook of ky.#options.hooks.afterResponse) {
4646 // eslint-disable-next-line no-await-in-loop
4747 const modifiedResponse = await hook(
4848 ky.request,
4949 ky.#getNormalizedOptions(),
5050 ky.#decorateResponse(response.clone()),
5151 {retryCount: ky.#retryCount},
5252 );
5353
5454 if (modifiedResponse instanceof globalThis.Response) {
5555 response = modifiedResponse;
5656 }
5757 }
5858
5959 ky.#decorateResponse(response);
6060
6161 if (!response.ok && ky.#options.throwHttpErrors) {
6262 let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions());
6363
6464 for (const hook of ky.#options.hooks.beforeError) {
6565 // eslint-disable-next-line no-await-in-loop
6666 error = await hook(error, {retryCount: ky.#retryCount});
6767 }
6868
6969 throw error;
7070 }
7171
7272 // If `onDownloadProgress` is passed, it uses the stream API internally
7373 if (ky.#options.onDownloadProgress) {
7474 if (typeof ky.#options.onDownloadProgress !== 'function') {
7575 throw new TypeError('The `onDownloadProgress` option must be a function');
7676 }
7777
7878 if (!supportsResponseStreams) {
7979 throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
8080 }
8181
8282 return streamResponse(response.clone(), ky.#options.onDownloadProgress);
8383 }
8484
8585 return response;
8686 };
8787
8888 const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase());
8989 const result = (isRetriableMethod ? ky.#retry(function_) : function_())
9090 .finally(async () => {
9191 const originalRequest = ky.#originalRequest;
9292 const cleanupPromises = [];
9393
9494 if (originalRequest && !originalRequest.bodyUsed) {
9595 cleanupPromises.push(originalRequest.body?.cancel());
9696 }
9797
9898 if (!ky.request.bodyUsed) {
9999 cleanupPromises.push(ky.request.body?.cancel());
100100 }
101101
102102 await Promise.all(cleanupPromises);
103103 }) as ResponsePromise;
104104
105105 for (const [type, mimeType] of Object.entries(responseTypes) as ObjectEntries<typeof responseTypes>) {
106106 // Only expose `.bytes()` when the environment implements it.
107107 if (
108108 type === 'bytes'
109109 && typeof (globalThis.Response?.prototype as unknown as {bytes?: unknown})?.bytes !== 'function'
110110 ) {
111111 continue;
112112 }
113113
114114 result[type] = async () => {
115115 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
116116 ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
117117
118118 const response = await result;
119119
120120 if (type === 'json') {
121121 if (response.status === 204) {
122122 return '';
123123 }
124124
125125 const text = await response.text();
126126 if (text === '') {
127127 return '';
128128 }
129129
130130 if (options.parseJson) {
131131 return options.parseJson(text);
132132 }
133133
134134 return JSON.parse(text);
135135 }
136136
137137 return response[type]();
138138 };
139139 }
140140
141141 return result;
142142 }
143143
144144 // eslint-disable-next-line unicorn/prevent-abbreviations
145145 static #normalizeSearchParams(searchParams: SearchParamsOption): SearchParamsOption {
146146 // Filter out undefined values from plain objects
147147 if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) {
148148 return Object.fromEntries(
149149 Object.entries(searchParams).filter(([, value]) => value !== undefined),
150150 );
151151 }
152152
153153 return searchParams;
154154 }
155155
156156 public request: Request;
157157 readonly #abortController?: AbortController;
158158 #retryCount = 0;
159159 // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202
160160 #input: Input;
161161 readonly #options: InternalOptions;
162162 #originalRequest?: Request;
163163 #cachedNormalizedOptions?: NormalizedOptions;
164164
165165 // eslint-disable-next-line complexity
166166 constructor(input: Input, options: Options = {}) {
167167 this.#input = input;
168168
169169 this.#options = {
170170 ...options,
171171 headers: mergeHeaders((this.#input as Request).headers, options.headers),
172172 hooks: mergeHooks(
173173 {
174174 beforeRequest: [],
175175 beforeRetry: [],
176176 beforeError: [],
177177 afterResponse: [],
178178 },
179179 options.hooks,
180180 ),
181181 method: normalizeRequestMethod(options.method ?? (this.#input as Request).method ?? 'GET'),
182182 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
183183 prefixUrl: String(options.prefixUrl || ''),
184184 retry: normalizeRetryOptions(options.retry),
185185 throwHttpErrors: options.throwHttpErrors !== false,
186186 timeout: options.timeout ?? 10_000,
187187 fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
188188 };
189189
190190 if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) {
191191 throw new TypeError('`input` must be a string, URL, or Request');
192192 }
193193
194194 if (this.#options.prefixUrl && typeof this.#input === 'string') {
195195 if (this.#input.startsWith('/')) {
196196 throw new Error('`input` must not begin with a slash when using `prefixUrl`');
197197 }
198198
199199 if (!this.#options.prefixUrl.endsWith('/')) {
200200 this.#options.prefixUrl += '/';
201201 }
202202
203203 this.#input = this.#options.prefixUrl + this.#input;
204204 }
205205
206206 if (supportsAbortController && supportsAbortSignal) {
207207 const originalSignal = this.#options.signal ?? (this.#input as Request).signal;
208208 this.#abortController = new globalThis.AbortController();
209209 this.#options.signal = originalSignal ? AbortSignal.any([originalSignal, this.#abortController.signal]) : this.#abortController.signal;
210210 }
211211
212212 if (supportsRequestStreams) {
213213 // @ts-expect-error - Types are outdated.
214214 this.#options.duplex = 'half';
215215 }
216216
217217 if (this.#options.json !== undefined) {
218218 this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json);
219219 this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json');
220220 }
221221
222222 // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body
223223 // Only delete if user didn't explicitly provide a custom content-type
224224 const userProvidedContentType = options.headers && new globalThis.Headers(options.headers as HeadersInit).has('content-type');
225225 if (
226226 this.#input instanceof globalThis.Request
227227 && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams)
228228 && !userProvidedContentType
229229 ) {
230230 this.#options.headers.delete('content-type');
231231 }
232232
233233 this.request = new globalThis.Request(this.#input, this.#options);
234234
235235 if (hasSearchParameters(this.#options.searchParams)) {
236236 // eslint-disable-next-line unicorn/prevent-abbreviations
237237 const textSearchParams = typeof this.#options.searchParams === 'string'
238238 ? this.#options.searchParams.replace(/^\?/, '')
239239 : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams) as unknown as SearchParamsInit).toString();
240240 // eslint-disable-next-line unicorn/prevent-abbreviations
241241 const searchParams = '?' + textSearchParams;
242242 const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
243243
244244 // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
245245 this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this.#options as RequestInit);
246246 }
247247
248248 // If `onUploadProgress` is passed, it uses the stream API internally
249249 if (this.#options.onUploadProgress) {
250250 if (typeof this.#options.onUploadProgress !== 'function') {
251251 throw new TypeError('The `onUploadProgress` option must be a function');
252252 }
253253
254254 if (!supportsRequestStreams) {
255255 throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
256256 }
257257
258258 const originalBody = this.request.body;
259259 if (originalBody) {
260260 // Pass original body to calculate size correctly (before it becomes a stream)
261261 this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body);
262262 }
263263 }
264264 }
265265
266266 #calculateRetryDelay(error: unknown) {
267267 this.#retryCount++;
268268
269269 if (this.#retryCount > this.#options.retry.limit || isTimeoutError(error)) {
270270 throw error;
271271 }
272272
273273 if (isHTTPError(error)) {
274274 if (!this.#options.retry.statusCodes.includes(error.response.status)) {
275275 throw error;
276276 }
277277
278278 const retryAfter = error.response.headers.get('Retry-After')
279279 ?? error.response.headers.get('RateLimit-Reset')
280280 ?? error.response.headers.get('X-RateLimit-Reset') // GitHub
281281 ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter
282282 if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) {
283283 let after = Number(retryAfter) * 1000;
284284 if (Number.isNaN(after)) {
285285 after = Date.parse(retryAfter) - Date.now();
286286 } else if (after >= Date.parse('2024-01-01')) {
287287 // A large number is treated as a timestamp (fixed threshold protects against clock skew)
288288 after -= Date.now();
289289 }
290290
291291 const max = this.#options.retry.maxRetryAfter ?? after;
292292 return after < max ? after : max;
293293 }
294294
295295 if (error.response.status === 413) {
296296 throw error;
297297 }
298298 }
299299
300300 const retryDelay = this.#options.retry.delay(this.#retryCount);
301− return Math.min(this.#options.retry.backoffLimit, retryDelay);
301+
302+ let jitteredDelay = retryDelay;
303+ if (this.#options.retry.jitter === true) {
304+ jitteredDelay = Math.random() * retryDelay;
305+ } else if (typeof this.#options.retry.jitter === 'function') {
306+ jitteredDelay = this.#options.retry.jitter(retryDelay);
307+
308+ if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) {
309+ jitteredDelay = retryDelay;
310+ }
311+ }
312+
313+ return Math.min(this.#options.retry.backoffLimit, jitteredDelay);
302314 }
303315
304316 #decorateResponse(response: Response): Response {
305317 if (this.#options.parseJson) {
306318 response.json = async () => this.#options.parseJson!(await response.text());
307319 }
308320
309321 return response;
310322 }
311323
312324 async #retry<T extends (...arguments_: any) => Promise<any>>(function_: T): Promise<ReturnType<T> | void> {
313325 try {
314326 return await function_();
315327 } catch (error) {
316328 const ms = Math.min(this.#calculateRetryDelay(error), maxSafeTimeout);
317329 if (this.#retryCount < 1) {
318330 throw error;
319331 }
320332
321333 await delay(ms, {signal: this.#options.signal});
322334
323335 for (const hook of this.#options.hooks.beforeRetry) {
324336 // eslint-disable-next-line no-await-in-loop
325337 const hookResult = await hook({
326338 request: this.request,
327339 options: this.#getNormalizedOptions(),
328340 error: error as Error,
329341 retryCount: this.#retryCount,
330342 });
331343
332344 // If `stop` is returned from the hook, the retry process is stopped
333345 if (hookResult === stop) {
334346 return;
335347 }
336348 }
337349
338350 return this.#retry(function_);
339351 }
340352 }
341353
342354 async #fetch(): Promise<Response> {
343355 for (const hook of this.#options.hooks.beforeRequest) {
344356 // eslint-disable-next-line no-await-in-loop
345357 const result = await hook(
346358 this.request,
347359 this.#getNormalizedOptions(),
348360 {retryCount: this.#retryCount},
349361 );
350362
351363 if (result instanceof Request) {
352364 this.request = result;
353365 break;
354366 }
355367
356368 if (result instanceof Response) {
357369 return result;
358370 }
359371 }
360372
361373 const nonRequestOptions = findUnknownOptions(this.request, this.#options);
362374
363375 // Cloning is done here to prepare in advance for retries
364376 this.#originalRequest = this.request;
365377 this.request = this.#originalRequest.clone();
366378
367379 if (this.#options.timeout === false) {
368380 return this.#options.fetch(this.#originalRequest, nonRequestOptions);
369381 }
370382
371383 return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options as TimeoutOptions);
372384 }
373385
374386 #getNormalizedOptions(): NormalizedOptions {
375387 if (!this.#cachedNormalizedOptions) {
376388 const {hooks, ...normalizedOptions} = this.#options;
377389 this.#cachedNormalizedOptions = Object.freeze(normalizedOptions) as NormalizedOptions;
378390 }
379391
380392 return this.#cachedNormalizedOptions;
381393 }
382394}
retry.tssource/types/+33
11export type RetryOptions = {
22 /**
33 The number of times to retry failed requests.
44
55 @default 2
66 */
77 limit?: number;
88
99 /**
1010 The HTTP methods allowed to retry.
1111
1212 @default ['get', 'put', 'head', 'delete', 'options', 'trace']
1313 */
1414 methods?: string[];
1515
1616 /**
1717 The HTTP status codes allowed to retry.
1818
1919 @default [408, 413, 429, 500, 502, 503, 504]
2020 */
2121 statusCodes?: number[];
2222
2323 /**
2424 The HTTP status codes allowed to retry with a `Retry-After` header.
2525
2626 @default [413, 429, 503]
2727 */
2828 afterStatusCodes?: number[];
2929
3030 /**
3131 If the `Retry-After` header is greater than `maxRetryAfter`, the request will be canceled.
3232
3333 @default Infinity
3434 */
3535 maxRetryAfter?: number;
3636
3737 /**
3838 The upper limit of the delay per retry in milliseconds.
3939 To clamp the delay, set `backoffLimit` to 1000, for example.
4040
4141 By default, the delay is calculated in the following way:
4242
4343 ```
4444 0.3 * (2 ** (attemptCount - 1)) * 1000
4545 ```
4646
4747 The delay increases exponentially.
4848
4949 @default Infinity
5050 */
5151 backoffLimit?: number;
5252
5353 /**
5454 A function to calculate the delay between retries given `attemptCount` (starts from 1).
5555
5656 @default attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000
5757 */
5858 delay?: (attemptCount: number) => number;
59+
60+ /**
61+ Add random jitter to retry delays to prevent thundering herd problems.
62+
63+ When many clients retry simultaneously (e.g., after hitting a rate limit), they can overwhelm the server again. Jitter adds randomness to break this synchronization.
64+
65+ Set to `true` to use full jitter, which randomizes the delay between 0 and the computed delay.
66+
67+ Alternatively, pass a function to implement custom jitter strategies.
68+
69+ @default undefined (no jitter)
70+
71+ @example
72+ ```
73+ import ky from 'ky';
74+
75+ const json = await ky('https://example.com', {
76+ retry: {
77+ limit: 5,
78+
79+ // Full jitter (randomizes delay between 0 and computed value)
80+ jitter: true
81+
82+ // Percentage jitter (80-120% of delay)
83+ // jitter: delay => delay * (0.8 + Math.random() * 0.4)
84+
85+ // Absolute jitter (±100ms)
86+ // jitter: delay => delay + (Math.random() * 200 - 100)
87+ }
88+ }).json();
89+ ```
90+ */
91+ jitter?: boolean | ((delay: number) => number) | undefined;
5992};
normalize.tssource/utils/+1
11import {requestMethods} from '../core/constants.js';
22import type {HttpMethod} from '../types/options.js';
33import type {RetryOptions} from '../types/retry.js';
44
55export const normalizeRequestMethod = (input: string): string =>
66 requestMethods.includes(input as HttpMethod) ? input.toUpperCase() : input;
77
88const retryMethods = ['get', 'put', 'head', 'delete', 'options', 'trace'];
99
1010const retryStatusCodes = [408, 413, 429, 500, 502, 503, 504];
1111
1212const retryAfterStatusCodes = [413, 429, 503];
1313
1414const defaultRetryOptions: Required<RetryOptions> = {
1515 limit: 2,
1616 methods: retryMethods,
1717 statusCodes: retryStatusCodes,
1818 afterStatusCodes: retryAfterStatusCodes,
1919 maxRetryAfter: Number.POSITIVE_INFINITY,
2020 backoffLimit: Number.POSITIVE_INFINITY,
2121 delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000,
22+ jitter: undefined,
2223};
2324
2425export const normalizeRetryOptions = (retry: number | RetryOptions = {}): Required<RetryOptions> => {
2526 if (typeof retry === 'number') {
2627 return {
2728 ...defaultRetryOptions,
2829 limit: retry,
2930 };
3031 }
3132
3233 if (retry.methods && !Array.isArray(retry.methods)) {
3334 throw new Error('retry.methods must be an array');
3435 }
3536
3637 if (retry.statusCodes && !Array.isArray(retry.statusCodes)) {
3738 throw new Error('retry.statusCodes must be an array');
3839 }
3940
4041 return {
4142 ...defaultRetryOptions,
4243 ...retry,
4344 };
4445};
retry.tstest/+201
11import test from 'ava';
22import ky from '../source/index.js';
33import {createHttpTestServer} from './helpers/create-http-test-server.js';
44import {withPerformance} from './helpers/with-performance.js';
55
66const fixture = 'fixture';
77const defaultRetryCount = 2;
88const retryAfterOn500 = 2;
99const retryAfterOn413 = 2;
1010
1111test('network error', async t => {
1212 let requestCount = 0;
1313
1414 const server = await createHttpTestServer();
1515 server.get('/', (_request, response) => {
1616 requestCount++;
1717
1818 if (requestCount === defaultRetryCount + 1) {
1919 response.end(fixture);
2020 } else {
2121 response.status(99_999).end();
2222 }
2323 });
2424
2525 t.is(await ky(server.url).text(), fixture);
2626 t.is(requestCount, defaultRetryCount + 1);
2727
2828 await server.close();
2929});
3030
3131test('status code 500', async t => {
3232 let requestCount = 0;
3333
3434 const server = await createHttpTestServer();
3535 server.get('/', (_request, response) => {
3636 requestCount++;
3737
3838 if (requestCount === defaultRetryCount + 1) {
3939 response.end(fixture);
4040 } else {
4141 response.sendStatus(500);
4242 }
4343 });
4444
4545 t.is(await ky(server.url).text(), fixture);
4646 t.is(requestCount, defaultRetryCount + 1);
4747
4848 await server.close();
4949});
5050
5151test('only on defined status codes', async t => {
5252 let requestCount = 0;
5353
5454 const server = await createHttpTestServer();
5555 server.get('/', (_request, response) => {
5656 requestCount++;
5757
5858 if (requestCount === defaultRetryCount + 1) {
5959 response.end(fixture);
6060 } else {
6161 response.sendStatus(400);
6262 }
6363 });
6464
6565 await t.throwsAsync(ky(server.url).text(), {message: /Bad Request/});
6666 t.is(requestCount, 1);
6767
6868 await server.close();
6969});
7070
7171test('not on POST', async t => {
7272 let requestCount = 0;
7373
7474 const server = await createHttpTestServer();
7575 server.post('/', (_request, response) => {
7676 requestCount++;
7777
7878 if (requestCount === defaultRetryCount + 1) {
7979 response.end(fixture);
8080 } else {
8181 response.sendStatus(500);
8282 }
8383 });
8484
8585 await t.throwsAsync(ky.post(server.url).text(), {
8686 message: /Internal Server Error/,
8787 });
8888 t.is(requestCount, 1);
8989
9090 await server.close();
9191});
9292
9393test('respect Retry-After: 0 and retry immediately', async t => {
9494 const retryCount = 4;
9595 let requestCount = 0;
9696
9797 const server = await createHttpTestServer();
9898 server.get('/', (_request, response) => {
9999 requestCount++;
100100
101101 if (requestCount === retryCount + 1) {
102102 response.end(fixture);
103103 } else {
104104 response.writeHead(413, {
105105 'Retry-After': 0,
106106 });
107107
108108 response.end('');
109109 }
110110 });
111111
112112 await withPerformance({
113113 t,
114114 expectedDuration: 4 + 4 + 4 + 4,
115115 async test() {
116116 t.is(await ky(server.url, {
117117 retry: retryCount,
118118 }).text(), fixture);
119119 },
120120 });
121121
122122 t.is(requestCount, 5);
123123
124124 await server.close();
125125});
126126
127127test('RateLimit-Reset is treated the same as Retry-After', async t => {
128128 let requestCount = 0;
129129
130130 const server = await createHttpTestServer();
131131 server.get('/', (_request, response) => {
132132 requestCount++;
133133
134134 if (requestCount === defaultRetryCount + 1) {
135135 response.end(fixture);
136136 } else {
137137 const header = (requestCount < 2) ? 'RateLimit-Reset' : 'Retry-After';
138138 response.writeHead(429, {
139139 [header]: 1,
140140 });
141141
142142 response.end('');
143143 }
144144 });
145145
146146 await withPerformance({
147147 t,
148148 expectedDuration: 1000 + 1000,
149149 async test() {
150150 t.is(await ky(server.url).text(), fixture);
151151 },
152152 });
153153
154154 t.is(requestCount, 3);
155155
156156 await server.close();
157157});
158158
159159test('RateLimit-Reset with time since epoch', async t => {
160160 let requestCount = 0;
161161
162162 const server = await createHttpTestServer();
163163 server.get('/', (_request, response) => {
164164 requestCount++;
165165
166166 if (requestCount === defaultRetryCount + 1) {
167167 response.end(fixture);
168168 } else {
169169 const twoSecondsByDelta = 2;
170170 const oneSecondByEpoch = (Date.now() / 1000) + 1;
171171 response.writeHead(429, {
172172 'RateLimit-Reset': (requestCount < 2) ? twoSecondsByDelta : oneSecondByEpoch,
173173 });
174174
175175 response.end('');
176176 }
177177 });
178178
179179 await withPerformance({
180180 t,
181181 expectedDuration: 2000 + 1000,
182182 async test() {
183183 t.is(await ky(server.url).text(), fixture);
184184 },
185185 });
186186
187187 t.is(requestCount, 3);
188188
189189 await server.close();
190190});
191191
192192test('respect 413 Retry-After', async t => {
193193 const startTime = Date.now();
194194 let requestCount = 0;
195195
196196 const server = await createHttpTestServer();
197197 server.get('/', (_request, response) => {
198198 requestCount++;
199199
200200 if (requestCount === defaultRetryCount + 1) {
201201 response.end((Date.now() - startTime).toString());
202202 } else {
203203 response.writeHead(413, {
204204 'Retry-After': retryAfterOn413,
205205 });
206206 response.end('');
207207 }
208208 });
209209
210210 const timeElapsedInMs = Number(await ky(server.url).text());
211211 t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
212212 t.is(requestCount, retryAfterOn413 + 1);
213213
214214 await server.close();
215215});
216216
217217test('respect 413 Retry-After with timestamp', async t => {
218218 const startTime = Date.now();
219219 let requestCount = 0;
220220
221221 const server = await createHttpTestServer({bodyParser: false});
222222 server.get('/', (_request, response) => {
223223 requestCount++;
224224 if (requestCount === defaultRetryCount + 1) {
225225 response.end((Date.now() - startTime).toString());
226226 } else {
227227 // @NOTE we need to round up to the next second due to http-date resolution
228228 const date = new Date(Date.now() + ((retryAfterOn413 + 1) * 1000)).toUTCString();
229229 response.writeHead(413, {
230230 'Retry-After': date,
231231 });
232232 response.end('');
233233 }
234234 });
235235
236236 const timeElapsedInMs = Number(await ky(server.url).text());
237237 t.true(timeElapsedInMs >= retryAfterOn413 * 1000);
238238 t.is(requestCount, retryAfterOn413 + 1);
239239
240240 await server.close();
241241});
242242
243243test('doesn\'t retry on 413 without Retry-After header', async t => {
244244 let requestCount = 0;
245245
246246 const server = await createHttpTestServer();
247247 server.get('/', (_request, response) => {
248248 requestCount++;
249249 response.sendStatus(413);
250250 });
251251
252252 await t.throwsAsync(ky(server.url).text(), {message: /Payload Too Large/});
253253 t.is(requestCount, 1);
254254 await ky(server.url, {throwHttpErrors: false}).text();
255255 t.is(requestCount, 2);
256256
257257 await server.close();
258258});
259259
260260test('respect custom `afterStatusCodes` (500) with Retry-After header', async t => {
261261 const startTime = Date.now();
262262 let requestCount = 0;
263263
264264 const server = await createHttpTestServer();
265265 server.get('/', (_request, response) => {
266266 requestCount++;
267267
268268 if (requestCount === defaultRetryCount + 1) {
269269 response.end((Date.now() - startTime).toString());
270270 } else {
271271 response.writeHead(500, {
272272 'Retry-After': retryAfterOn500,
273273 });
274274 response.end('');
275275 }
276276 });
277277
278278 const timeElapsedInMs = Number(await ky(server.url, {retry: {afterStatusCodes: [500]}}).text());
279279 t.true(timeElapsedInMs >= retryAfterOn500 * 1000);
280280 t.is(requestCount, retryAfterOn500 + 1);
281281
282282 await server.close();
283283});
284284
285285test('respect number of retries', async t => {
286286 let requestCount = 0;
287287
288288 const server = await createHttpTestServer();
289289 server.get('/', (_request, response) => {
290290 requestCount++;
291291 response.sendStatus(408);
292292 });
293293
294294 await t.throwsAsync(
295295 ky(server.url, {
296296 retry: {
297297 limit: 3,
298298 },
299299 }).text(),
300300 {
301301 message: /Request Timeout/,
302302 },
303303 );
304304 t.is(requestCount, 4);
305305
306306 await server.close();
307307});
308308
309309test('respect retry methods', async t => {
310310 let requestCount = 0;
311311
312312 const server = await createHttpTestServer();
313313 server.post('/', (_request, response) => {
314314 requestCount++;
315315 response.sendStatus(408);
316316 });
317317
318318 server.get('/', (_request, response) => {
319319 requestCount++;
320320 response.sendStatus(408);
321321 });
322322
323323 await t.throwsAsync(
324324 ky(server.url, {
325325 method: 'post',
326326 retry: {
327327 limit: 3,
328328 methods: ['get'],
329329 },
330330 }).text(),
331331 {
332332 message: /Request Timeout/,
333333 },
334334 );
335335 t.is(requestCount, 1);
336336
337337 requestCount = 0;
338338 await t.throwsAsync(
339339 ky(server.url, {
340340 retry: {
341341 limit: 2,
342342 methods: ['get'],
343343 },
344344 }).text(),
345345 {
346346 message: /Request Timeout/,
347347 },
348348 );
349349 t.is(requestCount, defaultRetryCount + 1);
350350
351351 await server.close();
352352});
353353
354354test('respect maxRetryAfter', async t => {
355355 const retryCount = 4;
356356 let requestCount = 0;
357357
358358 const server = await createHttpTestServer();
359359 server.get('/', (_request, response) => {
360360 requestCount++;
361361
362362 if (requestCount === retryCount + 1) {
363363 response.end(fixture);
364364 } else {
365365 response.writeHead(413, {
366366 'Retry-After': 1,
367367 });
368368
369369 response.end('');
370370 }
371371 });
372372
373373 await withPerformance({
374374 t,
375375 expectedDuration: 420 + 420 + 420 + 420,
376376 async test() {
377377 t.is(await ky(server.url, {
378378 retry: {
379379 limit: retryCount,
380380 maxRetryAfter: 420,
381381 },
382382 }).text(), fixture);
383383 },
384384 });
385385
386386 t.is(requestCount, 5);
387387
388388 requestCount = 0;
389389
390390 await withPerformance({
391391 t,
392392 expectedDuration: 1000 + 1000 + 1000 + 1000,
393393 async test() {
394394 t.is(await ky(server.url, {
395395 retry: {
396396 limit: retryCount,
397397 maxRetryAfter: 2000,
398398 },
399399 }).text(), fixture);
400400 },
401401 });
402402
403403 t.is(requestCount, 5);
404404
405405 await server.close();
406406});
407407
408408test('retry - can provide retry as number', async t => {
409409 let requestCount = 0;
410410
411411 const server = await createHttpTestServer();
412412 server.get('/', async (_request, response) => {
413413 requestCount++;
414414 response.sendStatus(408);
415415 });
416416
417417 await t.throwsAsync(ky(server.url, {retry: 4}).text(), {
418418 message: /Request Timeout/,
419419 });
420420 t.is(requestCount, 5);
421421
422422 await server.close();
423423});
424424
425425test('doesn\'t retry on 413 with empty statusCodes and methods', async t => {
426426 let requestCount = 0;
427427
428428 const server = await createHttpTestServer();
429429
430430 server.get('/', async (_request, response) => {
431431 requestCount++;
432432 response.sendStatus(413);
433433 });
434434
435435 await t.throwsAsync(
436436 ky(server.url, {
437437 retry: {
438438 limit: 10,
439439 statusCodes: [],
440440 methods: [],
441441 },
442442 }).text(),
443443 {
444444 message: /Payload Too Large/,
445445 },
446446 );
447447
448448 t.is(requestCount, 1);
449449
450450 await server.close();
451451});
452452
453453test('doesn\'t retry on 413 with empty methods', async t => {
454454 let requestCount = 0;
455455
456456 const server = await createHttpTestServer();
457457 server.get('/', async (_request, response) => {
458458 requestCount++;
459459 response.sendStatus(413);
460460 });
461461
462462 await t.throwsAsync(
463463 ky(server.url, {
464464 retry: {
465465 limit: 10,
466466 methods: [],
467467 },
468468 }).text(),
469469 {
470470 message: /Payload Too Large/,
471471 },
472472 );
473473
474474 t.is(requestCount, 1);
475475
476476 await server.close();
477477});
478478
479479test('does retry on 408 with methods provided as array', async t => {
480480 let requestCount = 0;
481481
482482 const server = await createHttpTestServer();
483483 server.get('/', async (_request, response) => {
484484 requestCount++;
485485 response.sendStatus(408);
486486 });
487487
488488 await t.throwsAsync(
489489 ky(server.url, {
490490 retry: {
491491 limit: 3,
492492 methods: ['get'],
493493 },
494494 }).text(),
495495 {
496496 message: /Request Timeout/,
497497 },
498498 );
499499
500500 t.is(requestCount, 4);
501501
502502 await server.close();
503503});
504504
505505test('does retry on 408 with statusCodes provided as array', async t => {
506506 let requestCount = 0;
507507
508508 const server = await createHttpTestServer();
509509 server.get('/', async (_request, response) => {
510510 requestCount++;
511511 response.sendStatus(408);
512512 });
513513
514514 await t.throwsAsync(
515515 ky(server.url, {
516516 retry: {
517517 limit: 3,
518518 statusCodes: [408],
519519 },
520520 }).text(),
521521 {
522522 message: /Request Timeout/,
523523 },
524524 );
525525
526526 t.is(requestCount, 4);
527527
528528 await server.close();
529529});
530530
531531test('doesn\'t retry when retry.limit is set to 0', async t => {
532532 let requestCount = 0;
533533
534534 const server = await createHttpTestServer();
535535 server.get('/', (_request, response) => {
536536 requestCount++;
537537 response.sendStatus(408);
538538 });
539539
540540 await t.throwsAsync(
541541 ky(server.url, {
542542 retry: {
543543 limit: 0,
544544 },
545545 }).text(),
546546 {
547547 message: /Request Timeout/,
548548 },
549549 );
550550
551551 t.is(requestCount, 1);
552552
553553 await server.close();
554554});
555555
556556test('throws when retry.methods is not an array', async t => {
557557 const server = await createHttpTestServer();
558558
559559 t.throws(() => {
560560 void ky(server.url, {
561561 retry: {
562562 // @ts-expect-error
563563 methods: 'get',
564564 },
565565 });
566566 });
567567
568568 await server.close();
569569});
570570
571571test('throws when retry.statusCodes is not an array', async t => {
572572 const server = await createHttpTestServer();
573573
574574 t.throws(() => {
575575 void ky(server.url, {
576576 retry: {
577577 // @ts-expect-error
578578 statusCodes: 403,
579579 },
580580 });
581581 });
582582
583583 await server.close();
584584});
585585
586586test('respect maximum backoffLimit', async t => {
587587 const retryCount = 4;
588588 let requestCount = 0;
589589
590590 const server = await createHttpTestServer();
591591 server.get('/', (_request, response) => {
592592 requestCount++;
593593
594594 if (requestCount === retryCount + 1) {
595595 response.end(fixture);
596596 } else {
597597 response.sendStatus(500);
598598 }
599599 });
600600
601601 await withPerformance({
602602 t,
603603 expectedDuration: 300 + 600 + 1200 + 2400,
604604 async test() {
605605 t.is(await ky(server.url, {
606606 retry: retryCount,
607607 }).text(), fixture);
608608 },
609609 });
610610
611611 t.is(requestCount, 5);
612612
613613 requestCount = 0;
614614
615615 await withPerformance({
616616 t,
617617 expectedDuration: 300 + 600 + 1000 + 1000,
618618 async test() {
619619 t.is(await ky(server.url, {
620620 retry: {
621621 limit: retryCount,
622622 backoffLimit: 1000,
623623 },
624624 }).text(), fixture);
625625 },
626626 });
627627
628628 t.is(requestCount, 5);
629629
630630 await server.close();
631631});
632632
633633test('respect custom retry.delay', async t => {
634634 const retryCount = 4;
635635 let requestCount = 0;
636636
637637 const server = await createHttpTestServer();
638638 server.get('/', (_request, response) => {
639639 requestCount++;
640640
641641 if (requestCount === retryCount + 1) {
642642 response.end(fixture);
643643 } else {
644644 response.sendStatus(500);
645645 }
646646 });
647647
648648 await withPerformance({
649649 t,
650650 expectedDuration: 200 + 300 + 400 + 500,
651651 async test() {
652652 t.is(await ky(server.url, {
653653 retry: {
654654 limit: retryCount,
655655 delay: n => 100 * (n + 1),
656656 },
657657 }).text(), fixture);
658658 },
659659 });
660660
661661 t.is(requestCount, 5);
662662
663663 await server.close();
664664});
665+
666+test('jitter: true applies full jitter to delay', async t => {
667+ const retryCount = 3;
668+ let requestCount = 0;
669+ const delays: number[] = [];
670+ let lastTime = Date.now();
671+
672+ const server = await createHttpTestServer();
673+ server.get('/', (_request, response) => {
674+ const now = Date.now();
675+ if (requestCount > 0) {
676+ delays.push(now - lastTime);
677+ }
678+
679+ lastTime = now;
680+ requestCount++;
681+
682+ if (requestCount === retryCount + 1) {
683+ response.end(fixture);
684+ } else {
685+ response.sendStatus(500);
686+ }
687+ });
688+
689+ await ky(server.url, {
690+ retry: {
691+ limit: retryCount,
692+ jitter: true,
693+ },
694+ }).text();
695+
696+ t.is(requestCount, 4);
697+
698+ // Full jitter should produce delays between 0 and the computed delay
699+ // Add 20% tolerance for system overhead
700+ t.true(delays[0] >= 0 && delays[0] <= 360);
701+ t.true(delays[1] >= 0 && delays[1] <= 720);
702+ t.true(delays[2] >= 0 && delays[2] <= 1440);
703+
704+ await server.close();
705+});
706+
707+test('jitter: custom function applies custom jitter', async t => {
708+ const retryCount = 3;
709+ let requestCount = 0;
710+ const jitterCalls: number[] = [];
711+
712+ const customJitter = (delay: number) => {
713+ jitterCalls.push(delay);
714+ return delay * 0.5; // Half the delay
715+ };
716+
717+ const server = await createHttpTestServer();
718+ server.get('/', (_request, response) => {
719+ requestCount++;
720+
721+ if (requestCount === retryCount + 1) {
722+ response.end(fixture);
723+ } else {
724+ response.sendStatus(500);
725+ }
726+ });
727+
728+ await ky(server.url, {
729+ retry: {
730+ limit: retryCount,
731+ jitter: customJitter,
732+ },
733+ }).text();
734+
735+ t.is(requestCount, 4);
736+ t.is(jitterCalls.length, 3); // Called for each retry
737+
738+ // Verify the jitter function received the correct delays
739+ t.is(jitterCalls[0], 300); // First retry
740+ t.is(jitterCalls[1], 600); // Second retry
741+ t.is(jitterCalls[2], 1200); // Third retry
742+
743+ await server.close();
744+});
745+
746+test('jitter respects backoffLimit', async t => {
747+ const retryCount = 3;
748+ let requestCount = 0;
749+ const delays: number[] = [];
750+ let lastTime = Date.now();
751+
752+ const server = await createHttpTestServer();
753+ server.get('/', (_request, response) => {
754+ const now = Date.now();
755+ if (requestCount > 0) {
756+ delays.push(now - lastTime);
757+ }
758+
759+ lastTime = now;
760+ requestCount++;
761+
762+ if (requestCount === retryCount + 1) {
763+ response.end(fixture);
764+ } else {
765+ response.sendStatus(500);
766+ }
767+ });
768+
769+ await ky(server.url, {
770+ retry: {
771+ limit: retryCount,
772+ backoffLimit: 500,
773+ jitter: true,
774+ },
775+ }).text();
776+
777+ t.is(requestCount, 4);
778+
779+ // With backoffLimit of 500, all delays should be <= 500ms
780+ // Even though the computed delays would be 300, 600, 1200
781+ // After jitter and backoffLimit, they should all be <= 500
782+ // Add 20% tolerance for system overhead
783+ t.true(delays[0] >= 0 && delays[0] <= 600);
784+ t.true(delays[1] >= 0 && delays[1] <= 600);
785+ t.true(delays[2] >= 0 && delays[2] <= 600);
786+
787+ await server.close();
788+});
789+
790+test('jitter works with custom delay function', async t => {
791+ const retryCount = 2;
792+ let requestCount = 0;
793+ const jitterCalls: number[] = [];
794+
795+ const customJitter = (delay: number) => {
796+ jitterCalls.push(delay);
797+ return delay * 0.5;
798+ };
799+
800+ const server = await createHttpTestServer();
801+ server.get('/', (_request, response) => {
802+ requestCount++;
803+
804+ if (requestCount === retryCount + 1) {
805+ response.end(fixture);
806+ } else {
807+ response.sendStatus(500);
808+ }
809+ });
810+
811+ await ky(server.url, {
812+ retry: {
813+ limit: retryCount,
814+ delay: n => 100 * n, // Custom delay: 100ms, 200ms, etc.
815+ jitter: customJitter,
816+ },
817+ }).text();
818+
819+ t.is(requestCount, 3);
820+ t.is(jitterCalls.length, 2);
821+ t.is(jitterCalls[0], 100); // First retry with custom delay
822+ t.is(jitterCalls[1], 200); // Second retry with custom delay
823+
824+ await server.close();
825+});
826+
827+test('jitter is not applied when Retry-After header is present', async t => {
828+ const startTime = Date.now();
829+ let requestCount = 0;
830+ const jitterCalls: number[] = [];
831+
832+ const customJitter = (delay: number) => {
833+ jitterCalls.push(delay);
834+ return delay * 0.5;
835+ };
836+
837+ const server = await createHttpTestServer();
838+ server.get('/', (_request, response) => {
839+ requestCount++;
840+
841+ if (requestCount === 3) {
842+ response.end((Date.now() - startTime).toString());
843+ } else {
844+ response.writeHead(429, {
845+ 'Retry-After': 1,
846+ });
847+ response.end('');
848+ }
849+ });
850+
851+ const timeElapsedInMs = Number(await ky(server.url, {
852+ retry: {
853+ jitter: customJitter,
854+ },
855+ }).text());
856+
857+ // Should have made 3 requests (initial + 2 retries)
858+ t.is(requestCount, 3);
859+ // Jitter function should NOT have been called when Retry-After is present
860+ t.is(jitterCalls.length, 0);
861+ // Should have waited at least 2 seconds (1s per retry)
862+ t.true(timeElapsedInMs >= 2000);
863+
864+ await server.close();
865+});
readme.md+24
11<div align="center">
22 <br>
33 <div>
44 <img width="600" height="600" src="media/logo.svg" alt="ky">
55 </div>
66 <br>
77 <br>
88 <br>
99</div>
1010
1111> Ky is a tiny and elegant HTTP client based on the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
1212
1313[![Coverage Status](https://codecov.io/gh/sindresorhus/ky/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/ky)
1414[![](https://badgen.net/bundlephobia/minzip/ky)](https://bundlephobia.com/result?p=ky)
1515
1616Ky targets [modern browsers](#browser-support), Node.js, Bun, and Deno.
1717
1818It's just a tiny package with no dependencies.
1919
2020## Benefits over plain `fetch`
2121
2222- Simpler API
2323- Method shortcuts (`ky.post()`)
2424- Treats non-2xx status codes as errors (after redirects)
2525- Retries failed requests
2626- JSON option
2727- Timeout support
2828- URL prefix option
2929- Instances with custom defaults
3030- Hooks
3131- TypeScript niceties (e.g. `.json()` supports generics and defaults to `unknown`, not `any`)
3232
3333## Install
3434
3535```sh
3636npm install ky
3737```
3838
3939###### CDN
4040
4141- [jsdelivr](https://cdn.jsdelivr.net/npm/ky/+esm)
4242- [unpkg](https://unpkg.com/ky)
4343- [esm.sh](https://esm.sh/ky)
4444
4545## Usage
4646
4747```js
4848import ky from 'ky';
4949
5050const json = await ky.post('https://example.com', {json: {foo: true}}).json();
5151
5252console.log(json);
5353//=> {data: '🦄'}
5454```
5555
5656With plain `fetch`, it would be:
5757
5858```js
5959class HTTPError extends Error {}
6060
6161const response = await fetch('https://example.com', {
6262 method: 'POST',
6363 body: JSON.stringify({foo: true}),
6464 headers: {
6565 'content-type': 'application/json'
6666 }
6767});
6868
6969if (!response.ok) {
7070 throw new HTTPError(`Fetch error: ${response.statusText}`);
7171}
7272
7373const json = await response.json();
7474
7575console.log(json);
7676//=> {data: '🦄'}
7777```
7878
7979If you are using [Deno](https://github.com/denoland/deno), import Ky from a URL. For example, using a CDN:
8080
8181```js
8282import ky from 'https://esm.sh/ky';
8383```
8484
8585## API
8686
8787### ky(input, options?)
8888
8989The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with additional `options` available (see below).
9090
9191Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response) with [`Body` methods](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body) added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.Fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` will return an empty string if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.
9292
9393Available body shortcuts: `.json()`, `.text()`, `.formData()`, `.arrayBuffer()`, `.blob()`, and `.bytes()`. The `.bytes()` shortcut is only present when the runtime supports `Response.prototype.bytes()`.
9494
9595```js
9696import ky from 'ky';
9797
9898const user = await ky('/api/user').json();
9999
100100console.log(user);
101101```
102102
103103⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `.json()`.
104104
105105```ts
106106import ky from 'ky';
107107
108108// user1 is unknown
109109const user1 = await ky('/api/users/1').json();
110110// user2 is a User
111111const user2 = await ky<User>('/api/users/2').json();
112112// user3 is a User
113113const user3 = await ky('/api/users/3').json<User>();
114114
115115console.log([user1, user2, user3]);
116116
117117// Get raw bytes (when supported by the runtime)
118118const bytes = await ky('/api/file').bytes();
119119console.log(bytes instanceof Uint8Array);
120120```
121121
122122### ky.get(input, options?)
123123### ky.post(input, options?)
124124### ky.put(input, options?)
125125### ky.patch(input, options?)
126126### ky.head(input, options?)
127127### ky.delete(input, options?)
128128
129129Sets `options.method` to the method name and makes a request.
130130
131131⌨️ **TypeScript:** Accepts an optional type parameter for use with JSON responses (see [`ky()`](#kyinput-options)).
132132
133133#### input
134134
135135Type: `string` | `URL` | `Request`
136136
137137Same as [`fetch` input](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#input).
138138
139139When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefixUrl`) will be ignored.
140140
141141#### options
142142
143143Type: `object`
144144
145145Same as [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), plus the following additional options:
146146
147147##### method
148148
149149Type: `string`\
150150Default: `'get'`
151151
152152HTTP method used to make the request.
153153
154154Internally, the standard methods (`GET`, `POST`, `PUT`, `PATCH`, `HEAD` and `DELETE`) are uppercased in order to avoid server errors due to case sensitivity.
155155
156156##### json
157157
158158Type: `object` and any other value accepted by [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
159159
160160Shortcut for sending JSON. Use this instead of the `body` option. Accepts any plain object or value, which will be `JSON.stringify()`'d and sent in the body with the correct header set.
161161
162162##### searchParams
163163
164164Type: `string | object<string, string | number | boolean | undefined> | Array<Array<string | number | boolean>> | URLSearchParams`\
165165Default: `''`
166166
167167Search parameters to include in the request URL. Setting this will override all existing search parameters in the input URL.
168168
169169Accepts any value supported by [`URLSearchParams()`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).
170170
171171When passing an object, `undefined` values are automatically filtered out, while `null` values are preserved and converted to the string `'null'`.
172172
173173##### prefixUrl
174174
175175Type: `string | URL`
176176
177177A prefix to prepend to the `input` URL when making the request. It can be any valid URL, either relative or absolute. A trailing slash `/` is optional and will be added automatically, if needed, when it is joined with `input`. Only takes effect when `input` is a string. The `input` argument cannot start with a slash `/` when using this option.
178178
179179Useful when used with [`ky.extend()`](#kyextenddefaultoptions) to create niche-specific Ky-instances.
180180
181181```js
182182import ky from 'ky';
183183
184184// On https://example.com
185185
186186const response = await ky('unicorn', {prefixUrl: '/api'});
187187//=> 'https://example.com/api/unicorn'
188188
189189const response2 = await ky('unicorn', {prefixUrl: 'https://cats.com'});
190190//=> 'https://cats.com/unicorn'
191191```
192192
193193Notes:
194194 - After `prefixUrl` and `input` are joined, the result is resolved against the [base URL](https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI) of the page (if any).
195195 - Leading slashes in `input` are disallowed when using this option to enforce consistency and avoid confusion about how the `input` URL is handled, given that `input` will not follow the normal URL resolution rules when `prefixUrl` is being used, which changes the meaning of a leading slash.
196196
197197##### retry
198198
199199Type: `object | number`\
200200Default:
201201- `limit`: `2`
202202- `methods`: `get` `put` `head` `delete` `options` `trace`
203203- `statusCodes`: [`408`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413) [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) [`500`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) [`502`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) [`504`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
204204- `afterStatusCodes`: [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413), [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503)
205205- `maxRetryAfter`: `undefined`
206206- `backoffLimit`: `undefined`
207207- `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000`
208+- `jitter`: `undefined`
208209
209210An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, and `maxRetryAfter` fields for maximum retry count, allowed methods, allowed status codes, status codes allowed to use the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.
210211
211212If `retry` is a number, it will be used as `limit` and other defaults will remain in place.
212213
213214If the response provides an HTTP status contained in `afterStatusCodes`, Ky will wait until the date, timeout, or timestamp given in the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header has passed to retry the request. If `Retry-After` is missing, the non-standard [`RateLimit-Reset`](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-05.html#section-3.3) header is used in its place as a fallback. If the provided status code is not in the list, the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header will be ignored.
214215
215216If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`. If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will use `maxRetryAfter`.
216217
217218The `backoffLimit` option is the upper limit of the delay per retry in milliseconds.
218219To clamp the delay, set `backoffLimit` to 1000, for example.
219220By default, the delay is calculated with `0.3 * (2 ** (attemptCount - 1)) * 1000`. The delay increases exponentially.
220221
221222The `delay` option can be used to change how the delay between retries is calculated. The function receives one parameter, the attempt count, starting at `1`.
222223
224+The `jitter` option adds random jitter to retry delays to prevent thundering herd problems. When many clients retry simultaneously (e.g., after hitting a rate limit), they can overwhelm the server again. Jitter adds randomness to break this synchronization. Set to `true` to use full jitter, which randomizes the delay between 0 and the computed delay. Alternatively, pass a function to implement custom jitter strategies.
225+
226+**Note:** Jitter is not applied when the server provides a `Retry-After` header, as the server's explicit timing should be respected.
227+
223228Retries are not triggered following a [timeout](#timeout).
224229
225230```js
226231import ky from 'ky';
227232
228233const json = await ky('https://example.com', {
229234 retry: {
230235 limit: 10,
231236 methods: ['get'],
232237 statusCodes: [413],
233238 backoffLimit: 3000
234239 }
235240}).json();
236241```
237242
243+```js
244+import ky from 'ky';
245+
246+const json = await ky('https://example.com', {
247+ retry: {
248+ limit: 5,
249+
250+ // Full jitter (randomizes delay between 0 and computed value)
251+ jitter: true
252+
253+ // Percentage jitter (80-120% of delay)
254+ // jitter: delay => delay * (0.8 + Math.random() * 0.4)
255+
256+ // Absolute jitter (±100ms)
257+ // jitter: delay => delay + (Math.random() * 200 - 100)
258+ }
259+}).json();
260+```
261+
238262> [!NOTE]
239263> Chromium-based browsers automatically retry `408 Request Timeout` responses at the network layer for keep-alive connections. This means requests may be retried by both the browser and ky. If you want to avoid duplicate retries, you can either set `keepalive: false` in your request options (though this may impact performance for multiple requests) or remove `408` from the retry status codes.
240264
241265##### timeout
242266
243267Type: `number | false`\
244268Default: `10000`
245269
246270Timeout in milliseconds for getting a response, including any retries. Can not be greater than 2147483647.
247271If set to `false`, there will be no timeout.
248272
249273##### hooks
250274
251275Type: `object<string, Function[]>`\
252276Default: `{beforeRequest: [], beforeRetry: [], afterResponse: []}`
253277
254278Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
255279
256280###### hooks.beforeRequest
257281
258282Type: `Function[]`\
259283Default: `[]`
260284
261285This hook enables you to modify the request right before it is sent. Ky will make no further changes to the request after this. The hook function receives the normalized request, options, and a state object. You could, for example, modify the `request.headers` here.
262286
263287The `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., avoiding overwriting headers set in `beforeRetry`).
264288
265289The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to completely avoid making an HTTP request. This can be used to mock a request, check an internal cache, etc. An **important** consideration when returning a request or response from this hook is that any remaining `beforeRequest` hooks will be skipped, so you may want to only return them from the last hook.
266290
267291```js
268292import ky from 'ky';
269293
270294const api = ky.extend({
271295 hooks: {
272296 beforeRequest: [
273297 (request, options, {retryCount}) => {
274298 // Only set default auth header on initial request, not on retries
275299 // (retries may have refreshed token set by beforeRetry)
276300 if (retryCount === 0) {
277301 request.headers.set('Authorization', 'token initial-token');
278302 }
279303 }
280304 ]
281305 }
282306});
283307
284308const response = await api.get('https://example.com/api/users');
285309```
286310
287311###### hooks.beforeRetry
288312
289313Type: `Function[]`\
290314Default: `[]`
291315
292316This hook enables you to modify the request right before retry. Ky will make no further changes to the request after this. The hook function receives an object with the normalized request and options, an error instance, and the retry count. You could, for example, modify `request.headers` here.
293317
294318The `retryCount` is always `>= 1` since this hook is only called during retries, not on the initial request.
295319
296320If the request received a response, the error will be of type `HTTPError` and the `Response` object will be available at `error.response`. Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of `HTTPError`.
297321
298322You can prevent Ky from retrying the request by throwing an error. Ky will not handle it in any way and the error will be propagated to the request initiator. The rest of the `beforeRetry` hooks will not be called in this case. Alternatively, you can return the [`ky.stop`](#kystop) symbol to do the same thing but without propagating an error (this has some limitations, see `ky.stop` docs for details).
299323
300324```js
301325import ky from 'ky';
302326
303327const response = await ky('https://example.com', {
304328 hooks: {
305329 beforeRetry: [
306330 async ({request, options, error, retryCount}) => {
307331 const token = await ky('https://example.com/refresh-token');
308332 request.headers.set('Authorization', `token ${token}`);
309333 }
310334 ]
311335 }
312336});
313337```
314338
315339###### hooks.beforeError
316340
317341Type: `Function[]`\
318342Default: `[]`
319343
320344This hook enables you to modify the `HTTPError` right before it is thrown. The hook function receives a `HTTPError` and a state object as arguments and should return an instance of `HTTPError`.
321345
322346The `state.retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between the initial request and retries, which is useful when you need different error handling based on retry attempts (e.g., showing different error messages on the final attempt).
323347
324348```js
325349import ky from 'ky';
326350
327351await ky('https://example.com', {
328352 hooks: {
329353 beforeError: [
330354 async error => {
331355 const {response} = error;
332356 if (response) {
333357 const body = await response.json();
334358 error.name = 'GitHubError';
335359 error.message = `${body.message} (${response.status})`;
336360 }
337361
338362 return error;
339363 },
340364
341365 // Or show different message based on retry count
342366 (error, state) => {
343367 if (state.retryCount === error.options.retry.limit) {
344368 error.message = `${error.message} (failed after ${state.retryCount} retries)`;
345369 }
346370
347371 return error;
348372 }
349373 ]
350374 }
351375});
352376```
353377
354378###### hooks.afterResponse
355379
356380Type: `Function[]`\
357381Default: `[]`
358382
359383This 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).
360384
361385The `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).
362386
363387```js
364388import ky from 'ky';
365389
366390const response = await ky('https://example.com', {
367391 hooks: {
368392 afterResponse: [
369393 (_request, _options, response) => {
370394 // You could do something with the response, for example, logging.
371395 log(response);
372396
373397 // Or return a `Response` instance to overwrite the response.
374398 return new Response('A different response', {status: 200});
375399 },
376400
377401 // Or retry with a fresh token on a 403 error
378402 async (request, options, response) => {
379403 if (response.status === 403) {
380404 // Get a fresh token
381405 const token = await ky('https://example.com/token').text();
382406
383407 // Retry with the token
384408 request.headers.set('Authorization', `token ${token}`);
385409
386410 return ky(request, options);
387411 }
388412 },
389413
390414 // Or show a notification only on the last retry for 5xx errors
391415 (request, options, response, {retryCount}) => {
392416 if (response.status >= 500 && response.status <= 599) {
393417 if (retryCount === options.retry.limit) {
394418 showNotification('Request failed after all retries');
395419 }
396420 }
397421 }
398422 ]
399423 }
400424});
401425```
402426
403427##### throwHttpErrors
404428
405429Type: `boolean`\
406430Default: `true`
407431
408432Throw an `HTTPError` when, after following redirects, the response has a non-2xx status code. To also throw for redirects instead of following them, set the [`redirect`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) option to `'manual'`.
409433
410434Setting this to `false` may be useful if you are checking for resource availability and are expecting error responses.
411435
412436Note: If `false`, error responses are considered successful and the request will not be retried.
413437
414438##### onDownloadProgress
415439
416440Type: `Function`
417441
418442Download progress event handler.
419443
420444The function receives these arguments:
421445- `progress` is an object with the these properties:
422446- - `percent` is a number between 0 and 1 representing the progress percentage.
423447- - `transferredBytes` is the number of bytes transferred so far.
424448- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
425449- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the first call.
426450
427451```js
428452import ky from 'ky';
429453
430454const response = await ky('https://example.com', {
431455 onDownloadProgress: (progress, chunk) => {
432456 // Example output:
433457 // `0% - 0 of 1271 bytes`
434458 // `100% - 1271 of 1271 bytes`
435459 console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
436460 }
437461});
438462```
439463
440464##### onUploadProgress
441465
442466Type: `Function`
443467
444468Upload progress event handler.
445469
446470The function receives these arguments:
447471- `progress` is an object with the these properties:
448472- - `percent` is a number between 0 and 1 representing the progress percentage.
449473- - `transferredBytes` is the number of bytes transferred so far.
450474- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
451475- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the last call.
452476
453477```js
454478import ky from 'ky';
455479
456480const response = await ky.post('https://example.com/upload', {
457481 body: largeFile,
458482 onUploadProgress: (progress, chunk) => {
459483 // Example output:
460484 // `0% - 0 of 1271 bytes`
461485 // `100% - 1271 of 1271 bytes`
462486 console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
463487 }
464488});
465489```
466490
467491##### parseJson
468492
469493Type: `Function`\
470494Default: `JSON.parse()`
471495
472496User-defined JSON-parsing function.
473497
474498Use-cases:
4754991. Parse JSON via the [`bourne` package](https://github.com/hapijs/bourne) to protect from prototype pollution.
4765002. Parse JSON with [`reviver` option of `JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
477501
478502```js
479503import ky from 'ky';
480504import bourne from '@hapijs/bourne';
481505
482506const json = await ky('https://example.com', {
483507 parseJson: text => bourne(text)
484508}).json();
485509```
486510
487511##### stringifyJson
488512
489513Type: `Function`\
490514Default: `JSON.stringify()`
491515
492516User-defined JSON-stringifying function.
493517
494518Use-cases:
4955191. Stringify JSON with a custom `replacer` function.
496520
497521```js
498522import ky from 'ky';
499523import {DateTime} from 'luxon';
500524
501525const json = await ky('https://example.com', {
502526 stringifyJson: data => JSON.stringify(data, (key, value) => {
503527 if (key.endsWith('_at')) {
504528 return DateTime.fromISO(value).toSeconds();
505529 }
506530
507531 return value;
508532 })
509533}).json();
510534```
511535
512536##### fetch
513537
514538Type: `Function`\
515539Default: `fetch`
516540
517541User-defined `fetch` function.
518542Has to be fully compatible with the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) standard.
519543
520544Use-cases:
5215451. Use custom `fetch` implementations like [`isomorphic-unfetch`](https://www.npmjs.com/package/isomorphic-unfetch).
5225462. Use the `fetch` wrapper function provided by some frameworks that use server-side rendering (SSR).
523547
524548```js
525549import ky from 'ky';
526550import fetch from 'isomorphic-unfetch';
527551
528552const json = await ky('https://example.com', {fetch}).json();
529553```
530554
531555### ky.extend(defaultOptions)
532556
533557Create a new `ky` instance with some defaults overridden with your own.
534558
535559In contrast to `ky.create()`, `ky.extend()` inherits defaults from its parent.
536560
537561You can pass headers as a `Headers` instance or a plain object.
538562
539563You can remove a header with `.extend()` by passing the header with an `undefined` value.
540564Passing `undefined` as a string removes the header only if it comes from a `Headers` instance.
541565
542566Similarly, you can remove existing `hooks` entries by extending the hook with an explicit `undefined`.
543567
544568```js
545569import ky from 'ky';
546570
547571const url = 'https://sindresorhus.com';
548572
549573const original = ky.create({
550574 headers: {
551575 rainbow: 'rainbow',
552576 unicorn: 'unicorn'
553577 },
554578 hooks: {
555579 beforeRequest: [ () => console.log('before 1') ],
556580 afterResponse: [ () => console.log('after 1') ],
557581 },
558582});
559583
560584const extended = original.extend({
561585 headers: {
562586 rainbow: undefined
563587 },
564588 hooks: {
565589 beforeRequest: undefined,
566590 afterResponse: [ () => console.log('after 2') ],
567591 }
568592});
569593
570594const response = await extended(url).json();
571595//=> after 1
572596//=> after 2
573597
574598console.log('rainbow' in response);
575599//=> false
576600
577601console.log('unicorn' in response);
578602//=> true
579603```
580604
581605You can also refer to parent defaults by providing a function to `.extend()`.
582606
583607```js
584608import ky from 'ky';
585609
586610const api = ky.create({prefixUrl: 'https://example.com/api'});
587611
588612const usersApi = api.extend((options) => ({prefixUrl: `${options.prefixUrl}/users`}));
589613
590614const response = await usersApi.get('123');
591615//=> 'https://example.com/api/users/123'
592616
593617const response = await api.get('version');
594618//=> 'https://example.com/api/version'
595619```
596620
597621### ky.create(defaultOptions)
598622
599623Create a new Ky instance with complete new defaults.
600624
601625```js
602626import ky from 'ky';
603627
604628// On https://my-site.com
605629
606630const api = ky.create({prefixUrl: 'https://example.com/api'});
607631
608632const response = await api.get('users/123');
609633//=> 'https://example.com/api/users/123'
610634
611635const response = await api.get('/status', {prefixUrl: ''});
612636//=> 'https://my-site.com/status'
613637```
614638
615639#### defaultOptions
616640
617641Type: `object`
618642
619643### ky.stop
620644
621645A `Symbol` that can be returned by a `beforeRetry` hook to stop the retry. This will also short circuit the remaining `beforeRetry` hooks.
622646
623647Note: Returning this symbol makes Ky abort and return with an `undefined` response. Be sure to check for a response before accessing any properties on it or use [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining). It is also incompatible with body methods, such as `.json()` or `.text()`, because there is no response to parse. In general, we recommend throwing an error instead of returning this symbol, as that will cause Ky to abort and then throw, which avoids these limitations.
624648
625649A valid use-case for `ky.stop` is to prevent retries when making requests for side effects, where the returned data is not important. For example, logging client activity to the server.
626650
627651```js
628652import ky from 'ky';
629653
630654const options = {
631655 hooks: {
632656 beforeRetry: [
633657 async ({request, options, error, retryCount}) => {
634658 const shouldStopRetry = await ky('https://example.com/api');
635659 if (shouldStopRetry) {
636660 return ky.stop;
637661 }
638662 }
639663 ]
640664 }
641665};
642666
643667// Note that response will be `undefined` in case `ky.stop` is returned.
644668const response = await ky.post('https://example.com', options);
645669
646670// Using `.text()` or other body methods is not supported.
647671const text = await ky('https://example.com', options).text();
648672```
649673
650674### HTTPError
651675
652676Exposed 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).
653677
654678Be aware that some types of errors, such as network errors, inherently mean that a response was not received. In that case, the error will not be an instance of HTTPError and will not contain a `response` property.
655679
656680> [!IMPORTANT]
657681> When catching an `HTTPError`, you must consume or cancel the `error.response` body to prevent resource leaks (especially in Deno and Bun).
658682
659683```js
660684import {isHTTPError} from 'ky';
661685
662686try {
663687 await ky('https://example.com').json();
664688} catch (error) {
665689 if (isHTTPError(error)) {
666690 // Option 1: Read the error response body
667691 const errorJson = await error.response.json();
668692
669693 // Option 2: Cancel the body if you don't need it
670694 // await error.response.body?.cancel();
671695 }
672696}
673697```
674698
675699You can also use the `beforeError` hook:
676700
677701```js
678702await ky('https://example.com', {
679703 hooks: {
680704 beforeError: [
681705 async error => {
682706 const {response} = error;
683707 if (response) {
684708 error.message = `${error.message}: ${await response.text()}`;
685709 }
686710
687711 return error;
688712 }
689713 ]
690714 }
691715});
692716```
693717
694718⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `error.response.json()`.
695719
696720### TimeoutError
697721
698722The error thrown when the request times out. It has a `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request).
699723
700724## Tips
701725
702726### Sending form data
703727
704728Sending form data in Ky is identical to `fetch`. Just pass a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) instance to the `body` option. The `Content-Type` header will be automatically set to `multipart/form-data`.
705729
706730```js
707731import ky from 'ky';
708732
709733// `multipart/form-data`
710734const formData = new FormData();
711735formData.append('food', 'fries');
712736formData.append('drink', 'icetea');
713737
714738const response = await ky.post(url, {body: formData});
715739```
716740
717741If you want to send the data in `application/x-www-form-urlencoded` format, you will need to encode the data with [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams).
718742
719743```js
720744import ky from 'ky';
721745
722746// `application/x-www-form-urlencoded`
723747const searchParams = new URLSearchParams();
724748searchParams.set('food', 'fries');
725749searchParams.set('drink', 'icetea');
726750
727751const response = await ky.post(url, {body: searchParams});
728752```
729753
730754#### Modifying FormData in hooks
731755
732756If you need to modify FormData in a `beforeRequest` hook (for example, to transform field names), delete the `Content-Type` header before creating a new `Request`:
733757
734758```js
735759import ky from 'ky';
736760
737761const response = await ky.post(url, {
738762 body: formData,
739763 hooks: {
740764 beforeRequest: [
741765 request => {
742766 const newFormData = new FormData();
743767
744768 // Modify FormData as needed
745769 for (const [key, value] of formData) {
746770 newFormData.set(key.toLowerCase(), value);
747771 }
748772
749773 // Delete `Content-Type` to let Request regenerate it with correct boundary
750774 request.headers.delete('content-type');
751775
752776 return new Request(request, {body: newFormData});
753777 }
754778 ]
755779 }
756780});
757781```
758782
759783### Setting a custom `Content-Type`
760784
761785Ky automatically sets an appropriate [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header for each request based on the data in the request body. However, some APIs require custom, non-standard content types, such as `application/x-amz-json-1.1`. Using the `headers` option, you can manually override the content type.
762786
763787```js
764788import ky from 'ky';
765789
766790const json = await ky.post('https://example.com', {
767791 headers: {
768792 'content-type': 'application/json'
769793 },
770794 json: {
771795 foo: true
772796 },
773797}).json();
774798
775799console.log(json);
776800//=> {data: '🦄'}
777801```
778802
779803### Cancellation
780804
781805Fetch (and hence Ky) has built-in support for request cancellation through the [`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). [Read more.](https://developers.google.com/web/updates/2017/09/abortable-fetch)
782806
783807Example:
784808
785809```js
786810import ky from 'ky';
787811
788812const controller = new AbortController();
789813const {signal} = controller;
790814
791815setTimeout(() => {
792816 controller.abort();
793817}, 5000);
794818
795819try {
796820 console.log(await ky(url, {signal}).text());
797821} catch (error) {
798822 if (error.name === 'AbortError') {
799823 console.log('Fetch aborted');
800824 } else {
801825 console.error('Fetch error:', error);
802826 }
803827}
804828```
805829
806830## FAQ
807831
808832#### How do I use this in Node.js?
809833
810834Node.js 18 and later supports `fetch` natively, so you can just use this package directly.
811835
812836#### How do I use this with a web app (React, Vue.js, etc.) that uses server-side rendering (SSR)?
813837
814838Same as above.
815839
816840#### How do I test a browser library that uses this?
817841
818842Either use a test runner that can run in the browser, like Mocha, or use [AVA](https://avajs.dev) with `ky-universal`. [Read more.](https://github.com/sindresorhus/ky-universal#faq)
819843
820844#### How do I use this without a bundler like Webpack?
821845
822846Make sure your code is running as a JavaScript module (ESM), for example by using a `<script type="module">` tag in your HTML document. Then Ky can be imported directly by that module without a bundler or other tools.
823847
824848```html
825849<script type="module">
826850import ky from 'https://unpkg.com/ky/distribution/index.js';
827851
828852const json = await ky('https://jsonplaceholder.typicode.com/todos/1').json();
829853
830854console.log(json.title);
831855//=> 'delectus aut autem'
832856</script>
833857```
834858
835859#### How is it different from [`got`](https://github.com/sindresorhus/got)
836860
837861Got is maintained by the same people as Ky, so you probably want Ky instead. It's smaller, works in the browser too, and is more stable since it's built on Fetch.
838862
839863#### How is it different from [`axios`](https://github.com/axios/axios)?
840864
841865See my answer [here](https://twitter.com/sindresorhus/status/1037763588826398720).
842866
843867#### How is it different from [`r2`](https://github.com/mikeal/r2)?
844868
845869See my answer in [#10](https://github.com/sindresorhus/ky/issues/10).
846870
847871#### What does `ky` mean?
848872
849873It's just a random short npm package name I managed to get. It does, however, have a meaning in Japanese:
850874
851875> A form of text-able slang, KY is an abbreviation for 空気読めない (kuuki yomenai), which literally translates into “cannot read the air.” It's a phrase applied to someone who misses the implied meaning.
852876
853877## Browser support
854878
855879The latest version of Chrome, Firefox, and Safari.
856880
857881## Node.js support
858882
859883Node.js 18 and later.
860884
861885## Related
862886
863887- [fetch-extras](https://github.com/sindresorhus/fetch-extras) - Useful utilities for working with Fetch
864888- [ky-hooks-change-case](https://github.com/alice-health/ky-hooks-change-case) - Ky hooks to modify cases on requests and responses of objects
865889
866890## Maintainers
867891
868892- [Sindre Sorhus](https://github.com/sindresorhus)
869893- [Seth Holladay](https://github.com/sholladay)
870894- [Szymon Marczak](https://github.com/szmarczak)