ky
Version:
Tiny and elegant HTTP client based on the Fetch API
826 lines • 39.7 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 { 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 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 (typeof retry !== 'object') {
return retry;
}
// Clone nested arrays too so init hooks can mutate retry config without leaking state across requests.
return {
...retry,
...(retry.methods && { methods: [...retry.methods] }),
...(retry.statusCodes && { statusCodes: [...retry.statusCodes] }),
...(retry.afterStatusCodes && { afterStatusCodes: [...retry.afterStatusCodes] }),
};
};
const objectToString = Object.prototype.toString;
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 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();
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) {
// 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 (ky.#beforeRetryHookErrors.has(error)) {
throw error;
}
let processedError = error;
for (const hook of ky.#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: ky.request,
options: ky.#getNormalizedOptions(),
error: processedError,
retryCount: ky.#retryCount,
});
// Only overwrite if the hook returns a valid Error instance.
if (hookResult instanceof Error) {
processedError = hookResult;
}
}
throw processedError;
}
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 response[type]();
}
const text = await response.text();
if (text === '') {
if (schema !== undefined) {
return validateJsonWithSchema(undefined, schema);
}
return JSON.parse(text);
}
const jsonValue = initHookOptions.parseJson
? await initHookOptions.parseJson(text, { request: ky.#getResponseRequest(response), response })
: JSON.parse(text);
return schema === undefined ? jsonValue : validateJsonWithSchema(jsonValue, schema);
};
}
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;
// eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202
#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 ?? {},
};
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) {
let absoluteInput;
try {
absoluteInput = new URL(this.#input);
}
catch { }
if (!absoluteInput) {
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() {
const retryDelay = this.#options.retry.delay(this.#retryCount + 1);
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) {
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);
// Handle forced retry from afterResponse hook - skip method check and shouldRetry
if (errorObject instanceof ForceRetryError) {
return errorObject.customDelay ?? this.#calculateDelay();
}
// Check if method is retriable for non-forced retries
if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
throw error;
}
// User-provided shouldRetry function takes precedence over default checks (retryOnTimeout, status codes, etc.)
if (this.#options.retry.shouldRetry !== undefined) {
const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount + 1 });
// 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)) {
if (!this.#options.retry.retryOnTimeout) {
throw error;
}
return this.#calculateDelay();
}
if (isHTTPError(error)) {
if (!this.#options.retry.statusCodes.includes(error.response.status)) {
throw error;
}
const retryAfter = error.response.headers.get('Retry-After')
?? 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)) {
let after = Number(retryAfter) * 1000;
if (Number.isNaN(after)) {
after = Date.parse(retryAfter) - Date.now();
}
else if (after >= Date.parse('2024-01-01')) {
// A large number is treated as a timestamp (fixed threshold protects against clock skew)
after -= Date.now();
}
if (!Number.isFinite(after)) {
return Math.min(this.#options.retry.maxRetryAfter, this.#calculateDelay());
}
after = Math.max(0, after);
// Don't apply jitter when server provides explicit retry timing
return Math.min(this.#options.retry.maxRetryAfter, after);
}
if (error.response.status === 413) {
throw error;
}
return this.#calculateDelay();
}
// Only retry known retriable error types. Unknown errors (e.g., programming bugs) are not retried.
if (!isNetworkError(error)) {
throw error;
}
return this.#calculateDelay();
}
#decorateResponse(response) {
const request = this.#getResponseRequest(response);
if (this.#options.parseJson) {
response.json = async () => {
const text = await response.text();
if (text === '') {
return JSON.parse(text);
}
return this.#options.parseJson(text, { request, response });
};
}
return response;
}
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 text = await this.#readResponseText(response, this.#getErrorDataTimeout());
if (text === timedOutResponseData) {
this.#throwIfTotalTimeoutExhausted();
return undefined;
}
if (!text) {
return undefined;
}
if (!this.#isJsonContentType(response.headers.get('content-type') ?? '')) {
return text;
}
const data = await this.#parseJson(text, response, this.#getErrorDataTimeout(), this.#getResponseRequest(response));
if (data === timedOutResponseData) {
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 errorDataTimeout;
}
if (remainingTotal <= 0) {
throw new TimeoutError(this.request);
}
return Math.min(errorDataTimeout, remainingTotal);
}
#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);
}
#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 hook({
request: this.request,
options: this.#getNormalizedOptions(),
retryCount: 0,
});
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 hook({
request: this.request,
options: this.#getNormalizedOptions(),
response: hookResponse,
retryCount: this.#retryCount,
});
}
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 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 (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);
const retryRequest = this.#options.retry.limit > 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