| /** | ||
| Wrapper for non-Error values that were thrown. | ||
| In JavaScript, any value can be thrown (not just Error instances). This class wraps such values to ensure consistent error handling. | ||
| */ | ||
| export declare class NonError extends Error { | ||
| name: string; | ||
| readonly value: unknown; | ||
| constructor(value: unknown); | ||
| } |
| /** | ||
| Wrapper for non-Error values that were thrown. | ||
| In JavaScript, any value can be thrown (not just Error instances). This class wraps such values to ensure consistent error handling. | ||
| */ | ||
| export class NonError extends Error { | ||
| name = 'NonError'; | ||
| value; | ||
| constructor(value) { | ||
| let message = 'Non-error value was thrown'; | ||
| // Intentionally minimal as this error is just an edge-case. | ||
| try { | ||
| if (typeof value === 'string') { | ||
| message = value; | ||
| } | ||
| else if (value && typeof value === 'object' && 'message' in value && typeof value.message === 'string') { | ||
| message = value.message; | ||
| } | ||
| } | ||
| catch { | ||
| // Use default message if accessing properties throws | ||
| } | ||
| super(message); | ||
| this.value = value; | ||
| } | ||
| } | ||
| //# sourceMappingURL=NonError.js.map |
| {"version":3,"file":"NonError.js","sourceRoot":"","sources":["../../source/errors/NonError.ts"],"names":[],"mappings":"AAAA;;;;EAIE;AACF,MAAM,OAAO,QAAS,SAAQ,KAAK;IACzB,IAAI,GAAG,UAAU,CAAC;IAClB,KAAK,CAAU;IAExB,YAAY,KAAc;QACzB,IAAI,OAAO,GAAG,4BAA4B,CAAC;QAE3C,4DAA4D;QAC5D,IAAI,CAAC;YACJ,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;gBAC/B,OAAO,GAAG,KAAK,CAAC;YACjB,CAAC;iBAAM,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,SAAS,IAAI,KAAK,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;gBAC1G,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;YACzB,CAAC;QACF,CAAC;QAAC,MAAM,CAAC;YACR,qDAAqD;QACtD,CAAC;QAED,KAAK,CAAC,OAAO,CAAC,CAAC;QAEf,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACpB,CAAC;CACD","sourcesContent":["/**\nWrapper for non-Error values that were thrown.\n\nIn JavaScript, any value can be thrown (not just Error instances). This class wraps such values to ensure consistent error handling.\n*/\nexport class NonError extends Error {\n\toverride name = 'NonError';\n\treadonly value: unknown;\n\n\tconstructor(value: unknown) {\n\t\tlet message = 'Non-error value was thrown';\n\n\t\t// Intentionally minimal as this error is just an edge-case.\n\t\ttry {\n\t\t\tif (typeof value === 'string') {\n\t\t\t\tmessage = value;\n\t\t\t} else if (value && typeof value === 'object' && 'message' in value && typeof value.message === 'string') {\n\t\t\t\tmessage = value.message;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Use default message if accessing properties throws\n\t\t}\n\n\t\tsuper(message);\n\n\t\tthis.value = value;\n\t}\n}\n"]} |
@@ -20,2 +20,5 @@ import { type KyOptionsRegistry } from '../types/options.js'; | ||
| export declare const kyOptionKeys: KyOptionsRegistry; | ||
| export declare const vendorSpecificOptions: { | ||
| readonly next: true; | ||
| }; | ||
| export declare const requestOptionsRegistry: { | ||
@@ -22,0 +25,0 @@ readonly method: true; |
@@ -63,3 +63,10 @@ export const supportsRequestStreams = (() => { | ||
| fetch: true, | ||
| context: true, | ||
| }; | ||
| // Vendor-specific fetch options that should always be passed to fetch() | ||
| // even if they appear on the Request object due to vendor patching. | ||
| // See: https://github.com/sindresorhus/ky/issues/541 | ||
| export const vendorSpecificOptions = { | ||
| next: true, // Next.js cache revalidation (revalidate, tags) | ||
| }; | ||
| // Standard RequestInit options that should NOT be passed separately to fetch() | ||
@@ -66,0 +73,0 @@ // because they're already applied to the Request object. |
@@ -1,2 +0,2 @@ | ||
| import type { Input, InternalOptions, Options } from '../types/options.js'; | ||
| import type { Input, Options } from '../types/options.js'; | ||
| import { type ResponsePromise } from '../types/ResponsePromise.js'; | ||
@@ -7,12 +7,3 @@ export declare class Ky { | ||
| request: Request; | ||
| protected abortController?: AbortController; | ||
| protected _retryCount: number; | ||
| protected _input: Input; | ||
| protected _options: InternalOptions; | ||
| protected _originalRequest?: Request; | ||
| constructor(input: Input, options?: Options); | ||
| protected _calculateRetryDelay(error: unknown): number; | ||
| protected _decorateResponse(response: Response): Response; | ||
| protected _retry<T extends (...arguments_: any) => Promise<any>>(function_: T): Promise<ReturnType<T> | void>; | ||
| protected _fetch(): Promise<Response>; | ||
| } |
+131
-76
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { NonError } from '../errors/NonError.js'; | ||
| import { streamRequest, streamResponse } from '../utils/body.js'; | ||
@@ -14,3 +15,3 @@ import { mergeHeaders, mergeHooks } from '../utils/merge.js'; | ||
| const function_ = async () => { | ||
| if (typeof ky._options.timeout === 'number' && ky._options.timeout > maxSafeTimeout) { | ||
| if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) { | ||
| throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`); | ||
@@ -22,6 +23,6 @@ } | ||
| // If retry is not needed, close the cloned request's ReadableStream for memory safety. | ||
| let response = await ky._fetch(); | ||
| for (const hook of ky._options.hooks.afterResponse) { | ||
| let response = await ky.#fetch(); | ||
| for (const hook of ky.#options.hooks.afterResponse) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), ky._decorateResponse(response.clone()), { retryCount: ky._retryCount }); | ||
| const modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), ky.#decorateResponse(response.clone()), { retryCount: ky.#retryCount }); | ||
| if (modifiedResponse instanceof globalThis.Response) { | ||
@@ -31,8 +32,8 @@ response = modifiedResponse; | ||
| } | ||
| ky._decorateResponse(response); | ||
| if (!response.ok && ky._options.throwHttpErrors) { | ||
| ky.#decorateResponse(response); | ||
| if (!response.ok && ky.#options.throwHttpErrors) { | ||
| let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions()); | ||
| for (const hook of ky._options.hooks.beforeError) { | ||
| for (const hook of ky.#options.hooks.beforeError) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| error = await hook(error, { retryCount: ky._retryCount }); | ||
| error = await hook(error, { retryCount: ky.#retryCount }); | ||
| } | ||
@@ -42,4 +43,4 @@ throw error; | ||
| // If `onDownloadProgress` is passed, it uses the stream API internally | ||
| if (ky._options.onDownloadProgress) { | ||
| if (typeof ky._options.onDownloadProgress !== 'function') { | ||
| if (ky.#options.onDownloadProgress) { | ||
| if (typeof ky.#options.onDownloadProgress !== 'function') { | ||
| throw new TypeError('The `onDownloadProgress` option must be a function'); | ||
@@ -50,10 +51,10 @@ } | ||
| } | ||
| return streamResponse(response.clone(), ky._options.onDownloadProgress); | ||
| return streamResponse(response.clone(), ky.#options.onDownloadProgress); | ||
| } | ||
| return response; | ||
| }; | ||
| const isRetriableMethod = ky._options.retry.methods.includes(ky.request.method.toLowerCase()); | ||
| const result = (isRetriableMethod ? ky._retry(function_) : function_()) | ||
| const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase()); | ||
| const result = (isRetriableMethod ? ky.#retry(function_) : function_()) | ||
| .finally(async () => { | ||
| const originalRequest = ky._originalRequest; | ||
| const originalRequest = ky.#originalRequest; | ||
| const cleanupPromises = []; | ||
@@ -105,14 +106,16 @@ if (originalRequest && !originalRequest.bodyUsed) { | ||
| request; | ||
| abortController; | ||
| _retryCount = 0; | ||
| _input; | ||
| _options; | ||
| _originalRequest; | ||
| #abortController; | ||
| #retryCount = 0; | ||
| // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202 | ||
| #input; | ||
| #options; | ||
| #originalRequest; | ||
| #userProvidedAbortSignal; | ||
| #cachedNormalizedOptions; | ||
| // eslint-disable-next-line complexity | ||
| constructor(input, options = {}) { | ||
| this._input = input; | ||
| this._options = { | ||
| this.#input = input; | ||
| this.#options = { | ||
| ...options, | ||
| headers: mergeHeaders(this._input.headers, options.headers), | ||
| headers: mergeHeaders(this.#input.headers, options.headers), | ||
| hooks: mergeHooks({ | ||
@@ -124,3 +127,3 @@ beforeRequest: [], | ||
| }, options.hooks), | ||
| method: normalizeRequestMethod(options.method ?? this._input.method ?? 'GET'), | ||
| method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'), | ||
| // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing | ||
@@ -132,27 +135,28 @@ prefixUrl: String(options.prefixUrl || ''), | ||
| fetch: options.fetch ?? globalThis.fetch.bind(globalThis), | ||
| context: options.context ?? {}, | ||
| }; | ||
| if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globalThis.Request)) { | ||
| if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) { | ||
| throw new TypeError('`input` must be a string, URL, or Request'); | ||
| } | ||
| if (this._options.prefixUrl && typeof this._input === 'string') { | ||
| if (this._input.startsWith('/')) { | ||
| if (this.#options.prefixUrl && typeof this.#input === 'string') { | ||
| if (this.#input.startsWith('/')) { | ||
| throw new Error('`input` must not begin with a slash when using `prefixUrl`'); | ||
| } | ||
| if (!this._options.prefixUrl.endsWith('/')) { | ||
| this._options.prefixUrl += '/'; | ||
| if (!this.#options.prefixUrl.endsWith('/')) { | ||
| this.#options.prefixUrl += '/'; | ||
| } | ||
| this._input = this._options.prefixUrl + this._input; | ||
| this.#input = this.#options.prefixUrl + this.#input; | ||
| } | ||
| if (supportsAbortController && supportsAbortSignal) { | ||
| const originalSignal = this._options.signal ?? this._input.signal; | ||
| this.abortController = new globalThis.AbortController(); | ||
| this._options.signal = originalSignal ? AbortSignal.any([originalSignal, this.abortController.signal]) : this.abortController.signal; | ||
| this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal; | ||
| this.#abortController = new globalThis.AbortController(); | ||
| this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal; | ||
| } | ||
| if (supportsRequestStreams) { | ||
| // @ts-expect-error - Types are outdated. | ||
| this._options.duplex = 'half'; | ||
| this.#options.duplex = 'half'; | ||
| } | ||
| if (this._options.json !== undefined) { | ||
| this._options.body = this._options.stringifyJson?.(this._options.json) ?? JSON.stringify(this._options.json); | ||
| this._options.headers.set('content-type', this._options.headers.get('content-type') ?? 'application/json'); | ||
| if (this.#options.json !== undefined) { | ||
| this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json); | ||
| this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json'); | ||
| } | ||
@@ -162,13 +166,13 @@ // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body | ||
| const userProvidedContentType = options.headers && new globalThis.Headers(options.headers).has('content-type'); | ||
| if (this._input instanceof globalThis.Request | ||
| && ((supportsFormData && this._options.body instanceof globalThis.FormData) || this._options.body instanceof URLSearchParams) | ||
| if (this.#input instanceof globalThis.Request | ||
| && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams) | ||
| && !userProvidedContentType) { | ||
| this._options.headers.delete('content-type'); | ||
| this.#options.headers.delete('content-type'); | ||
| } | ||
| this.request = new globalThis.Request(this._input, this._options); | ||
| if (hasSearchParameters(this._options.searchParams)) { | ||
| this.request = new globalThis.Request(this.#input, this.#options); | ||
| if (hasSearchParameters(this.#options.searchParams)) { | ||
| // eslint-disable-next-line unicorn/prevent-abbreviations | ||
| const textSearchParams = typeof this._options.searchParams === 'string' | ||
| ? this._options.searchParams.replace(/^\?/, '') | ||
| : new URLSearchParams(Ky.#normalizeSearchParams(this._options.searchParams)).toString(); | ||
| const textSearchParams = typeof this.#options.searchParams === 'string' | ||
| ? this.#options.searchParams.replace(/^\?/, '') | ||
| : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams)).toString(); | ||
| // eslint-disable-next-line unicorn/prevent-abbreviations | ||
@@ -178,7 +182,7 @@ const searchParams = '?' + textSearchParams; | ||
| // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws. | ||
| this.request = new globalThis.Request(new globalThis.Request(url, { ...this.request }), this._options); | ||
| this.request = new globalThis.Request(new globalThis.Request(url, { ...this.request }), this.#options); | ||
| } | ||
| // If `onUploadProgress` is passed, it uses the stream API internally | ||
| if (this._options.onUploadProgress) { | ||
| if (typeof this._options.onUploadProgress !== 'function') { | ||
| if (this.#options.onUploadProgress) { | ||
| if (typeof this.#options.onUploadProgress !== 'function') { | ||
| throw new TypeError('The `onUploadProgress` option must be a function'); | ||
@@ -192,13 +196,46 @@ } | ||
| // Pass original body to calculate size correctly (before it becomes a stream) | ||
| this.request = streamRequest(this.request, this._options.onUploadProgress, this._options.body); | ||
| this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body); | ||
| } | ||
| } | ||
| } | ||
| _calculateRetryDelay(error) { | ||
| this._retryCount++; | ||
| if (this._retryCount > this._options.retry.limit || isTimeoutError(error)) { | ||
| #calculateDelay() { | ||
| const retryDelay = this.#options.retry.delay(this.#retryCount); | ||
| let jitteredDelay = retryDelay; | ||
| if (this.#options.retry.jitter === true) { | ||
| jitteredDelay = Math.random() * retryDelay; | ||
| } | ||
| else if (typeof this.#options.retry.jitter === 'function') { | ||
| jitteredDelay = this.#options.retry.jitter(retryDelay); | ||
| if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) { | ||
| jitteredDelay = retryDelay; | ||
| } | ||
| } | ||
| return Math.min(this.#options.retry.backoffLimit, jitteredDelay); | ||
| } | ||
| async #calculateRetryDelay(error) { | ||
| this.#retryCount++; | ||
| if (this.#retryCount > this.#options.retry.limit) { | ||
| throw error; | ||
| } | ||
| // Wrap non-Error throws to ensure consistent error handling | ||
| const errorObject = error instanceof Error ? error : new NonError(error); | ||
| // User-provided shouldRetry function takes precedence over all other checks | ||
| if (this.#options.retry.shouldRetry !== undefined) { | ||
| const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount }); | ||
| // Strict boolean checking - only exact true/false are handled specially | ||
| if (result === false) { | ||
| throw error; | ||
| } | ||
| if (result === true) { | ||
| // Force retry - skip all other validation and return delay | ||
| return this.#calculateDelay(); | ||
| } | ||
| // If undefined or any other value, fall through to default behavior | ||
| } | ||
| // Default timeout behavior | ||
| if (isTimeoutError(error) && !this.#options.retry.retryOnTimeout) { | ||
| throw error; | ||
| } | ||
| if (isHTTPError(error)) { | ||
| if (!this._options.retry.statusCodes.includes(error.response.status)) { | ||
| if (!this.#options.retry.statusCodes.includes(error.response.status)) { | ||
| throw error; | ||
@@ -208,5 +245,6 @@ } | ||
| ?? error.response.headers.get('RateLimit-Reset') | ||
| ?? error.response.headers.get('X-RateLimit-Retry-After') // Symfony-based services | ||
| ?? error.response.headers.get('X-RateLimit-Reset') // GitHub | ||
| ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter | ||
| if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) { | ||
| if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) { | ||
| let after = Number(retryAfter) * 1000; | ||
@@ -220,3 +258,4 @@ if (Number.isNaN(after)) { | ||
| } | ||
| const max = this._options.retry.maxRetryAfter ?? after; | ||
| const max = this.#options.retry.maxRetryAfter ?? after; | ||
| // Don't apply jitter when server provides explicit retry timing | ||
| return after < max ? after : max; | ||
@@ -228,12 +267,11 @@ } | ||
| } | ||
| const retryDelay = this._options.retry.delay(this._retryCount); | ||
| return Math.min(this._options.retry.backoffLimit, retryDelay); | ||
| return this.#calculateDelay(); | ||
| } | ||
| _decorateResponse(response) { | ||
| if (this._options.parseJson) { | ||
| response.json = async () => this._options.parseJson(await response.text()); | ||
| #decorateResponse(response) { | ||
| if (this.#options.parseJson) { | ||
| response.json = async () => this.#options.parseJson(await response.text()); | ||
| } | ||
| return response; | ||
| } | ||
| async _retry(function_) { | ||
| async #retry(function_) { | ||
| try { | ||
@@ -243,8 +281,9 @@ return await function_(); | ||
| catch (error) { | ||
| const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout); | ||
| if (this._retryCount < 1) { | ||
| const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout); | ||
| if (this.#retryCount < 1) { | ||
| throw error; | ||
| } | ||
| await delay(ms, { signal: this._options.signal }); | ||
| for (const hook of this._options.hooks.beforeRetry) { | ||
| // Only use user-provided signal for delay, not our internal abortController | ||
| await delay(ms, this.#userProvidedAbortSignal ? { signal: this.#userProvidedAbortSignal } : {}); | ||
| for (const hook of this.#options.hooks.beforeRetry) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
@@ -255,4 +294,13 @@ const hookResult = await hook({ | ||
| error: error, | ||
| retryCount: this._retryCount, | ||
| retryCount: this.#retryCount, | ||
| }); | ||
| // If a Request is returned, use it for the retry | ||
| if (hookResult instanceof globalThis.Request) { | ||
| this.request = hookResult; | ||
| break; | ||
| } | ||
| // If a Response is returned, use it and skip the retry | ||
| if (hookResult instanceof globalThis.Response) { | ||
| return hookResult; | ||
| } | ||
| // If `stop` is returned from the hook, the retry process is stopped | ||
@@ -263,9 +311,16 @@ if (hookResult === stop) { | ||
| } | ||
| return this._retry(function_); | ||
| return this.#retry(function_); | ||
| } | ||
| } | ||
| async _fetch() { | ||
| for (const hook of this._options.hooks.beforeRequest) { | ||
| async #fetch() { | ||
| // Reset abortController if it was aborted (happens on timeout retry) | ||
| if (this.#abortController?.signal.aborted) { | ||
| this.#abortController = new globalThis.AbortController(); | ||
| this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal; | ||
| // Recreate request with new signal | ||
| this.request = new globalThis.Request(this.request, { signal: this.#options.signal }); | ||
| } | ||
| for (const hook of this.#options.hooks.beforeRequest) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this._retryCount }); | ||
| const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this.#retryCount }); | ||
| if (result instanceof Request) { | ||
@@ -279,14 +334,14 @@ this.request = result; | ||
| } | ||
| const nonRequestOptions = findUnknownOptions(this.request, this._options); | ||
| const nonRequestOptions = findUnknownOptions(this.request, this.#options); | ||
| // Cloning is done here to prepare in advance for retries | ||
| this._originalRequest = this.request; | ||
| this.request = this._originalRequest.clone(); | ||
| if (this._options.timeout === false) { | ||
| return this._options.fetch(this._originalRequest, nonRequestOptions); | ||
| this.#originalRequest = this.request; | ||
| this.request = this.#originalRequest.clone(); | ||
| if (this.#options.timeout === false) { | ||
| return this.#options.fetch(this.#originalRequest, nonRequestOptions); | ||
| } | ||
| return timeout(this._originalRequest, nonRequestOptions, this.abortController, this._options); | ||
| return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options); | ||
| } | ||
| #getNormalizedOptions() { | ||
| if (!this.#cachedNormalizedOptions) { | ||
| const { hooks, ...normalizedOptions } = this._options; | ||
| const { hooks, ...normalizedOptions } = this.#options; | ||
| this.#cachedNormalizedOptions = Object.freeze(normalizedOptions); | ||
@@ -293,0 +348,0 @@ } |
@@ -6,3 +6,3 @@ /*! MIT License © Sindre Sorhus */ | ||
| export type { KyInstance } from './types/ky.js'; | ||
| export type { Input, Options, NormalizedOptions, RetryOptions, SearchParamsOption, Progress, } from './types/options.js'; | ||
| export type { Input, Options, NormalizedOptions, RetryOptions, ShouldRetryState, SearchParamsOption, Progress, } from './types/options.js'; | ||
| export type { Hooks, BeforeRequestHook, BeforeRequestState, BeforeRetryHook, BeforeRetryState, BeforeErrorHook, BeforeErrorState, AfterResponseHook, AfterResponseState, } from './types/hooks.js'; | ||
@@ -9,0 +9,0 @@ export type { ResponsePromise } from './types/ResponsePromise.js'; |
@@ -27,2 +27,4 @@ /*! MIT License © Sindre Sorhus */ | ||
| export { isKyError, isHTTPError, isTimeoutError } from './utils/type-guards.js'; | ||
| // Intentionally not exporting this for now as it's just an implementation detail and we don't want to commit to a certain API yet at least. | ||
| // export {NonError} from './errors/NonError.js'; | ||
| //# sourceMappingURL=index.js.map |
@@ -22,3 +22,3 @@ import { type stop } from '../core/constants.js'; | ||
| }; | ||
| export type BeforeRetryHook = (options: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>; | ||
| export type BeforeRetryHook = (options: BeforeRetryState) => Request | Response | typeof stop | void | Promise<Request | Response | typeof stop | void>; | ||
| export type AfterResponseState = { | ||
@@ -75,2 +75,4 @@ /** | ||
| The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing retry request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to skip the retry and use that response instead. **Note:** Returning a request or response skips remaining `beforeRetry` hooks. | ||
| If 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`. | ||
@@ -80,2 +82,4 @@ | ||
| **Modifying headers:** | ||
| @example | ||
@@ -90,3 +94,3 @@ ``` | ||
| const token = await ky('https://example.com/refresh-token'); | ||
| options.headers.set('Authorization', `token ${token}`); | ||
| request.headers.set('Authorization', `token ${token}`); | ||
| } | ||
@@ -98,2 +102,45 @@ ] | ||
| **Modifying the request URL:** | ||
| @example | ||
| ``` | ||
| import ky from 'ky'; | ||
| const response = await ky('https://example.com/api', { | ||
| hooks: { | ||
| beforeRetry: [ | ||
| async ({request, error}) => { | ||
| // Add query parameters based on error response | ||
| if (error.response) { | ||
| const body = await error.response.json(); | ||
| const url = new URL(request.url); | ||
| url.searchParams.set('processId', body.processId); | ||
| return new Request(url, request); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| **Returning a cached response:** | ||
| @example | ||
| ``` | ||
| import ky from 'ky'; | ||
| const response = await ky('https://example.com/api', { | ||
| hooks: { | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Use cached response instead of retrying | ||
| if (retryCount > 1 && cachedResponse) { | ||
| return cachedResponse; | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| @default [] | ||
@@ -100,0 +147,0 @@ */ |
@@ -213,2 +213,62 @@ import type { LiteralUnion, Required } from './common.js'; | ||
| fetch?: (input: Input, init?: RequestInit) => Promise<Response>; | ||
| /** | ||
| User-defined data passed to hooks. | ||
| This option allows you to pass arbitrary contextual data to hooks without polluting the request itself. The context is available in all hooks and is **guaranteed to always be an object** (never `undefined`), so you can safely access properties without optional chaining. | ||
| Use cases: | ||
| - Pass authentication tokens or API keys to hooks | ||
| - Attach request metadata for logging or debugging | ||
| - Implement conditional logic in hooks based on the request context | ||
| - Pass serverless environment bindings (e.g., Cloudflare Workers) | ||
| **Note:** Context is shallow merged. Top-level properties are merged, but nested objects are replaced. Only enumerable properties are copied. | ||
| @example | ||
| ``` | ||
| import ky from 'ky'; | ||
| // Pass data to hooks | ||
| const api = ky.create({ | ||
| hooks: { | ||
| beforeRequest: [ | ||
| (request, options) => { | ||
| const {token} = options.context; | ||
| if (token) { | ||
| request.headers.set('Authorization', `Bearer ${token}`); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| await api('https://example.com', { | ||
| context: { | ||
| token: 'secret123' | ||
| } | ||
| }).json(); | ||
| // Shallow merge: only top-level properties are merged | ||
| const instance = ky.create({ | ||
| context: { | ||
| a: 1, | ||
| b: { | ||
| nested: true | ||
| } | ||
| } | ||
| }); | ||
| const extended = instance.extend({ | ||
| context: { | ||
| b: { | ||
| updated: true | ||
| }, | ||
| c: 3 | ||
| } | ||
| }); | ||
| // Result: {a: 1, b: {updated: true}, c: 3} | ||
| // Note: The original `b.nested` is gone (shallow merge) | ||
| ``` | ||
| */ | ||
| context?: Record<string, unknown>; | ||
| }; | ||
@@ -270,7 +330,8 @@ /** | ||
| } | ||
| export type InternalOptions = Required<Omit<Options, 'hooks' | 'retry'>, 'fetch' | 'prefixUrl' | 'timeout'> & { | ||
| export type InternalOptions = Required<Omit<Options, 'hooks' | 'retry' | 'context'>, 'fetch' | 'prefixUrl' | 'timeout'> & { | ||
| headers: Required<Headers>; | ||
| hooks: Required<Hooks>; | ||
| retry: Required<RetryOptions>; | ||
| retry: Required<Omit<RetryOptions, 'shouldRetry'>> & Pick<RetryOptions, 'shouldRetry'>; | ||
| prefixUrl: string; | ||
| context: Record<string, unknown>; | ||
| }; | ||
@@ -287,3 +348,4 @@ /** | ||
| onUploadProgress: Options['onUploadProgress']; | ||
| context: Record<string, unknown>; | ||
| } | ||
| export type { RetryOptions } from './retry.js'; | ||
| export type { RetryOptions, ShouldRetryState } from './retry.js'; |
@@ -1,3 +0,3 @@ | ||
| export interface KyRequest<T = unknown> extends Request { | ||
| export type KyRequest<T = unknown> = { | ||
| json: <J = T>() => Promise<J>; | ||
| } | ||
| } & Request; |
@@ -1,3 +0,3 @@ | ||
| export interface KyResponse<T = unknown> extends Response { | ||
| export type KyResponse<T = unknown> = { | ||
| json: <J = T>() => Promise<J>; | ||
| } | ||
| } & Response; |
@@ -0,1 +1,11 @@ | ||
| export type ShouldRetryState = { | ||
| /** | ||
| The error that caused the request to fail. | ||
| */ | ||
| error: Error; | ||
| /** | ||
| The number of retries attempted. Starts at 1 for the first retry. | ||
| */ | ||
| retryCount: number; | ||
| }; | ||
| export type RetryOptions = { | ||
@@ -53,2 +63,97 @@ /** | ||
| delay?: (attemptCount: number) => number; | ||
| /** | ||
| Add 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. | ||
| @default undefined (no jitter) | ||
| @example | ||
| ``` | ||
| import ky from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| retry: { | ||
| limit: 5, | ||
| // Full jitter (randomizes delay between 0 and computed value) | ||
| jitter: true | ||
| // Percentage jitter (80-120% of delay) | ||
| // jitter: delay => delay * (0.8 + Math.random() * 0.4) | ||
| // Absolute jitter (±100ms) | ||
| // jitter: delay => delay + (Math.random() * 200 - 100) | ||
| } | ||
| }).json(); | ||
| ``` | ||
| */ | ||
| jitter?: boolean | ((delay: number) => number) | undefined; | ||
| /** | ||
| Whether to retry when the request times out. | ||
| @default false | ||
| @example | ||
| ``` | ||
| import ky from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| retry: { | ||
| limit: 3, | ||
| retryOnTimeout: true | ||
| } | ||
| }).json(); | ||
| ``` | ||
| */ | ||
| retryOnTimeout?: boolean; | ||
| /** | ||
| A function to determine whether a retry should be attempted. | ||
| This function takes precedence over all other retry checks and is called first, before any other retry validation. | ||
| **Note:** This is different from the `beforeRetry` hook: | ||
| - `shouldRetry`: Controls WHETHER to retry (called before the retry decision is made) | ||
| - `beforeRetry`: Called AFTER retry is confirmed, allowing you to modify the request | ||
| Should return: | ||
| - `true` to force a retry (bypasses `retryOnTimeout`, status code checks, and other validations) | ||
| - `false` to prevent a retry (no retry will occur) | ||
| - `undefined` to use the default retry logic (`retryOnTimeout`, status codes, etc.) | ||
| @example | ||
| ``` | ||
| import ky, {HTTPError} from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| retry: { | ||
| limit: 3, | ||
| shouldRetry: ({error, retryCount}) => { | ||
| // Retry on specific business logic errors from API | ||
| if (error instanceof HTTPError) { | ||
| const status = error.response.status; | ||
| // Retry on 429 (rate limit) but only for first 2 attempts | ||
| if (status === 429 && retryCount <= 2) { | ||
| return true; | ||
| } | ||
| // Don't retry on 4xx errors except rate limits | ||
| if (status >= 400 && status < 500) { | ||
| return false; | ||
| } | ||
| } | ||
| // Use default retry logic for other errors | ||
| return undefined; | ||
| } | ||
| } | ||
| }).json(); | ||
| ``` | ||
| */ | ||
| shouldRetry?: (state: ShouldRetryState) => boolean | undefined | Promise<boolean | undefined>; | ||
| }; |
@@ -83,3 +83,3 @@ import { usualFormBoundarySize } from '../core/constants.js'; | ||
| } | ||
| const totalBytes = Number(response.headers.get('content-length')) || 0; | ||
| const totalBytes = Math.max(0, Number(response.headers.get('content-length')) || 0); | ||
| return new Response(withProgress(response.body, totalBytes, onDownloadProgress), { | ||
@@ -86,0 +86,0 @@ status: response.status, |
@@ -93,2 +93,16 @@ import { supportsAbortSignal } from '../core/constants.js'; | ||
| } | ||
| // Special handling for context - shallow merge only | ||
| if (key === 'context') { | ||
| if (value !== undefined && value !== null && (!isObject(value) || Array.isArray(value))) { | ||
| throw new TypeError('The `context` option must be an object'); | ||
| } | ||
| // Shallow merge: always create a new object to prevent mutation bugs | ||
| returnValue = { | ||
| ...returnValue, | ||
| context: (value === undefined || value === null) | ||
| ? {} | ||
| : { ...returnValue.context, ...value }, | ||
| }; | ||
| continue; | ||
| } | ||
| // Special handling for searchParams | ||
@@ -139,4 +153,7 @@ if (key === 'searchParams') { | ||
| } | ||
| if (returnValue.context === undefined) { | ||
| returnValue.context = {}; | ||
| } | ||
| return returnValue; | ||
| }; | ||
| //# sourceMappingURL=merge.js.map |
| import type { RetryOptions } from '../types/retry.js'; | ||
| export declare const normalizeRequestMethod: (input: string) => string; | ||
| export declare const normalizeRetryOptions: (retry?: number | RetryOptions) => Required<RetryOptions>; | ||
| type InternalRetryOptions = Required<Omit<RetryOptions, 'shouldRetry'>> & Pick<RetryOptions, 'shouldRetry'>; | ||
| export declare const normalizeRetryOptions: (retry?: number | RetryOptions) => InternalRetryOptions; | ||
| export {}; |
@@ -14,2 +14,4 @@ import { requestMethods } from '../core/constants.js'; | ||
| delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000, | ||
| jitter: undefined, | ||
| retryOnTimeout: false, | ||
| }; | ||
@@ -16,0 +18,0 @@ export const normalizeRetryOptions = (retry = {}) => { |
@@ -1,6 +0,16 @@ | ||
| import { kyOptionKeys, requestOptionsRegistry } from '../core/constants.js'; | ||
| import { kyOptionKeys, requestOptionsRegistry, vendorSpecificOptions } from '../core/constants.js'; | ||
| export const findUnknownOptions = (request, options) => { | ||
| const unknownOptions = {}; | ||
| for (const key in options) { | ||
| if (!(key in requestOptionsRegistry) && !(key in kyOptionKeys) && !(key in request)) { | ||
| // Skip inherited properties | ||
| if (!Object.hasOwn(options, key)) { | ||
| continue; | ||
| } | ||
| // An option is passed to fetch() if: | ||
| // 1. It's not a standard RequestInit option (not in requestOptionsRegistry) | ||
| // 2. It's not a ky-specific option (not in kyOptionKeys) | ||
| // 3. Either: | ||
| // a. It's not on the Request object, OR | ||
| // b. It's a vendor-specific option that should always be passed (in vendorSpecificOptions) | ||
| if (!(key in requestOptionsRegistry) && !(key in kyOptionKeys) && (!(key in request) || key in vendorSpecificOptions)) { | ||
| unknownOptions[key] = options[key]; | ||
@@ -7,0 +17,0 @@ } |
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { TimeoutError } from '../errors/TimeoutError.js'; | ||
| /** | ||
| * Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is a Ky error, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isKyError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data'); | ||
| * } catch (error) { | ||
| * if (isKyError(error)) { | ||
| * // Handle Ky-specific errors | ||
| * console.log('Ky error occurred:', error.message); | ||
| * } else { | ||
| * // Handle other errors | ||
| * console.log('Unknown error:', error); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| @param error - The error to check | ||
| @returns `true` if the error is a Ky error, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isKyError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data'); | ||
| } catch (error) { | ||
| if (isKyError(error)) { | ||
| // Handle Ky-specific errors | ||
| console.log('Ky error occurred:', error.message); | ||
| } else { | ||
| // Handle other errors | ||
| console.log('Unknown error:', error); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export declare function isKyError(error: unknown): error is HTTPError | TimeoutError; | ||
| /** | ||
| * Type guard to check if an error is an HTTPError. | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is an HTTPError, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isHTTPError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data'); | ||
| * } catch (error) { | ||
| * if (isHTTPError(error)) { | ||
| * console.log('HTTP error status:', error.response.status); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is an HTTPError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is an HTTPError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isHTTPError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data'); | ||
| } catch (error) { | ||
| if (isHTTPError(error)) { | ||
| console.log('HTTP error status:', error.response.status); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export declare function isHTTPError<T = unknown>(error: unknown): error is HTTPError<T>; | ||
| /** | ||
| * Type guard to check if an error is a TimeoutError. | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is a TimeoutError, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isTimeoutError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data', { timeout: 1000 }); | ||
| * } catch (error) { | ||
| * if (isTimeoutError(error)) { | ||
| * console.log('Request timed out:', error.request.url); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is a TimeoutError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is a TimeoutError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isTimeoutError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data', { timeout: 1000 }); | ||
| } catch (error) { | ||
| if (isTimeoutError(error)) { | ||
| console.log('Request timed out:', error.request.url); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export declare function isTimeoutError(error: unknown): error is TimeoutError; |
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { TimeoutError } from '../errors/TimeoutError.js'; | ||
| /** | ||
| * Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is a Ky error, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isKyError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data'); | ||
| * } catch (error) { | ||
| * if (isKyError(error)) { | ||
| * // Handle Ky-specific errors | ||
| * console.log('Ky error occurred:', error.message); | ||
| * } else { | ||
| * // Handle other errors | ||
| * console.log('Unknown error:', error); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| @param error - The error to check | ||
| @returns `true` if the error is a Ky error, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isKyError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data'); | ||
| } catch (error) { | ||
| if (isKyError(error)) { | ||
| // Handle Ky-specific errors | ||
| console.log('Ky error occurred:', error.message); | ||
| } else { | ||
| // Handle other errors | ||
| console.log('Unknown error:', error); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export function isKyError(error) { | ||
@@ -29,19 +29,19 @@ return isHTTPError(error) || isTimeoutError(error); | ||
| /** | ||
| * Type guard to check if an error is an HTTPError. | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is an HTTPError, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isHTTPError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data'); | ||
| * } catch (error) { | ||
| * if (isHTTPError(error)) { | ||
| * console.log('HTTP error status:', error.response.status); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is an HTTPError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is an HTTPError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isHTTPError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data'); | ||
| } catch (error) { | ||
| if (isHTTPError(error)) { | ||
| console.log('HTTP error status:', error.response.status); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export function isHTTPError(error) { | ||
@@ -51,19 +51,19 @@ return error instanceof HTTPError || (error?.name === HTTPError.name); | ||
| /** | ||
| * Type guard to check if an error is a TimeoutError. | ||
| * | ||
| * @param error - The error to check | ||
| * @returns `true` if the error is a TimeoutError, `false` otherwise | ||
| * | ||
| * @example | ||
| * ``` | ||
| * import ky, {isTimeoutError} from 'ky'; | ||
| * try { | ||
| * const response = await ky.get('/api/data', { timeout: 1000 }); | ||
| * } catch (error) { | ||
| * if (isTimeoutError(error)) { | ||
| * console.log('Request timed out:', error.request.url); | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| Type guard to check if an error is a TimeoutError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is a TimeoutError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isTimeoutError} from 'ky'; | ||
| try { | ||
| const response = await ky.get('/api/data', { timeout: 1000 }); | ||
| } catch (error) { | ||
| if (isTimeoutError(error)) { | ||
| console.log('Request timed out:', error.request.url); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export function isTimeoutError(error) { | ||
@@ -70,0 +70,0 @@ return error instanceof TimeoutError || (error?.name === TimeoutError.name); |
+1
-1
| { | ||
| "name": "ky", | ||
| "version": "1.12.0", | ||
| "version": "1.13.0", | ||
| "description": "Tiny and elegant HTTP client based on the Fetch API", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+290
-4
@@ -208,4 +208,7 @@ <div align="center"> | ||
| - `delay`: `attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000` | ||
| - `jitter`: `undefined` | ||
| - `retryOnTimeout`: `false` | ||
| - `shouldRetry`: `undefined` | ||
| An 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. | ||
| An object representing `limit`, `methods`, `statusCodes`, `afterStatusCodes`, `maxRetryAfter`, `backoffLimit`, `delay`, `jitter`, `retryOnTimeout`, and `shouldRetry` 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, maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time, backoff limit, delay calculation function, retry jitter, timeout retry behavior, and custom retry logic. | ||
@@ -224,4 +227,21 @@ If `retry` is a number, it will be used as `limit` and other defaults will remain in place. | ||
| Retries are not triggered following a [timeout](#timeout). | ||
| 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. | ||
| **Note:** Jitter is not applied when the server provides a `Retry-After` header, as the server's explicit timing should be respected. | ||
| The `retryOnTimeout` option determines whether to retry when a request times out. By default, retries are not triggered following a [timeout](#timeout). | ||
| The `shouldRetry` option provides custom retry logic that **takes precedence over all other retry checks**. This function is called first, before any other retry validation. | ||
| **Note:** This is different from the `beforeRetry` hook: | ||
| - `shouldRetry`: Controls WHETHER to retry (called before the retry decision is made) | ||
| - `beforeRetry`: Called AFTER retry is confirmed, allowing you to modify the request | ||
| The function receives a state object with the error and retry count (starts at 1 for the first retry), and should return: | ||
| - `true` to force a retry (bypasses `retryOnTimeout`, status code checks, and other validations) | ||
| - `false` to prevent a retry (no retry will occur) | ||
| - `undefined` to use the default retry logic (`retryOnTimeout`, status codes, etc.) | ||
| **General example** | ||
| ```js | ||
@@ -240,2 +260,68 @@ import ky from 'ky'; | ||
| **Retrying on timeout:** | ||
| ```js | ||
| import ky from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| timeout: 5000, | ||
| retry: { | ||
| limit: 3, | ||
| retryOnTimeout: true | ||
| } | ||
| }).json(); | ||
| ``` | ||
| **Using jitter to prevent thundering herd:** | ||
| ```js | ||
| import ky from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| retry: { | ||
| limit: 5, | ||
| // Full jitter (randomizes delay between 0 and computed value) | ||
| jitter: true | ||
| // Percentage jitter (80-120% of delay) | ||
| // jitter: delay => delay * (0.8 + Math.random() * 0.4) | ||
| // Absolute jitter (±100ms) | ||
| // jitter: delay => delay + (Math.random() * 200 - 100) | ||
| } | ||
| }).json(); | ||
| ``` | ||
| **Custom retry logic:** | ||
| ```js | ||
| import ky, {HTTPError} from 'ky'; | ||
| const json = await ky('https://example.com', { | ||
| retry: { | ||
| limit: 3, | ||
| shouldRetry: ({error, retryCount}) => { | ||
| // Retry on specific business logic errors from API | ||
| if (error instanceof HTTPError) { | ||
| const status = error.response.status; | ||
| // Retry on 429 (rate limit) but only for first 2 attempts | ||
| if (status === 429 && retryCount <= 2) { | ||
| return true; | ||
| } | ||
| // Don't retry on 4xx errors except rate limits | ||
| if (status >= 400 && status < 500) { | ||
| return false; | ||
| } | ||
| } | ||
| // Use default retry logic for other errors | ||
| return undefined; | ||
| } | ||
| } | ||
| }).json(); | ||
| ``` | ||
| > [!NOTE] | ||
@@ -297,2 +383,4 @@ > 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. | ||
| The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing retry request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to skip the retry and use that response instead. **Note:** Returning a request or response skips remaining `beforeRetry` hooks. | ||
| The `retryCount` is always `>= 1` since this hook is only called during retries, not on the initial request. | ||
@@ -304,2 +392,4 @@ | ||
| **Modifying headers:** | ||
| ```js | ||
@@ -320,2 +410,43 @@ import ky from 'ky'; | ||
| **Modifying the request URL:** | ||
| ```js | ||
| import ky from 'ky'; | ||
| const response = await ky('https://example.com/api', { | ||
| hooks: { | ||
| beforeRetry: [ | ||
| async ({request, error}) => { | ||
| // Add query parameters based on error response | ||
| if (error.response) { | ||
| const body = await error.response.json(); | ||
| const url = new URL(request.url); | ||
| url.searchParams.set('processId', body.processId); | ||
| return new Request(url, request); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| **Returning a cached response:** | ||
| ```js | ||
| import ky from 'ky'; | ||
| const response = await ky('https://example.com/api', { | ||
| hooks: { | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Use cached response instead of retrying | ||
| if (retryCount > 1 && cachedResponse) { | ||
| return cachedResponse; | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| ###### hooks.beforeError | ||
@@ -537,2 +668,64 @@ | ||
| ##### context | ||
| Type: `object<string, unknown>`\ | ||
| Default: `{}` | ||
| User-defined data passed to hooks. | ||
| This option allows you to pass arbitrary contextual data to hooks without polluting the request itself. The context is available in all hooks and is **guaranteed to always be an object** (never `undefined`), so you can safely access properties without optional chaining. | ||
| Use cases: | ||
| - Pass authentication tokens or API keys to hooks | ||
| - Attach request metadata for logging or debugging | ||
| - Implement conditional logic in hooks based on the request context | ||
| - Pass serverless environment bindings (e.g., Cloudflare Workers) | ||
| **Note:** Context is shallow merged. Top-level properties are merged, but nested objects are replaced. Only enumerable properties are copied. | ||
| ```js | ||
| import ky from 'ky'; | ||
| // Pass data to hooks | ||
| const api = ky.create({ | ||
| hooks: { | ||
| beforeRequest: [ | ||
| (request, options) => { | ||
| const {token} = options.context; | ||
| if (token) { | ||
| request.headers.set('Authorization', `Bearer ${token}`); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| await api('https://example.com', { | ||
| context: { | ||
| token: 'secret123' | ||
| } | ||
| }).json(); | ||
| // Shallow merge: only top-level properties are merged | ||
| const instance = ky.create({ | ||
| context: { | ||
| a: 1, | ||
| b: { | ||
| nested: true | ||
| } | ||
| } | ||
| }); | ||
| const extended = instance.extend({ | ||
| context: { | ||
| b: { | ||
| updated: true | ||
| }, | ||
| c: 3 | ||
| } | ||
| }); | ||
| // Result: {a: 1, b: {updated: true}, c: 3} | ||
| // Note: The original `b.nested` is gone (shallow merge) | ||
| ``` | ||
| ### ky.extend(defaultOptions) | ||
@@ -711,3 +904,3 @@ | ||
| Sending 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`. | ||
| Sending 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`, overriding any existing `Content-Type` header. | ||
@@ -725,3 +918,3 @@ ```js | ||
| If 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). | ||
| If 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). Like `FormData`, this will override any existing `Content-Type` headers. | ||
@@ -815,2 +1008,95 @@ ```js | ||
| ### Proxy support (Node.js) | ||
| #### Native proxy support | ||
| Node.js 24.5+ supports automatic proxy configuration via environment variables. Set `NODE_USE_ENV_PROXY=1` or use the `--use-env-proxy` CLI flag. | ||
| ```sh | ||
| NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://proxy.example.com:8080 node app.js | ||
| ``` | ||
| Or: | ||
| ```sh | ||
| node --use-env-proxy app.js | ||
| ``` | ||
| Supported environment variables: | ||
| - `HTTP_PROXY` / `http_proxy`: Proxy URL for HTTP requests | ||
| - `HTTPS_PROXY` / `https_proxy`: Proxy URL for HTTPS requests | ||
| - `NO_PROXY` / `no_proxy`: Comma-separated list of hosts to bypass the proxy | ||
| #### Using ProxyAgent | ||
| For more control, use `ProxyAgent` or `EnvHttpProxyAgent` with the `dispatcher` option. | ||
| ```js | ||
| import ky from 'ky'; | ||
| import {ProxyAgent} from 'undici'; | ||
| const proxyAgent = new ProxyAgent('http://proxy.example.com:8080'); | ||
| const response = await ky('https://example.com', { | ||
| // @ts-expect-error - dispatcher is not in the type definition, but it's passed through to fetch. | ||
| dispatcher: proxyAgent | ||
| }).json(); | ||
| ``` | ||
| Using `EnvHttpProxyAgent` to automatically read proxy settings from environment variables: | ||
| ```js | ||
| import ky from 'ky'; | ||
| import {EnvHttpProxyAgent} from 'undici'; | ||
| const proxyAgent = new EnvHttpProxyAgent(); | ||
| const api = ky.extend({ | ||
| // @ts-expect-error - dispatcher is not in the type definition, but it's passed through to fetch. | ||
| dispatcher: proxyAgent | ||
| }); | ||
| const response = await api('https://example.com').json(); | ||
| ``` | ||
| ### HTTP/2 support (Node.js) | ||
| Undici supports HTTP/2, but it's not enabled by default. Create a custom dispatcher with the `allowH2` option: | ||
| ```js | ||
| import ky from 'ky'; | ||
| import {Agent, Pool} from 'undici'; | ||
| const agent = new Agent({ | ||
| factory(origin, options) { | ||
| return new Pool(origin, { | ||
| ...options, | ||
| allowH2: true | ||
| }); | ||
| } | ||
| }); | ||
| const response = await ky('https://example.com', { | ||
| // @ts-expect-error - dispatcher is not in the type definition, but it's passed through to fetch. | ||
| dispatcher: agent | ||
| }).json(); | ||
| ``` | ||
| Combine proxy and HTTP/2: | ||
| ```js | ||
| import ky from 'ky'; | ||
| import {ProxyAgent} from 'undici'; | ||
| const proxyAgent = new ProxyAgent({ | ||
| uri: 'http://proxy.example.com:8080', | ||
| allowH2: true | ||
| }); | ||
| const response = await ky('https://example.com', { | ||
| // @ts-expect-error - dispatcher is not in the type definition, but it's passed through to fetch. | ||
| dispatcher: proxyAgent | ||
| }).json(); | ||
| ``` | ||
| ## FAQ | ||
@@ -817,0 +1103,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
236703
17.78%72
4.35%1888
18.15%1157
32.84%4
33.33%28
21.74%