| import type { ForceRetryOptions } from '../core/constants.js'; | ||
| /** | ||
| Internal error used to signal a forced retry from afterResponse hooks. | ||
| This is thrown when a user returns ky.retry() from an afterResponse hook. | ||
| */ | ||
| export declare class ForceRetryError extends Error { | ||
| name: "ForceRetryError"; | ||
| customDelay: number | undefined; | ||
| code: string | undefined; | ||
| customRequest: Request | undefined; | ||
| constructor(options?: ForceRetryOptions); | ||
| } |
| import { NonError } from './NonError.js'; | ||
| /** | ||
| Internal error used to signal a forced retry from afterResponse hooks. | ||
| This is thrown when a user returns ky.retry() from an afterResponse hook. | ||
| */ | ||
| export class ForceRetryError extends Error { | ||
| name = 'ForceRetryError'; | ||
| customDelay; | ||
| code; | ||
| customRequest; | ||
| constructor(options) { | ||
| // Runtime protection: wrap non-Error causes in NonError | ||
| // TypeScript type is Error for guidance, but JS users can pass anything | ||
| const cause = options?.cause | ||
| ? (options.cause instanceof Error ? options.cause : new NonError(options.cause)) | ||
| : undefined; | ||
| super(options?.code ? `Forced retry: ${options.code}` : 'Forced retry', cause ? { cause } : undefined); | ||
| this.customDelay = options?.delay; | ||
| this.code = options?.code; | ||
| this.customRequest = options?.request; | ||
| } | ||
| } | ||
| //# sourceMappingURL=ForceRetryError.js.map |
| {"version":3,"file":"ForceRetryError.js","sourceRoot":"","sources":["../../source/errors/ForceRetryError.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,QAAQ,EAAC,MAAM,eAAe,CAAC;AAEvC;;;EAGE;AACF,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAChC,IAAI,GAAG,iBAA0B,CAAC;IAC3C,WAAW,CAAqB;IAChC,IAAI,CAAqB;IACzB,aAAa,CAAsB;IAEnC,YAAY,OAA2B;QACtC,wDAAwD;QACxD,wEAAwE;QACxE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK;YAC3B,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAChF,CAAC,CAAC,SAAS,CAAC;QAEb,KAAK,CACJ,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,iBAAiB,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,cAAc,EAChE,KAAK,CAAC,CAAC,CAAC,EAAC,KAAK,EAAC,CAAC,CAAC,CAAC,SAAS,CAC3B,CAAC;QAEF,IAAI,CAAC,WAAW,GAAG,OAAO,EAAE,KAAK,CAAC;QAClC,IAAI,CAAC,IAAI,GAAG,OAAO,EAAE,IAAI,CAAC;QAC1B,IAAI,CAAC,aAAa,GAAG,OAAO,EAAE,OAAO,CAAC;IACvC,CAAC;CACD","sourcesContent":["import type {ForceRetryOptions} from '../core/constants.js';\nimport {NonError} from './NonError.js';\n\n/**\nInternal error used to signal a forced retry from afterResponse hooks.\nThis is thrown when a user returns ky.retry() from an afterResponse hook.\n*/\nexport class ForceRetryError extends Error {\n\toverride name = 'ForceRetryError' as const;\n\tcustomDelay: number | undefined;\n\tcode: string | undefined;\n\tcustomRequest: Request | undefined;\n\n\tconstructor(options?: ForceRetryOptions) {\n\t\t// Runtime protection: wrap non-Error causes in NonError\n\t\t// TypeScript type is Error for guidance, but JS users can pass anything\n\t\tconst cause = options?.cause\n\t\t\t? (options.cause instanceof Error ? options.cause : new NonError(options.cause))\n\t\t\t: undefined;\n\n\t\tsuper(\n\t\t\toptions?.code ? `Forced retry: ${options.code}` : 'Forced retry',\n\t\t\tcause ? {cause} : undefined,\n\t\t);\n\n\t\tthis.customDelay = options?.delay;\n\t\tthis.code = options?.code;\n\t\tthis.customRequest = options?.request;\n\t}\n}\n"]} |
@@ -19,2 +19,169 @@ import { type KyOptionsRegistry } from '../types/options.js'; | ||
| export declare const stop: unique symbol; | ||
| /** | ||
| Options for forcing a retry via `ky.retry()`. | ||
| */ | ||
| export type ForceRetryOptions = { | ||
| /** | ||
| Custom delay in milliseconds before retrying. | ||
| If not provided, uses the default retry delay calculation based on `retry.delay` configuration. | ||
| **Note:** Custom delays bypass jitter and `backoffLimit`. This is intentional, as custom delays often come from server responses (e.g., `Retry-After` headers) and should be respected exactly as specified. | ||
| */ | ||
| delay?: number; | ||
| /** | ||
| Error code for the retry. | ||
| This machine-readable identifier will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries. | ||
| @example | ||
| ``` | ||
| return ky.retry({code: 'RATE_LIMIT'}); | ||
| // Resulting error message: 'Forced retry: RATE_LIMIT' | ||
| ``` | ||
| */ | ||
| code?: string; | ||
| /** | ||
| Original error that caused the retry. | ||
| This allows you to preserve the error chain when forcing a retry based on caught exceptions. The error will be set as the `cause` of the `ForceRetryError`, enabling proper error chain traversal. | ||
| @example | ||
| ``` | ||
| try { | ||
| const data = await response.clone().json(); | ||
| validateBusinessLogic(data); | ||
| } catch (error) { | ||
| return ky.retry({ | ||
| code: 'VALIDATION_FAILED', | ||
| cause: error // Preserves original error in chain | ||
| }); | ||
| } | ||
| ``` | ||
| */ | ||
| cause?: Error; | ||
| /** | ||
| Custom request to use for the retry. | ||
| This allows you to modify or completely replace the request during a forced retry. The custom request becomes the starting point for the retry - `beforeRetry` hooks can still further modify it if needed. | ||
| **Note:** The custom request's `signal` will be replaced with Ky's managed signal to handle timeouts and user-provided abort signals correctly. If the original request body has been consumed, you must provide a new body or clone the request before consuming. | ||
| @example | ||
| ``` | ||
| // Fallback to a different endpoint | ||
| return ky.retry({ | ||
| request: new Request('https://backup-api.com/endpoint', { | ||
| method: request.method, | ||
| headers: request.headers, | ||
| }), | ||
| code: 'BACKUP_ENDPOINT' | ||
| }); | ||
| // Retry with refreshed authentication token | ||
| const data = await response.clone().json(); | ||
| return ky.retry({ | ||
| request: new Request(request, { | ||
| headers: { | ||
| ...Object.fromEntries(request.headers), | ||
| 'Authorization': `Bearer ${data.newToken}` | ||
| } | ||
| }), | ||
| code: 'TOKEN_REFRESHED' | ||
| }); | ||
| ``` | ||
| */ | ||
| request?: Request; | ||
| }; | ||
| /** | ||
| Marker returned by ky.retry() to signal a forced retry from afterResponse hooks. | ||
| */ | ||
| export declare class RetryMarker { | ||
| options?: ForceRetryOptions | undefined; | ||
| constructor(options?: ForceRetryOptions | undefined); | ||
| } | ||
| /** | ||
| Force a retry from an `afterResponse` hook. | ||
| This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError`. | ||
| @param options - Optional configuration for the retry. | ||
| @example | ||
| ``` | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| afterResponse: [ | ||
| async (request, options, response) => { | ||
| // Retry based on response body content | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| // Simple retry with default delay | ||
| if (data.error?.code === 'TEMPORARY_ERROR') { | ||
| return ky.retry(); | ||
| } | ||
| // Retry with custom delay from API response | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| // Retry with a modified request (e.g., fallback endpoint) | ||
| if (data.error?.code === 'FALLBACK_TO_BACKUP') { | ||
| return ky.retry({ | ||
| request: new Request('https://backup-api.com/endpoint', { | ||
| method: request.method, | ||
| headers: request.headers, | ||
| }), | ||
| code: 'BACKUP_ENDPOINT' | ||
| }); | ||
| } | ||
| // Retry with refreshed authentication | ||
| if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) { | ||
| return ky.retry({ | ||
| request: new Request(request, { | ||
| headers: { | ||
| ...Object.fromEntries(request.headers), | ||
| 'Authorization': `Bearer ${data.newToken}` | ||
| } | ||
| }), | ||
| code: 'TOKEN_REFRESHED' | ||
| }); | ||
| } | ||
| // Retry with cause to preserve error chain | ||
| try { | ||
| validateResponse(data); | ||
| } catch (error) { | ||
| return ky.retry({ | ||
| code: 'VALIDATION_FAILED', | ||
| cause: error | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ], | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Observable in beforeRetry hooks | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.message}`); | ||
| // Example output: "Forced retry #1: Forced retry: RATE_LIMIT" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| const response = await api.get('https://example.com/api'); | ||
| ``` | ||
| */ | ||
| export declare const retry: (options?: ForceRetryOptions) => RetryMarker; | ||
| export declare const kyOptionKeys: KyOptionsRegistry; | ||
@@ -21,0 +188,0 @@ export declare const vendorSpecificOptions: { |
@@ -50,2 +50,95 @@ export const supportsRequestStreams = (() => { | ||
| export const stop = Symbol('stop'); | ||
| /** | ||
| Marker returned by ky.retry() to signal a forced retry from afterResponse hooks. | ||
| */ | ||
| export class RetryMarker { | ||
| options; | ||
| constructor(options) { | ||
| this.options = options; | ||
| } | ||
| } | ||
| /** | ||
| Force a retry from an `afterResponse` hook. | ||
| This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError`. | ||
| @param options - Optional configuration for the retry. | ||
| @example | ||
| ``` | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| afterResponse: [ | ||
| async (request, options, response) => { | ||
| // Retry based on response body content | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| // Simple retry with default delay | ||
| if (data.error?.code === 'TEMPORARY_ERROR') { | ||
| return ky.retry(); | ||
| } | ||
| // Retry with custom delay from API response | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| // Retry with a modified request (e.g., fallback endpoint) | ||
| if (data.error?.code === 'FALLBACK_TO_BACKUP') { | ||
| return ky.retry({ | ||
| request: new Request('https://backup-api.com/endpoint', { | ||
| method: request.method, | ||
| headers: request.headers, | ||
| }), | ||
| code: 'BACKUP_ENDPOINT' | ||
| }); | ||
| } | ||
| // Retry with refreshed authentication | ||
| if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) { | ||
| return ky.retry({ | ||
| request: new Request(request, { | ||
| headers: { | ||
| ...Object.fromEntries(request.headers), | ||
| 'Authorization': `Bearer ${data.newToken}` | ||
| } | ||
| }), | ||
| code: 'TOKEN_REFRESHED' | ||
| }); | ||
| } | ||
| // Retry with cause to preserve error chain | ||
| try { | ||
| validateResponse(data); | ||
| } catch (error) { | ||
| return ky.retry({ | ||
| code: 'VALIDATION_FAILED', | ||
| cause: error | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ], | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Observable in beforeRetry hooks | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.message}`); | ||
| // Example output: "Forced retry #1: Forced retry: RATE_LIMIT" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| const response = await api.get('https://example.com/api'); | ||
| ``` | ||
| */ | ||
| export const retry = (options) => new RetryMarker(options); | ||
| export const kyOptionKeys = { | ||
@@ -52,0 +145,0 @@ json: true, |
+56
-19
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { NonError } from '../errors/NonError.js'; | ||
| import { ForceRetryError } from '../errors/ForceRetryError.js'; | ||
| import { streamRequest, streamResponse } from '../utils/body.js'; | ||
@@ -10,3 +11,3 @@ import { mergeHeaders, mergeHooks } from '../utils/merge.js'; | ||
| import { isHTTPError, isTimeoutError } from '../utils/type-guards.js'; | ||
| import { maxSafeTimeout, responseTypes, stop, supportsAbortController, supportsAbortSignal, supportsFormData, supportsResponseStreams, supportsRequestStreams, } from './constants.js'; | ||
| import { maxSafeTimeout, responseTypes, stop, RetryMarker, supportsAbortController, supportsAbortSignal, supportsFormData, supportsResponseStreams, supportsRequestStreams, } from './constants.js'; | ||
| export class Ky { | ||
@@ -25,10 +26,24 @@ static create(input, options) { | ||
| for (const hook of ky.#options.hooks.afterResponse) { | ||
| // Clone the response before passing to hook so we can cancel it if needed | ||
| const clonedResponse = ky.#decorateResponse(response.clone()); | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), ky.#decorateResponse(response.clone()), { retryCount: ky.#retryCount }); | ||
| const modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), clonedResponse, { retryCount: ky.#retryCount }); | ||
| if (modifiedResponse instanceof globalThis.Response) { | ||
| response = modifiedResponse; | ||
| } | ||
| 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) | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await Promise.all([ | ||
| clonedResponse.body?.cancel(), | ||
| response.body?.cancel(), | ||
| ]); | ||
| throw new ForceRetryError(modifiedResponse.options); | ||
| } | ||
| } | ||
| ky.#decorateResponse(response); | ||
| if (!response.ok && ky.#options.throwHttpErrors) { | ||
| if (!response.ok && (typeof ky.#options.throwHttpErrors === 'function' | ||
| ? ky.#options.throwHttpErrors(response.status) | ||
| : ky.#options.throwHttpErrors)) { | ||
| let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions()); | ||
@@ -53,4 +68,5 @@ for (const hook of ky.#options.hooks.beforeError) { | ||
| }; | ||
| const isRetriableMethod = ky.#options.retry.methods.includes(ky.request.method.toLowerCase()); | ||
| const result = (isRetriableMethod ? ky.#retry(function_) : function_()) | ||
| // Always wrap in #retry to catch forced retries from afterResponse hooks | ||
| // Method retriability is checked in #calculateRetryDelay for non-forced retries | ||
| const result = ky.#retry(function_) | ||
| .finally(async () => { | ||
@@ -128,3 +144,3 @@ const originalRequest = ky.#originalRequest; | ||
| retry: normalizeRetryOptions(options.retry), | ||
| throwHttpErrors: options.throwHttpErrors !== false, | ||
| throwHttpErrors: options.throwHttpErrors ?? true, | ||
| timeout: options.timeout ?? 10_000, | ||
@@ -176,4 +192,4 @@ fetch: options.fetch ?? globalThis.fetch.bind(globalThis), | ||
| const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams); | ||
| // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws. | ||
| this.request = new globalThis.Request(new globalThis.Request(url, { ...this.request }), this.#options); | ||
| // Recreate request with the updated URL. We already have all options in this.#options, including duplex. | ||
| this.request = new globalThis.Request(url, this.#options); | ||
| } | ||
@@ -188,7 +204,3 @@ // If `onUploadProgress` is passed, it uses the stream API internally | ||
| } | ||
| const originalBody = this.request.body; | ||
| if (originalBody) { | ||
| // Pass original body to calculate size correctly (before it becomes a stream) | ||
| this.request = streamRequest(this.request, this.#options.onUploadProgress, this.#options.body); | ||
| } | ||
| this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined); | ||
| } | ||
@@ -217,2 +229,10 @@ } | ||
| 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 all other checks | ||
@@ -280,2 +300,10 @@ if (this.#options.retry.shouldRetry !== undefined) { | ||
| await delay(ms, this.#userProvidedAbortSignal ? { signal: this.#userProvidedAbortSignal } : {}); | ||
| // 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 managedRequest = this.#options.signal | ||
| ? new globalThis.Request(error.customRequest, { signal: this.#options.signal }) | ||
| : new globalThis.Request(error.customRequest); | ||
| this.#assignRequest(managedRequest); | ||
| } | ||
| for (const hook of this.#options.hooks.beforeRetry) { | ||
@@ -289,5 +317,4 @@ // eslint-disable-next-line no-await-in-loop | ||
| }); | ||
| // If a Request is returned, use it for the retry | ||
| if (hookResult instanceof globalThis.Request) { | ||
| this.request = hookResult; | ||
| this.#assignRequest(hookResult); | ||
| break; | ||
@@ -318,9 +345,9 @@ } | ||
| const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this.#retryCount }); | ||
| if (result instanceof Request) { | ||
| this.request = result; | ||
| break; | ||
| } | ||
| if (result instanceof Response) { | ||
| return result; | ||
| } | ||
| if (result instanceof globalThis.Request) { | ||
| this.#assignRequest(result); | ||
| break; | ||
| } | ||
| } | ||
@@ -343,3 +370,13 @@ const nonRequestOptions = findUnknownOptions(this.request, this.#options); | ||
| } | ||
| #assignRequest(request) { | ||
| this.#cachedNormalizedOptions = undefined; | ||
| this.request = this.#wrapRequestWithUploadProgress(request); | ||
| } | ||
| #wrapRequestWithUploadProgress(request, originalBody) { | ||
| if (!this.#options.onUploadProgress || !request.body) { | ||
| return request; | ||
| } | ||
| return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined); | ||
| } | ||
| } | ||
| //# sourceMappingURL=Ky.js.map |
@@ -13,2 +13,3 @@ /*! MIT License © Sindre Sorhus */ | ||
| export { TimeoutError } from './errors/TimeoutError.js'; | ||
| export { isKyError, isHTTPError, isTimeoutError } from './utils/type-guards.js'; | ||
| export { ForceRetryError } from './errors/ForceRetryError.js'; | ||
| export { isKyError, isHTTPError, isTimeoutError, isForceRetryError, } from './utils/type-guards.js'; |
| /*! MIT License © Sindre Sorhus */ | ||
| import { Ky } from './core/Ky.js'; | ||
| import { requestMethods, stop } from './core/constants.js'; | ||
| import { requestMethods, stop, retry } from './core/constants.js'; | ||
| import { validateAndMerge } from './utils/merge.js'; | ||
@@ -20,2 +20,3 @@ const createInstance = (defaults) => { | ||
| ky.stop = stop; | ||
| ky.retry = retry; | ||
| return ky; | ||
@@ -27,5 +28,6 @@ }; | ||
| export { TimeoutError } from './errors/TimeoutError.js'; | ||
| export { isKyError, isHTTPError, isTimeoutError } from './utils/type-guards.js'; | ||
| export { ForceRetryError } from './errors/ForceRetryError.js'; | ||
| export { isKyError, isHTTPError, isTimeoutError, isForceRetryError, } from './utils/type-guards.js'; | ||
| // Intentionally not exporting this for now as it's just an implementation detail and we don't want to commit to a certain API yet at least. | ||
| // export {NonError} from './errors/NonError.js'; | ||
| //# sourceMappingURL=index.js.map |
@@ -1,2 +0,2 @@ | ||
| import { type stop } from '../core/constants.js'; | ||
| import { type stop, type RetryMarker } from '../core/constants.js'; | ||
| import type { KyRequest, KyResponse, HTTPError } from '../index.js'; | ||
@@ -31,3 +31,3 @@ import type { NormalizedOptions } from './options.js'; | ||
| }; | ||
| export type AfterResponseHook = (request: KyRequest, options: NormalizedOptions, response: KyResponse, state: AfterResponseState) => Response | void | Promise<Response | void>; | ||
| export type AfterResponseHook = (request: KyRequest, options: NormalizedOptions, response: KyResponse, state: AfterResponseState) => Response | RetryMarker | void | Promise<Response | RetryMarker | void>; | ||
| export type BeforeErrorState = { | ||
@@ -148,2 +148,4 @@ /** | ||
| You can also force a retry by returning `ky.retry()` or `ky.retry(options)`. This is useful when you need to retry based on the response body content, even if the response has a successful status code. The retry will respect the retry limit and be observable in `beforeRetry` hooks. | ||
| @default [] | ||
@@ -179,2 +181,16 @@ | ||
| // Or force retry based on response body content | ||
| async (request, options, response) => { | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| // Force retry with custom delay from API response | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| } | ||
| }, | ||
| // Or show a notification only on the last retry for 5xx errors | ||
@@ -181,0 +197,0 @@ (request, options, response, {retryCount}) => { |
@@ -1,2 +0,2 @@ | ||
| import { type stop } from '../core/constants.js'; | ||
| import { type stop, type retry } from '../core/constants.js'; | ||
| import type { Input, Options } from './options.js'; | ||
@@ -127,2 +127,50 @@ import type { ResponsePromise } from './ResponsePromise.js'; | ||
| readonly stop: typeof stop; | ||
| /** | ||
| Force a retry from an `afterResponse` hook. | ||
| This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError`. | ||
| @example | ||
| ``` | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| afterResponse: [ | ||
| async (request, options, response) => { | ||
| // Retry based on response body content | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| // Simple retry with default delay | ||
| if (data.error?.code === 'TEMPORARY_ERROR') { | ||
| return ky.retry(); | ||
| } | ||
| // Retry with custom delay from API response | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ], | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Observable in beforeRetry hooks | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.message}`); | ||
| // Example output: "Forced retry #1: Forced retry: RATE_LIMIT" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| const response = await api.get('https://example.com/api'); | ||
| ``` | ||
| */ | ||
| readonly retry: typeof retry; | ||
| }; |
@@ -146,2 +146,4 @@ import type { LiteralUnion, Required } from './common.js'; | ||
| You can also pass a function that accepts the HTTP status code and returns a boolean for selective error handling. Note that this can violate the principle of least surprise, so it's recommended to use the boolean form unless you have a specific use case like treating 404 responses differently. | ||
| Note: If `false`, error responses are considered successful and the request will not be retried. | ||
@@ -151,3 +153,3 @@ | ||
| */ | ||
| throwHttpErrors?: boolean; | ||
| throwHttpErrors?: boolean | ((status: number) => boolean); | ||
| /** | ||
@@ -331,3 +333,3 @@ Download progress event handler. | ||
| } | ||
| export type InternalOptions = Required<Omit<Options, 'hooks' | 'retry' | 'context'>, 'fetch' | 'prefixUrl' | 'timeout'> & { | ||
| export type InternalOptions = Required<Omit<Options, 'hooks' | 'retry' | 'context' | 'throwHttpErrors'>, 'fetch' | 'prefixUrl' | 'timeout'> & { | ||
| headers: Required<Headers>; | ||
@@ -338,2 +340,3 @@ hooks: Required<Hooks>; | ||
| context: Record<string, unknown>; | ||
| throwHttpErrors: boolean | ((status: number) => boolean); | ||
| }; | ||
@@ -340,0 +343,0 @@ /** |
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { TimeoutError } from '../errors/TimeoutError.js'; | ||
| import { ForceRetryError } from '../errors/ForceRetryError.js'; | ||
| /** | ||
@@ -64,1 +65,25 @@ Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| export declare function isTimeoutError(error: unknown): error is TimeoutError; | ||
| /** | ||
| Type guard to check if an error is a ForceRetryError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is a ForceRetryError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.code}`); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| */ | ||
| export declare function isForceRetryError(error: unknown): error is ForceRetryError; |
| import { HTTPError } from '../errors/HTTPError.js'; | ||
| import { TimeoutError } from '../errors/TimeoutError.js'; | ||
| import { ForceRetryError } from '../errors/ForceRetryError.js'; | ||
| /** | ||
@@ -70,2 +71,28 @@ Type guard to check if an error is a Ky error (HTTPError or TimeoutError). | ||
| } | ||
| /** | ||
| Type guard to check if an error is a ForceRetryError. | ||
| @param error - The error to check | ||
| @returns `true` if the error is a ForceRetryError, `false` otherwise | ||
| @example | ||
| ``` | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.code}`); | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| ``` | ||
| */ | ||
| export function isForceRetryError(error) { | ||
| return error instanceof ForceRetryError || (error?.name === ForceRetryError.name); | ||
| } | ||
| //# sourceMappingURL=type-guards.js.map |
+1
-1
| { | ||
| "name": "ky", | ||
| "version": "1.13.0", | ||
| "version": "1.14.0", | ||
| "description": "Tiny and elegant HTTP client based on the Fetch API", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+198
-1
@@ -492,2 +492,4 @@ <div align="center"> | ||
| You can also force a retry by returning [`ky.retry(options)`](#kyretryoptions). This is useful when you need to retry based on the response body content, even if the response has a successful status code. The retry will respect the `retry.limit` option and be observable in `beforeRetry` hooks. | ||
| The `state.retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between initial requests and retries, which is useful when you need different behavior for retries (e.g., showing a notification only on the final retry). | ||
@@ -522,2 +524,16 @@ | ||
| // Or force retry based on response body content | ||
| async (request, options, response) => { | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| // Retry with custom delay from API response | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| } | ||
| }, | ||
| // Or show a notification only on the last retry for 5xx errors | ||
@@ -538,3 +554,3 @@ (request, options, response, {retryCount}) => { | ||
| Type: `boolean`\ | ||
| Type: `boolean | (status: number) => boolean`\ | ||
| Default: `true` | ||
@@ -546,2 +562,4 @@ | ||
| You can also pass a function that accepts the HTTP status code and returns a boolean for selective error handling. Note that this can violate the principle of least surprise, so it's recommended to use the boolean form unless you have a specific use case like treating 404 responses differently. | ||
| Note: If `false`, error responses are considered successful and the request will not be retried. | ||
@@ -847,2 +865,138 @@ | ||
| ### ky.retry(options?) | ||
| Force a retry from an `afterResponse` hook. | ||
| This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError` with the error name `'ForceRetryError'`. | ||
| #### options | ||
| Type: `object` | ||
| ##### delay | ||
| Type: `number` | ||
| Custom delay in milliseconds before retrying. If not provided, uses the default retry delay calculation based on `retry.delay` configuration. | ||
| **Note:** Custom delays bypass jitter and `backoffLimit`. This is intentional, as custom delays often come from server responses (e.g., `Retry-After` headers) and should be respected exactly as specified. | ||
| ##### code | ||
| Type: `string` | ||
| Error code for the retry. | ||
| This machine-readable identifier will be included in the error message passed to `beforeRetry` hooks, allowing you to distinguish between different types of forced retries. | ||
| ```js | ||
| return ky.retry({code: 'RATE_LIMIT'}); | ||
| // Resulting error message: 'Forced retry: RATE_LIMIT' | ||
| ``` | ||
| ##### cause | ||
| Type: `Error` | ||
| Original error that caused the retry. This allows you to preserve the error chain when forcing a retry based on caught exceptions. The error will be set as the `cause` of the `ForceRetryError`, enabling proper error chain traversal. | ||
| ```js | ||
| try { | ||
| const data = await response.clone().json(); | ||
| validateBusinessLogic(data); | ||
| } catch (error) { | ||
| return ky.retry({ | ||
| code: 'VALIDATION_FAILED', | ||
| cause: error // Preserves original error in chain | ||
| }); | ||
| } | ||
| ``` | ||
| ##### request | ||
| Type: `Request` | ||
| Custom request to use for the retry. | ||
| This allows you to modify or completely replace the request during a forced retry. The custom request becomes the starting point for the retry - `beforeRetry` hooks can still further modify it if needed. | ||
| **Note:** The custom request's `signal` will be replaced with Ky's managed signal to handle timeouts and user-provided abort signals correctly. If the original request body has been consumed, you must provide a new body or clone the request before consuming. | ||
| #### Example | ||
| ```js | ||
| import ky, {isForceRetryError} from 'ky'; | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| afterResponse: [ | ||
| async (request, options, response) => { | ||
| // Retry based on response body content | ||
| if (response.status === 200) { | ||
| const data = await response.clone().json(); | ||
| // Simple retry with default delay | ||
| if (data.error?.code === 'TEMPORARY_ERROR') { | ||
| return ky.retry(); | ||
| } | ||
| // Retry with custom delay from API response | ||
| if (data.error?.code === 'RATE_LIMIT') { | ||
| return ky.retry({ | ||
| delay: data.error.retryAfter * 1000, | ||
| code: 'RATE_LIMIT' | ||
| }); | ||
| } | ||
| // Retry with a modified request (e.g., fallback endpoint) | ||
| if (data.error?.code === 'FALLBACK_TO_BACKUP') { | ||
| return ky.retry({ | ||
| request: new Request('https://backup-api.com/endpoint', { | ||
| method: request.method, | ||
| headers: request.headers, | ||
| }), | ||
| code: 'BACKUP_ENDPOINT' | ||
| }); | ||
| } | ||
| // Retry with refreshed authentication | ||
| if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) { | ||
| return ky.retry({ | ||
| request: new Request(request, { | ||
| headers: { | ||
| ...Object.fromEntries(request.headers), | ||
| 'Authorization': `Bearer ${data.newToken}` | ||
| } | ||
| }), | ||
| code: 'TOKEN_REFRESHED' | ||
| }); | ||
| } | ||
| // Retry with cause to preserve error chain | ||
| try { | ||
| validateResponse(data); | ||
| } catch (error) { | ||
| return ky.retry({ | ||
| code: 'VALIDATION_FAILED', | ||
| cause: error | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ], | ||
| beforeRetry: [ | ||
| ({error, retryCount}) => { | ||
| // Observable in beforeRetry hooks | ||
| if (isForceRetryError(error)) { | ||
| console.log(`Forced retry #${retryCount}: ${error.message}`); | ||
| // Example output: "Forced retry #1: Forced retry: RATE_LIMIT" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| const response = await api.get('https://example.com/api'); | ||
| ``` | ||
| ### HTTPError | ||
@@ -1097,2 +1251,45 @@ | ||
| ### Consuming Server-Sent Events (SSE) | ||
| Use [`parse-sse`](https://github.com/sindresorhus/parse-sse): | ||
| ```js | ||
| import ky from 'ky'; | ||
| import {parseServerSentEvents} from 'parse-sse'; | ||
| const response = await ky('https://api.example.com/events'); | ||
| for await (const event of parseServerSentEvents(response)) { | ||
| console.log(event.data); | ||
| } | ||
| ``` | ||
| ### Extending types | ||
| Ky's TypeScript types are intentionally defined as type aliases rather than interfaces to prevent global module augmentation, which can lead to type conflicts and unexpected behavior across your codebase. If you need to add custom properties to Ky's types like `KyResponse` or `HTTPError`, create local wrapper types instead: | ||
| ```ts | ||
| import ky, {HTTPError} from 'ky'; | ||
| interface CustomError extends HTTPError { | ||
| customProperty: unknown; | ||
| } | ||
| const api = ky.extend({ | ||
| hooks: { | ||
| beforeError: [ | ||
| async error => { | ||
| (error as CustomError).customProperty = 'value'; | ||
| return error; | ||
| } | ||
| ] | ||
| } | ||
| }); | ||
| // Use with type assertion | ||
| const data = (error as CustomError).customProperty; | ||
| ``` | ||
| This approach keeps your types scoped to where they're needed without polluting the global namespace. | ||
| ## FAQ | ||
@@ -1099,0 +1296,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
275806
16.52%75
4.17%2296
21.61%1354
17.03%8
100%