UNPKG

ky

Version:

Tiny and elegant HTTP client based on the Fetch API

960 lines 45.3 kB
import { HTTPError } from '../errors/HTTPError.js'; import { NetworkError } from '../errors/NetworkError.js'; import { NonError } from '../errors/NonError.js'; import { ForceRetryError } from '../errors/ForceRetryError.js'; import { SchemaValidationError } from '../errors/SchemaValidationError.js'; import { TimeoutError } from '../errors/TimeoutError.js'; import { streamRequest, streamResponse } from '../utils/body.js'; import { cloneShallow, mergeHeaders, mergeHooks, deletedParametersSymbol, } from '../utils/merge.js'; import { normalizeRequestMethod, normalizeRetryOptions } from '../utils/normalize.js'; import timeout from '../utils/timeout.js'; import delay from '../utils/delay.js'; import { findUnknownOptions, hasSearchParameters } from '../utils/options.js'; import isRawNetworkError from '../utils/is-network-error.js'; import { isHTTPError, isNetworkError, isTimeoutError } from '../utils/type-guards.js'; import { calculateRetryTimingDelay, getRetryTimingHeader, } from './retry-timing.js'; import { maxSafeTimeout, responseTypes, stop, RetryMarker, supportsAbortController, supportsAbortSignal, supportsFormData, supportsResponseStreams, supportsRequestStreams, } from './constants.js'; const maxErrorResponseBodySize = 10 * 1024 * 1024; const prefixUrlRenamedErrorMessage = 'The `prefixUrl` option has been renamed `prefix` in v2 and enhanced to allow slashes in input. See also the new `baseUrl` option for improved flexibility with standard URL resolution: https://github.com/sindresorhus/ky#baseurl'; const timedOutResponseData = Symbol('timedOutResponseData'); const timedOutOperation = Symbol('timedOutOperation'); const createTextDecoder = (contentType) => { const match = /;\s*charset\s*=\s*(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType); const charset = match?.[1] ?? match?.[2]; if (charset) { try { return new TextDecoder(charset); } catch { } } return new TextDecoder(); }; const invalidSchemaMessage = 'The `schema` argument must follow the Standard Schema specification'; const cloneRetryOptions = (retry) => { if (retry === null || typeof retry !== 'object' || Array.isArray(retry)) { return retry; } const clonedRetry = { ...retry }; // Clone nested arrays too so init hooks can mutate retry config without leaking state across requests. if (Array.isArray(clonedRetry.methods)) { clonedRetry.methods = [...clonedRetry.methods]; } if (Array.isArray(clonedRetry.statusCodes)) { clonedRetry.statusCodes = [...clonedRetry.statusCodes]; } if (Array.isArray(clonedRetry.afterStatusCodes)) { clonedRetry.afterStatusCodes = [...clonedRetry.afterStatusCodes]; } return clonedRetry; }; const objectToString = Object.prototype.toString; const leadingC0ControlOrSpacePattern = /^[\0-\u0020]+/g; const asciiTabOrNewLinePattern = /[\t\n\r]/g; const schemePattern = /^[a-z][\d+.a-z-]*:/i; const malformedHttpProtocolPattern = /^https?:(?!\/\/)/i; const isRequestInstance = (value) => value instanceof globalThis.Request || objectToString.call(value) === '[object Request]'; // Accepted custom responses are treated as full Responses throughout Ky. // If a custom fetch returns one, it must behave like a Response for cloning, // body consumption, `json()` decoration, and any enabled stream features. const isResponseInstance = (value) => value instanceof globalThis.Response || objectToString.call(value) === '[object Response]'; const isAbsoluteInput = (input) => schemePattern.test(input); const normalizeInputForProtocolCheck = (input) => input.replaceAll(leadingC0ControlOrSpacePattern, '').replaceAll(asciiTabOrNewLinePattern, ''); const cloneSearchParametersForInitHook = (searchParameters) => { if (Array.isArray(searchParameters)) { return searchParameters.map(parameter => [...parameter]); } return cloneShallow(searchParameters); }; // Shallow-clone mutable option properties so init hook mutations don't leak across requests. function cloneInitHookOptions(options) { const clonedOptions = { ...options, json: cloneShallow(options.json), context: cloneShallow(options.context), headers: cloneShallow(options.headers), searchParams: cloneSearchParametersForInitHook(options.searchParams), }; if (options.retry !== undefined) { clonedOptions.retry = cloneRetryOptions(options.retry); } return clonedOptions; } const validateJsonWithSchema = async (jsonValue, schema) => { if ((typeof schema !== 'object' && typeof schema !== 'function') || schema === null) { throw new TypeError(invalidSchemaMessage); } const standardSchema = schema['~standard']; if (typeof standardSchema !== 'object' || standardSchema === null || typeof standardSchema.validate !== 'function') { throw new TypeError(invalidSchemaMessage); } const validationResult = await standardSchema.validate(jsonValue); if (validationResult.issues) { throw new SchemaValidationError(validationResult.issues); } return validationResult.value; }; export class Ky { static create(input, options) { const initHooks = options.hooks?.init ?? []; const initHookOptions = initHooks.length > 0 ? cloneInitHookOptions(options) : options; for (const hook of initHooks) { hook(initHookOptions); } const ky = new Ky(input, initHookOptions); const function_ = async () => { if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) { throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`); } if (typeof ky.#options.totalTimeout === 'number' && ky.#options.totalTimeout > maxSafeTimeout) { throw new RangeError(`The \`totalTimeout\` option cannot be greater than ${maxSafeTimeout}`); } // Delay the fetch so that body method shortcuts can set the Accept header await Promise.resolve(); const beforeRequestResponse = await ky.#runBeforeRequestHooks(); if (beforeRequestResponse !== undefined) { ky.#retryLimit = normalizeRetryOptions(ky.#options.retry).limit; } let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch()); let responseFromHook = beforeRequestResponse !== undefined || ky.#consumeReturnedResponseFromBeforeRetryHook(); for (;;) { // `undefined` means a hook stopped the flow without providing a response. // Non-native Responses still continue through Ky if they pass `isResponseInstance()`. if (response === undefined) { return response; } if (isResponseInstance(response)) { try { // eslint-disable-next-line no-await-in-loop response = await ky.#runAfterResponseHooks(response); } catch (error) { if (!(error instanceof ForceRetryError)) { throw error; } // eslint-disable-next-line no-await-in-loop const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch()); if (retriedResponse === undefined) { return retriedResponse; } response = retriedResponse; responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook(); continue; } } const currentResponse = response; // Opaque responses (`response.type === 'opaque'`) from `no-cors` requests always have `status: 0` and `ok: false`, but this is not a failure - the actual status is hidden by the browser. if (!currentResponse.ok && currentResponse.type !== 'opaque' && (typeof ky.#options.throwHttpErrors === 'function' ? ky.#options.throwHttpErrors(currentResponse.status) : ky.#options.throwHttpErrors)) { // `request` must reflect the request that actually failed, but `options` stays as Ky's // normalized options snapshot. Replacement `Request` instances do not preserve the // original `BodyInit`, so trying to make `options` mirror arbitrary requests would be lossy. const httpError = new HTTPError(currentResponse, ky.#getResponseRequest(currentResponse), ky.#getNormalizedOptions()); const errorToThrow = httpError; // eslint-disable-next-line no-await-in-loop httpError.data = await ky.#getResponseData(currentResponse); if (responseFromHook) { throw errorToThrow; } // eslint-disable-next-line no-await-in-loop const retriedResponse = await ky.#retryFromError(httpError, async () => ky.#fetch()); if (retriedResponse === undefined) { return retriedResponse; } response = retriedResponse; responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook(); continue; } break; } if (!isResponseInstance(response)) { return response; } ky.#decorateResponse(response); // If `onDownloadProgress` is passed, it uses the stream API internally if (ky.#options.onDownloadProgress) { if (typeof ky.#options.onDownloadProgress !== 'function') { throw new TypeError('The `onDownloadProgress` option must be a function'); } if (!supportsResponseStreams) { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } const progressResponse = response.clone(); ky.#cancelResponseBody(response); return streamResponse(progressResponse, ky.#options.onDownloadProgress); } return response; }; const result = (async () => { try { return await function_(); } catch (error) { await ky.#throwProcessedError(error); } finally { const originalRequest = ky.#originalRequest; // Ignore cancellation errors from already-locked or already-consumed streams. ky.#cancelBody(originalRequest?.body ?? undefined); // Only cancel the current request body if it's distinct from the original (i.e. it was cloned for retries). if (ky.request !== originalRequest) { ky.#cancelBody(ky.request.body ?? undefined); } } })(); for (const [type, mimeType] of Object.entries(responseTypes)) { // Only expose `.bytes()` when the environment implements it. if (type === 'bytes' && typeof globalThis.Response?.prototype?.bytes !== 'function') { continue; } result[type] = async (schema) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType); const response = await result; if (type !== 'json') { return ky.#raceBodyRead(async () => response[type](), response); } const text = await ky.#raceBodyRead(async () => response.text(), response); const request = ky.#getResponseRequest(response); const parsedResult = await ky.#raceWithTotalTimeout(async () => { const jsonValue = initHookOptions.parseJson ? await initHookOptions.parseJson(text, { request, response }) : (text === '' && schema !== undefined ? undefined : JSON.parse(text)); return schema === undefined ? jsonValue : validateJsonWithSchema(jsonValue, schema); }); if (parsedResult === timedOutOperation) { await ky.#throwProcessedError(new TimeoutError(request)); } return parsedResult; }; } return result; } // eslint-disable-next-line unicorn/prevent-abbreviations static #normalizeSearchParams(searchParams) { // Filter out undefined values from plain objects if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) { return Object.fromEntries(Object.entries(searchParams).filter(([, value]) => value !== undefined)); } return searchParams; } request; #abortController; #retryCount = 0; #retryLimit; #input; #options; #originalRequest; #userProvidedAbortSignal; #beforeRetryHookErrors = new WeakSet(); #cachedNormalizedOptions; #startTime; #returnedResponseFromBeforeRetryHook = false; #responseRequests = new WeakMap(); // eslint-disable-next-line complexity constructor(input, options = {}) { this.#input = input; if (Object.hasOwn(options, 'prefixUrl')) { throw new Error(prefixUrlRenamedErrorMessage); } this.#options = { ...options, headers: mergeHeaders(this.#input.headers, options.headers), hooks: mergeHooks({}, options.hooks), method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing prefix: String(options.prefix || ''), retry: normalizeRetryOptions(options.retry), throwHttpErrors: options.throwHttpErrors ?? true, timeout: options.timeout ?? 10_000, totalTimeout: options.totalTimeout ?? false, fetch: options.fetch ?? globalThis.fetch.bind(globalThis), context: options.context ?? {}, }; this.#retryLimit = this.#options.retry.limit; 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 (typeof this.#input === 'string') { if (this.#options.prefix) { const normalizedPrefix = this.#options.prefix.replace(/\/+$/, ''); const normalizedInput = this.#input.replace(/^\/+/, ''); this.#input = `${normalizedPrefix}/${normalizedInput}`; } if (this.#options.baseUrl) { const normalizedInput = normalizeInputForProtocolCheck(this.#input); if (malformedHttpProtocolPattern.test(normalizedInput)) { throw new TypeError('`input` url protocol must be followed by `//` when using `baseUrl`'); } if (!isAbsoluteInput(normalizedInput)) { this.#input = new URL(this.#input, (new Request(this.#options.baseUrl)).url); } } } if (supportsAbortController && supportsAbortSignal) { this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal; this.#abortController = new globalThis.AbortController(); this.#options.signal = this.#createManagedSignal(); } if (supportsRequestStreams) { // @ts-expect-error - Types are outdated. 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'); } // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body // Only delete if user didn't explicitly provide a custom content-type 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) && !userProvidedContentType) { this.#options.headers.delete('content-type'); } this.request = new globalThis.Request(this.#input, this.#options); if (hasSearchParameters(this.#options.searchParams)) { const url = new URL(this.request.url); const deleted = this.#options.searchParams?.[deletedParametersSymbol]; if (deleted) { // Remove keys from the input URL first so later searchParams entries can intentionally re-add them. for (const key of deleted) { url.searchParams.delete(key); } } if (typeof this.#options.searchParams === 'string') { const stringSearchParameters = this.#options.searchParams.replace(/^\?/, ''); if (stringSearchParameters !== '') { url.search = url.search ? `${url.search}&${stringSearchParameters}` : `?${stringSearchParameters}`; } } else { const optionsSearchParameters = new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams)); for (const [key, value] of optionsSearchParameters.entries()) { url.searchParams.append(key, value); } } if (this.#options.searchParams && typeof this.#options.searchParams === 'object' && !Array.isArray(this.#options.searchParams) && !(this.#options.searchParams instanceof URLSearchParams)) { for (const [key, value] of Object.entries(this.#options.searchParams)) { if (value === undefined) { url.searchParams.delete(key); } } } // Recreate request with the updated URL. We already have all options in this.#options, including duplex. this.request = new globalThis.Request(url, this.#options); } if (this.#options.onUploadProgress && typeof this.#options.onUploadProgress !== 'function') { throw new TypeError('The `onUploadProgress` option must be a function'); } // `totalTimeout` starts when the request pipeline is created, so it also includes // Ky's internal scheduling and user hook time before the first fetch attempt. this.#startTime = typeof this.#options.totalTimeout === 'number' ? this.#getCurrentTime() : undefined; } #calculateDelay(retry) { const retryDelay = retry.delay(this.#retryCount + 1); let jitteredDelay = retryDelay; if (retry.jitter === true) { jitteredDelay = Math.random() * retryDelay; } else if (typeof retry.jitter === 'function') { jitteredDelay = retry.jitter(retryDelay); if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) { jitteredDelay = retryDelay; } } return Math.min(retry.backoffLimit, jitteredDelay); } async #calculateRetryDelay(error) { const retry = normalizeRetryOptions(this.#options.retry); if (this.#retryCount >= Math.min(retry.limit, this.#retryLimit)) { throw error; } // Wrap non-Error throws to ensure consistent error handling const errorObject = error instanceof Error ? error : new NonError(error); // Handle forced retry from afterResponse hook - skip method check and shouldRetry if (errorObject instanceof ForceRetryError) { return errorObject.customDelay ?? this.#calculateDelay(retry); } // Check if method is retriable for non-forced retries if (!retry.methods.includes(this.request.method.toLowerCase())) { throw error; } const { shouldRetry } = retry; if (shouldRetry !== undefined) { const result = await this.#raceWithTotalTimeout(async () => shouldRetry({ error: errorObject, retryCount: this.#retryCount + 1 })); if (result === timedOutOperation) { throw new TimeoutError(this.request); } // Only exact booleans override the default retry checks. if (result === false) { throw error; } if (result === true) { return this.#calculateDelay(retry); } } // Default timeout behavior if (isTimeoutError(error)) { if (!retry.retryOnTimeout) { throw error; } return this.#calculateDelay(retry); } if (isHTTPError(error)) { if (!retry.statusCodes.includes(error.response.status)) { throw error; } const retryTimingHeader = getRetryTimingHeader(error.response.headers); if (retryTimingHeader && retry.afterStatusCodes.includes(error.response.status)) { const after = calculateRetryTimingDelay(retryTimingHeader); if (after === undefined) { // Malformed retry timing headers should not disable retries; they only lose their server-provided timing. return this.#calculateDelay(retry); } // Don't apply jitter when server provides explicit retry timing return Math.min(retry.maxRetryAfter, after); } if (error.response.status === 413) { throw error; } return this.#calculateDelay(retry); } // Only retry known retriable error types. Unknown errors (e.g., programming bugs) are not retried. if (!isNetworkError(error)) { throw error; } return this.#calculateDelay(retry); } #decorateResponse(response) { const request = this.#getResponseRequest(response); if (this.#options.parseJson) { response.json = async () => { const text = await response.text(); return this.#options.parseJson(text, { request, response }); }; } return response; } async #throwProcessedError(error) { // Non-Error throws (e.g., thrown strings) pass through unchanged if (!(error instanceof Error)) { throw error; } // Errors thrown by beforeRetry hooks must propagate unchanged. if (this.#beforeRetryHookErrors.has(error)) { throw error; } let processedError = error; for (const hook of this.#options.hooks.beforeError) { // `request` is the current failing request. `options` intentionally remains the // stable normalized Ky options snapshot for the same reason as `HTTPError` above. // eslint-disable-next-line no-await-in-loop const hookResult = await hook({ request: this.request, options: this.#getNormalizedOptions(), error: processedError, retryCount: this.#retryCount, }); // Only overwrite if the hook returns a valid Error instance. if (hookResult instanceof Error) { processedError = hookResult; } } throw processedError; } async #getResponseData(response) { // Even with request timeouts disabled, bound error-body reads so retries and error propagation // cannot be stalled indefinitely by never-ending response streams. const readTimeout = this.#getErrorDataTimeout(); const text = await this.#readResponseText(response, readTimeout.milliseconds); if (text === timedOutResponseData) { if (readTimeout.fromTotalTimeout) { throw new TimeoutError(this.request); } this.#throwIfTotalTimeoutExhausted(); return undefined; } if (!text) { return undefined; } if (!this.#isJsonContentType(response.headers.get('content-type') ?? '')) { return text; } const parseTimeout = this.#getErrorDataTimeout(); const data = await this.#parseJson(text, response, parseTimeout.milliseconds, this.#getResponseRequest(response)); if (data === timedOutResponseData) { if (parseTimeout.fromTotalTimeout) { throw new TimeoutError(this.request); } this.#throwIfTotalTimeoutExhausted(); return undefined; } return data; } #getErrorDataTimeout() { const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout; const remainingTotal = this.#getRemainingTotalTimeout(); if (remainingTotal === undefined) { return { milliseconds: errorDataTimeout, fromTotalTimeout: false, }; } if (remainingTotal <= 0) { throw new TimeoutError(this.request); } return { milliseconds: Math.min(errorDataTimeout, remainingTotal), fromTotalTimeout: remainingTotal <= errorDataTimeout, }; } #getBodyReadTimeout() { const remainingTotal = this.#getRemainingTotalTimeout(); if (remainingTotal !== undefined) { if (remainingTotal <= 0) { throw new TimeoutError(this.request); } return this.#options.timeout === false ? remainingTotal : Math.min(this.#options.timeout, remainingTotal); } return this.#options.timeout === false ? undefined : this.#options.timeout; } // Unlike error bodies (`#getResponseData`), a successful body read has no fallback value to return - // the caller's `.json()`/`.text()`/etc. promise must settle, so a timeout here always rejects. async #raceBodyRead(createBodyPromise, response) { let timeoutMs; try { timeoutMs = this.#getBodyReadTimeout(); } catch (error) { await this.#throwProcessedError(error); } const bodyPromise = createBodyPromise(); if (timeoutMs === undefined) { return bodyPromise; } const result = await Promise.race([ bodyPromise, new Promise(resolve => { const timeoutId = setTimeout(() => { resolve(timedOutResponseData); }, timeoutMs); void bodyPromise.finally(() => { clearTimeout(timeoutId); }).catch(() => undefined); }), ]); if (result === timedOutResponseData) { // The stream is locked by the native body method's own reader by this point, so // `response.body.cancel()` would reject as "already locked". Aborting the request's // signal is what actually interrupts the underlying network read. this.#abortController?.abort(); await this.#throwProcessedError(new TimeoutError(this.#getResponseRequest(response))); } return result; } async #raceWithTotalTimeout(operation) { const remainingTotal = this.#getRemainingTotalTimeout(); if (remainingTotal === undefined) { return operation(); } if (remainingTotal <= 0) { this.#abortController?.abort(); return timedOutOperation; } let timeoutId; try { const timeoutPromise = new Promise(resolve => { timeoutId = setTimeout(() => { resolve(timedOutOperation); }, remainingTotal); }); const operationResult = Promise.resolve() .then(operation) .then(value => ({ status: 'fulfilled', value })) .catch((error) => ({ status: 'rejected', error })); const result = await Promise.race([operationResult, timeoutPromise]); const remainingAfterOperation = this.#getRemainingTotalTimeout(); const didTimeOut = result === timedOutOperation || (remainingAfterOperation !== undefined && remainingAfterOperation <= 0); if (didTimeOut) { this.#abortController?.abort(); if (result === timedOutOperation) { void operationResult.then(result => { if (result.status === 'fulfilled') { this.#cancelReturnedBody(result.value); } }); } else if (result.status === 'fulfilled') { this.#cancelReturnedBody(result.value); } return timedOutOperation; } if (result.status === 'rejected') { throw result.error; } return result.value; } finally { clearTimeout(timeoutId); } } #isJsonContentType(contentType) { // Match JSON subtypes like `json`, `problem+json`, and `vnd.api+json`. const mimeType = (contentType.split(';', 1)[0] ?? '').trim().toLowerCase(); return /\/(?:.*[.+-])?json$/.test(mimeType); } async #readResponseText(response, timeoutMs) { const { body } = response; if (!body) { try { return await response.text(); } catch { return undefined; } } let reader; try { reader = body.getReader(); } catch { // Another consumer already locked the stream. return undefined; } const decoder = createTextDecoder(response.headers.get('content-type') ?? ''); const chunks = []; let totalBytes = 0; const readAll = (async () => { try { for (;;) { // eslint-disable-next-line no-await-in-loop const { done, value } = await reader.read(); if (done) { break; } totalBytes += value.byteLength; if (totalBytes > maxErrorResponseBodySize) { void reader.cancel().catch(() => undefined); return undefined; } chunks.push(decoder.decode(value, { stream: true })); } } catch { return undefined; } chunks.push(decoder.decode()); return chunks.join(''); })(); const timeoutPromise = new Promise(resolve => { const timeoutId = setTimeout(() => { resolve(timedOutResponseData); }, timeoutMs); void readAll.finally(() => { clearTimeout(timeoutId); }); }); const result = await Promise.race([readAll, timeoutPromise]); if (result === timedOutResponseData) { void reader.cancel().catch(() => undefined); } return result; } async #parseJson(text, response, timeoutMs, request) { let timeoutId; try { return await Promise.race([ Promise.resolve().then(() => this.#options.parseJson ? this.#options.parseJson(text, { request, response }) : JSON.parse(text)), new Promise(resolve => { timeoutId = setTimeout(() => { resolve(timedOutResponseData); }, timeoutMs); }), ]); } catch { return undefined; } finally { clearTimeout(timeoutId); } } #cancelBody(body) { if (!body) { return; } // Ignore cancellation failures from already-locked or already-consumed streams. void body.cancel().catch(() => undefined); } #cancelResponseBody(response) { // Ignore cancellation failures from already-locked or already-consumed streams. this.#cancelBody(response.body ?? undefined); } #cancelReturnedBody(value) { if (isResponseInstance(value)) { this.#cancelResponseBody(value); } else if (isRequestInstance(value)) { this.#cancelBody(value.body ?? undefined); } } #createManagedSignal() { return this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal; } #throwIfTotalTimeoutExhausted() { const remaining = this.#getRemainingTotalTimeout(); if (remaining !== undefined && remaining <= 0) { throw new TimeoutError(this.request); } } async #runBeforeRequestHooks() { for (const hook of this.#options.hooks.beforeRequest) { // eslint-disable-next-line no-await-in-loop const result = await this.#raceWithTotalTimeout(async () => hook({ request: this.request, options: this.#getNormalizedOptions(), retryCount: 0, })); if (result === timedOutOperation) { throw new TimeoutError(this.request); } if (isRequestInstance(result)) { this.#assignRequest(result); } else if (isResponseInstance(result)) { return result; } } return undefined; } async #runAfterResponseHooks(response) { const responseRequest = this.#getResponseRequest(response); for (const hook of this.#options.hooks.afterResponse) { const hookResponse = this.#setResponseRequest(response.clone(), responseRequest); this.#decorateResponse(hookResponse); let modifiedResponse; try { // eslint-disable-next-line no-await-in-loop modifiedResponse = await this.#raceWithTotalTimeout(async () => hook({ request: this.request, options: this.#getNormalizedOptions(), response: hookResponse, retryCount: this.#retryCount, })); if (modifiedResponse === timedOutOperation) { throw new TimeoutError(this.request); } } catch (error) { // Cancel both responses to prevent memory leaks when hook throws if (hookResponse !== response) { this.#cancelResponseBody(hookResponse); } this.#cancelResponseBody(response); throw error; } if (modifiedResponse instanceof RetryMarker) { // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun). // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec. if (hookResponse !== response) { this.#cancelResponseBody(hookResponse); } this.#cancelResponseBody(response); throw new ForceRetryError(modifiedResponse.options); } const nextResponse = isResponseInstance(modifiedResponse) ? this.#setResponseRequest(modifiedResponse, responseRequest) : response; // Cancel any response bodies we won't use to prevent memory leaks. // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation. // If the hook wrapped an existing body into a new Response, both Response objects can still point at the same stream. if (hookResponse !== response && hookResponse !== nextResponse && hookResponse.body !== nextResponse.body) { this.#cancelResponseBody(hookResponse); } if (response !== nextResponse && response.body !== nextResponse.body) { this.#cancelResponseBody(response); } response = nextResponse; } return response; } async #retry(function_) { try { return await function_(); } catch (error) { return this.#retryFromError(error, function_); } } async #retryFromError(error, function_) { this.#returnedResponseFromBeforeRetryHook = false; const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout); const delayOptions = { signal: this.#userProvidedAbortSignal }; const remainingTimeout = this.#getRemainingTotalTimeout(); if (remainingTimeout !== undefined) { if (remainingTimeout <= 0) { throw new TimeoutError(this.request); } // If waiting would consume all remaining budget, time out without starting another request. if (retryDelay >= remainingTimeout) { await delay(remainingTimeout, delayOptions); throw new TimeoutError(this.request); } } // Only use user-provided signal for delay, not our internal abortController await delay(retryDelay, delayOptions); this.#throwIfTotalTimeoutExhausted(); // Apply custom request from forced retry before beforeRetry hooks // Ensure the custom request has the correct managed signal for timeouts and user aborts if (error instanceof ForceRetryError && error.customRequest) { const customRequest = new globalThis.Request(error.customRequest, this.#options.signal ? { signal: this.#options.signal } : undefined); // Replacement Requests are authoritative by design. Do not rewrite headers here, // even for cross-origin retries. Callers using `ky.retry({request})` explicitly // opted into the exact Request they constructed. this.#assignRequest(customRequest); } for (const hook of this.#options.hooks.beforeRetry) { let hookResult; try { // eslint-disable-next-line no-await-in-loop hookResult = await this.#raceWithTotalTimeout(async () => hook({ request: this.request, options: this.#getNormalizedOptions(), error: error, retryCount: this.#retryCount + 1, })); } catch (hookError) { // Preserve the original request error path (`throw error`) so beforeError hooks can still run. if (hookError instanceof Error && hookError !== error) { this.#beforeRetryHookErrors.add(hookError); } throw hookError; } if (hookResult === timedOutOperation) { throw new TimeoutError(this.request); } if (isRequestInstance(hookResult)) { // Same contract as `ky.retry({request})`: a Request returned from `beforeRetry` // is used as-is rather than being sanitized or otherwise rewritten by Ky. this.#assignRequest(hookResult); break; } if (isResponseInstance(hookResult)) { this.#returnedResponseFromBeforeRetryHook = true; this.#retryCount++; return hookResult; } // If `stop` is returned from the hook, the retry process is stopped if (hookResult === stop) { return; } } this.#throwIfTotalTimeoutExhausted(); this.#retryCount++; return this.#retry(function_); } #consumeReturnedResponseFromBeforeRetryHook() { const value = this.#returnedResponseFromBeforeRetryHook; this.#returnedResponseFromBeforeRetryHook = false; return value; } 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.#createManagedSignal(); // Recreate request with new signal this.request = new globalThis.Request(this.request, { signal: this.#options.signal }); } const nonRequestOptions = findUnknownOptions(this.#options); this.#retryLimit = normalizeRetryOptions(this.#options.retry).limit; const retryRequest = this.#retryLimit > 0 ? this.request.clone() : undefined; const request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined); // Cloning is done here to prepare in advance for retries. // Skip cloning when retries are disabled - cloning a streaming body calls ReadableStream#tee() // which buffers the entire stream in memory, causing excessive memory usage for large uploads. this.#originalRequest = request; if (retryRequest) { this.request = retryRequest; } try { const remainingTotal = this.#getRemainingTotalTimeout(); if (remainingTotal !== undefined && remainingTotal <= 0) { throw new TimeoutError(this.request); } const effectiveTimeout = this.#options.timeout === false ? remainingTotal : (remainingTotal === undefined ? this.#options.timeout : Math.min(this.#options.timeout, remainingTotal)); const response = effectiveTimeout === undefined ? await this.#options.fetch(request, nonRequestOptions) : await timeout(request, nonRequestOptions, this.#abortController, { timeout: effectiveTimeout, fetch: this.#options.fetch, }); return this.#setResponseRequest(response, request); } catch (error) { if (isRawNetworkError(error)) { throw new NetworkError(this.request, { cause: error }); } throw error; } } #getRemainingTotalTimeout() { if (this.#startTime === undefined) { return undefined; } const elapsed = this.#getCurrentTime() - this.#startTime; return Math.max(0, this.#options.totalTimeout - elapsed); } #getCurrentTime() { return globalThis.performance?.now() ?? Date.now(); } #getNormalizedOptions() { if (!this.#cachedNormalizedOptions) { // Exclude Ky-specific options that are not part of `RequestInit`. const { hooks, json, parseJson, stringifyJson, searchParams, timeout, totalTimeout, throwHttpErrors, fetch, ...normalizedOptions } = this.#options; this.#cachedNormalizedOptions = Object.freeze(normalizedOptions); } return this.#cachedNormalizedOptions; } #assignRequest(request) { this.#cachedNormalizedOptions = undefined; this.request = request; } #getResponseRequest(response) { return this.#responseRequests.get(response) ?? this.request; } #setResponseRequest(response, request) { this.#responseRequests.set(response, request); return response; } #wrapRequestWithUploadProgress(request, originalBody) { if (!this.#options.onUploadProgress || !request.body || !supportsRequestStreams) { return request; } return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined); } } //# sourceMappingURL=Ky.js.map