ky
Version:
Tiny and elegant HTTP client based on the Fetch API
960 lines • 45.3 kB
JavaScript
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