From ebab0d9b4e85fdc32d492abf64ad76f26333a7d9 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:42:51 +0200 Subject: [PATCH 01/15] feat: Replace ResponseErr with ResponseError --- src/request-handler.ts | 134 +++++++++++++++++++++++++---------- src/response-error.ts | 31 ++++++-- src/types/request-handler.ts | 21 +++--- 3 files changed, 134 insertions(+), 52 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index d9ada6b..ad36422 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -6,7 +6,6 @@ import type { Method, RetryOptions, FetchResponse, - ResponseError, RequestHandlerReturnType, CreatedCustomFetcherInstance, FetcherConfig, @@ -21,7 +20,7 @@ import type { QueryParams, } from './types/api-handler'; import { applyInterceptor } from './interceptor-manager'; -import { ResponseErr } from './response-error'; +import { ResponseError } from './response-error'; import { appendQueryParams, isJSONSerializable, @@ -244,16 +243,26 @@ export function createRequestHandler( /** * Process global Request Error * - * @param {ResponseError} error Error instance - * @param {RequestConfig} requestConfig Per endpoint request config + * @param {ResponseError} error Error instance + * @param {RequestConfig} requestConfig Per endpoint request config * @returns {Promise} */ - const processError = async ( - error: ResponseError, - requestConfig: RequestConfig, + const processError = async < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( + error: ResponseError, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, ): Promise => { - if (!isRequestCancelled(error)) { - logger(requestConfig, 'API ERROR', error); + if (!isRequestCancelled(error as ResponseError)) { + logger(requestConfig, 'API ERROR', error as ResponseError); } // Local interceptors @@ -266,17 +275,27 @@ export function createRequestHandler( /** * Output default response in case of an error, depending on chosen strategy * - * @param {ResponseError} error - Error instance - * @param {FetchResponse | null} response - Response. It may be "null" in case of request being aborted. - * @param {RequestConfig} requestConfig - Per endpoint request config - * @returns {FetchResponse} Response together with the error object + * @param {ResponseError} error - Error instance + * @param {FetchResponse | null} response - Response. It may be "null" in case of request being aborted. + * @param {RequestConfig} requestConfig - Per endpoint request config + * @returns {FetchResponse} Response together with the error object */ - const outputErrorResponse = async ( - error: ResponseError, - response: FetchResponse | null, - requestConfig: RequestConfig, + const outputErrorResponse = async < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( + error: ResponseError, + response: FetchResponse | null, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, ): Promise => { - const _isRequestCancelled = isRequestCancelled(error); + const _isRequestCancelled = isRequestCancelled(error as ResponseError); const errorHandlingStrategy = getConfig(requestConfig, 'strategy'); const rejectCancelled = getConfig( requestConfig, @@ -295,7 +314,11 @@ export function createRequestHandler( } } - return outputResponse(response, requestConfig, error); + return outputResponse( + response, + requestConfig, + error, + ); }; /** @@ -329,7 +352,7 @@ export function createRequestHandler( PathParams, RequestBody > | null = null, - ): Promise> => { + ): Promise> => { const _reqConfig = reqConfig || {}; const mergedConfig = { ...handlerConfig, @@ -423,7 +446,7 @@ export function createRequestHandler( response = (await fetch( requestConfig.url as string, requestConfig as RequestInit, - )) as FetchResponse; + )) as unknown as FetchResponse; } // Add more information to response object @@ -433,7 +456,12 @@ export function createRequestHandler( // Check if the response status is not outside the range 200-299 and if so, output error if (!response.ok) { - throw new ResponseErr( + throw new ResponseError< + ResponseData, + QueryParams, + PathParams, + RequestBody + >( `${requestConfig.url} failed! Status: ${response.status || null}`, requestConfig, response, @@ -465,7 +493,12 @@ export function createRequestHandler( } // If polling is not required, or polling attempts are exhausted - const output = outputResponse(response, requestConfig); + const output = outputResponse< + ResponseData, + QueryParams, + PathParams, + RequestBody + >(response, requestConfig); if (cacheTime && _cacheKey) { const skipCache = requestConfig.skipCache; @@ -477,7 +510,12 @@ export function createRequestHandler( return output; } catch (err) { - const error = err as ResponseErr; + const error = err as ResponseError< + ResponseData, + QueryParams, + PathParams, + RequestBody + >; const status = error?.response?.status || error?.status || 0; if ( @@ -485,15 +523,21 @@ export function createRequestHandler( !(!shouldRetry || (await shouldRetry(error, attempt))) || !retryOn?.includes(status) ) { - await processError(error, fetcherConfig); + await processError< + ResponseData, + QueryParams, + PathParams, + RequestBody + >(error, fetcherConfig); removeRequest(fetcherConfig); - return outputErrorResponse( - error, - response, - fetcherConfig, - ); + return outputErrorResponse< + ResponseData, + QueryParams, + PathParams, + RequestBody + >(error, response, fetcherConfig); } logger( @@ -509,7 +553,10 @@ export function createRequestHandler( } } - return outputResponse(response, fetcherConfig); + return outputResponse( + response, + fetcherConfig, + ); }; /** @@ -520,11 +567,26 @@ export function createRequestHandler( * @param error - whether the response is erroneous * @returns {FetchResponse} Response data */ - const outputResponse = ( - response: FetchResponse | null, - requestConfig: RequestConfig, - error: ResponseError | null = null, - ): FetchResponse => { + const outputResponse = < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( + response: FetchResponse | null, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, + error: ResponseError< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null = null, + ): FetchResponse => { const defaultResponse = getConfig(requestConfig, 'defaultResponse'); // This may happen when request is cancelled. diff --git a/src/response-error.ts b/src/response-error.ts index f54ee2f..26bf213 100644 --- a/src/response-error.ts +++ b/src/response-error.ts @@ -1,16 +1,33 @@ -import type { FetchResponse, RequestConfig } from './types'; +import type { + DefaultParams, + DefaultPayload, + DefaultResponse, + DefaultUrlParams, + FetchResponse, + RequestConfig, +} from './types'; -export class ResponseErr extends Error { - response: FetchResponse; - request: RequestConfig; - config: RequestConfig; +export class ResponseError< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> extends Error { status: number; statusText: string; + request: RequestConfig; + config: RequestConfig; + response: FetchResponse; constructor( message: string, - requestInfo: RequestConfig, - response: FetchResponse, + requestInfo: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, + response: FetchResponse, ) { super(message); diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 57ece97..ea4e60f 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -74,9 +74,9 @@ export interface HeadersObject { export interface ExtendedResponse extends Omit { data: ResponseData extends [unknown] ? any : ResponseData; - error: ResponseError | null; + error: ResponseError | null; headers: HeadersObject & HeadersInit; - config: ExtendedRequestConfig; + config: RequestConfig; } /** @@ -89,13 +89,16 @@ export type FetchResponse< RequestBody = any, > = ExtendedResponse; -export interface ResponseError - extends Error { - config: ExtendedRequestConfig; - code?: string; +export interface ResponseError< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> extends Error { status?: number; statusText?: string; - request?: ExtendedRequestConfig; + request?: RequestConfig; + config: RequestConfig; response?: FetchResponse; } @@ -107,7 +110,7 @@ export type RetryFunction = ( export type PollingFunction = ( response: FetchResponse, attempts: number, - error?: ResponseError, + error?: ResponseError, ) => boolean; export type CacheKeyFunction = (config: FetcherConfig) => string; @@ -116,7 +119,7 @@ export type CacheBusterFunction = (config: FetcherConfig) => boolean; export type CacheSkipFunction = ( data: ResponseData, - config: RequestConfig, + config: RequestConfig, ) => boolean; /** From c34d708af13013cbaf5af2e4f98408a4ae8683cd Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:44:55 +0200 Subject: [PATCH 02/15] fix: Properly infer Request Body in the returned config --- src/types/request-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index ea4e60f..79a00b6 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -403,7 +403,7 @@ export interface Logger { export type RequestHandlerConfig< ResponseData = any, RequestBody = any, -> = RequestConfig; +> = RequestConfig; export type RequestConfig< ResponseData = any, From 88bf8cb2037292b5864eca668511c5547b5786e7 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:46:09 +0200 Subject: [PATCH 03/15] feat: Add Query Params and Path Params typings to error interceptor --- src/types/interceptor-manager.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/types/interceptor-manager.ts b/src/types/interceptor-manager.ts index 2819350..389b4eb 100644 --- a/src/types/interceptor-manager.ts +++ b/src/types/interceptor-manager.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { + DefaultParams, + DefaultPayload, + DefaultUrlParams, +} from './api-handler'; +import type { + DefaultResponse, FetchResponse, RequestHandlerConfig, ResponseError, @@ -21,6 +27,11 @@ export type ResponseInterceptor = ( | Promise> | Promise; -export type ErrorInterceptor = ( - error: ResponseError, +export type ErrorInterceptor< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> = ( + error: ResponseError, ) => unknown; From b20ebc67ffb3f96f12b7bfd5c979c5f1484ace0d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:46:40 +0200 Subject: [PATCH 04/15] feat: Add Query Params and Path Params typings to retry and polling functions --- src/types/request-handler.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 79a00b6..bdff308 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -102,12 +102,22 @@ export interface ResponseError< response?: FetchResponse; } -export type RetryFunction = ( - error: ResponseError, +export type RetryFunction = < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +>( + error: ResponseError, attempts: number, ) => Promise; -export type PollingFunction = ( +export type PollingFunction< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> = ( response: FetchResponse, attempts: number, error?: ResponseError, From 4c10439ddc11bf59b9031cffd25b6c874339406f Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:47:17 +0200 Subject: [PATCH 05/15] docs: Add example of softFail strategy to Error Handling --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4ec570d..17d1b7b 100644 --- a/README.md +++ b/README.md @@ -416,12 +416,20 @@ The following options are available for configuring interceptors in the `Request Here's an example of how to configure error handling: +```typescript +const { data } = await fetchf('https://api.example.com/', { + strategy: 'reject', // Use 'reject' strategy for error handling (default). You need to use try / catch in case +}); +``` + ```typescript const { data, error } = await fetchf('https://api.example.com/', { - strategy: 'reject', // Use 'reject' strategy for error handling (default) + strategy: 'softFail', // Use 'softFail' strategy for error handling so to avoid using try / catch everywhere }); ``` +Check `Response Object` section below to see the payload of the `error` object. + ### Configuration The `strategy` option can be configured with the following values: @@ -431,7 +439,7 @@ _Default:_ `reject`. Promises are rejected, and global error handling is triggered. You must use `try/catch` blocks to handle errors. - **`softFail`**: - Returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for `try/catch` blocks. + Returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response's `error` object without the need for `try/catch` blocks. - **`defaultResponse`**: Returns a default response specified in case of an error. The promise will not be rejected. This can be used in conjunction with `flattenResponse` and `defaultResponse: {}` to provide sensible defaults. @@ -746,10 +754,10 @@ Each request returns the following Response Object of type FetchResponse<R - **`error`**: - - **Type**: `ResponseErr` + - **Type**: `ResponseError` - An object with details about any error that occurred or `null` otherwise. - - **`name`**: The name of the error (e.g., 'ResponseError'). + - **`name`**: The name of the error, that is `ResponseError`. - **`message`**: A descriptive message about the error. - **`status`**: The HTTP status code of the response (e.g., 404, 500). - **`statusText`**: The HTTP status text of the response (e.g., 'Not Found', 'Internal Server Error'). From f0d4110dfeedd2d27b94e0c9c7c4ae3b018b0812 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:49:53 +0200 Subject: [PATCH 06/15] fix: Import in the tests --- test/request-handler.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index b8c2541..204ef23 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -9,7 +9,7 @@ import type { } from '../src/types/request-handler'; import { fetchf } from '../src'; import { ABORT_ERROR } from '../src/constants'; -import { ResponseErr } from '../src/response-error'; +import type { ResponseError } from '../src/response-error'; jest.mock('../src/utils', () => { const originalModule = jest.requireActual('../src/utils'); @@ -1045,7 +1045,7 @@ describe('Request Handler', () => { 'The operation was aborted.', ); } catch (error) { - const err = error as ResponseErr; + const err = error as ResponseError; expect(err.message).toBe('The operation was aborted.'); } From 8bae701e5427eeec0cce03acdb35ffd992f71aaf Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 17 Oct 2024 23:58:34 +0200 Subject: [PATCH 07/15] fix: Returned error object should contain response, request and config objects --- src/request-handler.ts | 6 ------ src/utils.ts | 15 --------------- 2 files changed, 21 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index ad36422..3d154e5 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -28,7 +28,6 @@ import { delayInvocation, flattenData, processHeaders, - deleteProperty, isSearchParams, } from './utils'; import { addRequest, removeRequest } from './queue-manager'; @@ -601,11 +600,6 @@ export function createRequestHandler( } as unknown as FetchResponse; } - // Clean up the error object - deleteProperty(error, 'response'); - deleteProperty(error, 'request'); - deleteProperty(error, 'config'); - let data = response?.data; // Set the default response if the provided data is an empty object diff --git a/src/utils.ts b/src/utils.ts index 39e0147..e4f6ef8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -291,18 +291,3 @@ export function processHeaders( return headersObject; } - -/** - * Deletes a property from an object if it exists. - * - * @param obj - The object from which to delete the property. - * @param property - The property to delete from the object. - */ -export function deleteProperty>( - obj: T | null, - property: keyof T, -): void { - if (obj && property in obj) { - delete obj[property]; - } -} From b5c3a0dd5aff632652ffd05de9790edf70bce673 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 00:23:18 +0200 Subject: [PATCH 08/15] docs: Add more examples to every strategy in the Error Handling section --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 17d1b7b..1e0531b 100644 --- a/README.md +++ b/README.md @@ -412,40 +412,79 @@ The following options are available for configuring interceptors in the `Request
Error handling strategies define how to manage errors that occur during requests. You can configure the strategy option to specify what should happen when an error occurs. This affects whether promises are rejected, if errors are handled silently, or if default responses are provided. You can also combine it with onError interceptor for more tailored approach. -### Example +### Configuration -Here's an example of how to configure error handling: +The `strategy` option can be configured with the following values: + +**`reject`**: (default) +Promises are rejected, and global error handling is triggered. You must use `try/catch` blocks to handle errors. ```typescript -const { data } = await fetchf('https://api.example.com/', { - strategy: 'reject', // Use 'reject' strategy for error handling (default). You need to use try / catch in case -}); +try { + const { data } = await fetchf('https://api.example.com/', { + strategy: 'reject', // It is default so it does not really needs to be specified + }); +} catch (error) { + console.error(error.status, error.statusText, error.response, error.config); +} ``` +**`softFail`**: + Returns a response object with additional property of `error` when an error occurs and does not throw any error. This approach helps you to handle error information directly within the response's `error` object without the need for `try/catch` blocks. + ```typescript const { data, error } = await fetchf('https://api.example.com/', { - strategy: 'softFail', // Use 'softFail' strategy for error handling so to avoid using try / catch everywhere + strategy: 'softFail', }); + +if (error !== null) { + console.error(error.status, error.statusText, error.response, error.config); +} ``` -Check `Response Object` section below to see the payload of the `error` object. +Check `Response Object` section below to see how `error` object is structured. -### Configuration +**`defaultResponse`**: + Returns a default response specified in case of an error. The promise will not be rejected. This can be used in conjunction with `flattenResponse` and `defaultResponse: {}` to provide sensible defaults. -The `strategy` option can be configured with the following values: -_Default:_ `reject`. +```typescript +const { data, error } = await fetchf('https://api.example.com/', { + strategy: 'defaultResponse', + defaultResponse: {}, +}); + +if (error !== null) { + console.error('Request failed', data); // "data" will be equal to {} if there is an error +} +``` + +**`silent`**: + Hangs the promise silently on error, useful for fire-and-forget requests without the need for `try/catch`. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. This strategy is useful for dispatching requests within asynchronous wrapper functions that do not need to be awaited. It prevents excessive usage of `try/catch` or additional response data checks everywhere. It can be used in combination with `onError` to handle errors separately. + +```typescript +async function myLoadingProcess() { + const { data } = await fetchf('https://api.example.com/', { + strategy: 'silent', + }); -- **`reject`**: - Promises are rejected, and global error handling is triggered. You must use `try/catch` blocks to handle errors. + // In case of an error nothing below will ever be executed. + console.log('This console log will not appear.'); +} -- **`softFail`**: - Returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response's `error` object without the need for `try/catch` blocks. +myLoadingProcess(); +``` -- **`defaultResponse`**: - Returns a default response specified in case of an error. The promise will not be rejected. This can be used in conjunction with `flattenResponse` and `defaultResponse: {}` to provide sensible defaults. +The `onError` option can be configured to intercept errors: -- **`silent`**: - Hangs the promise silently on error, useful for fire-and-forget requests without the need for `try/catch`. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. This strategy is useful for dispatching requests within asynchronous wrapper functions that do not need to be awaited. It prevents excessive usage of `try/catch` or additional response data checks everywhere. It can be used in combination with `onError` to handle errors separately. +```typescript +const { data } = await fetchf('https://api.example.com/', { + strategy: 'softFail', + onError(error) { + // Intercept any error + console.error('Request failed', error.status, error.statusText); + }, +}); +``` ### How It Works From aef21a72c826d556559014ffe83af3e7ae65b7d5 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 00:56:04 +0200 Subject: [PATCH 09/15] docs: Add example for Different Error and Success Responses --- README.md | 41 ++++++++++++++++++++++++++++++++++++--- docs/examples/examples.ts | 26 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1e0531b..c1bf794 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ The following options are available for configuring interceptors in the `Request ### Configuration -The `strategy` option can be configured with the following values: +#### `strategy` **`reject`**: (default) Promises are rejected, and global error handling is triggered. You must use `try/catch` blocks to handle errors. @@ -437,7 +437,7 @@ const { data, error } = await fetchf('https://api.example.com/', { strategy: 'softFail', }); -if (error !== null) { +if (error) { console.error(error.status, error.statusText, error.response, error.config); } ``` @@ -453,7 +453,7 @@ const { data, error } = await fetchf('https://api.example.com/', { defaultResponse: {}, }); -if (error !== null) { +if (error) { console.error('Request failed', data); // "data" will be equal to {} if there is an error } ``` @@ -474,6 +474,8 @@ async function myLoadingProcess() { myLoadingProcess(); ``` +#### `onError` + The `onError` option can be configured to intercept errors: ```typescript @@ -486,6 +488,39 @@ const { data } = await fetchf('https://api.example.com/', { }); ``` +#### Different Error and Success Responses + +There might be scenarios when your successful response data structure differs from the one that is on error. In such circumstances you can use union type and assign it depending on if it's an error or not. + +```typescript +interface SuccessResponseData { + bookId: string; + bookText: string; +} + +interface ErrorResponseData { + errorCode: number; + errorText: string; +} + +type ResponseData = SuccessResponseData | ErrorResponseData; + +const { data, error } = await fetchf('https://api.example.com/', { + strategy: 'softFail', +}); + +// Check for error here as 'data' is available for both successful and erroneous responses +if (error) { + const errorData = data as ErrorResponseData; + + console.log('Request failed', errorData.errorCode, errorData.errorText); +} else { + const successData = data as SuccessResponseData; + + console.log('Request successful', successData.bookText); +} +``` + ### How It Works 1. **Reject Strategy**: diff --git a/docs/examples/examples.ts b/docs/examples/examples.ts index 9723cf5..3293619 100644 --- a/docs/examples/examples.ts +++ b/docs/examples/examples.ts @@ -340,6 +340,31 @@ async function example7() { console.log('Example 7', response); } +// fetchf() - different error payload +async function example8() { + interface SuccessResponseData { + bookId: string; + bookText: string; + } + + interface ErrorResponseData { + errorCode: number; + errorText: string; + } + + const { data, error } = await fetchf( + 'https://example.com/api/custom-endpoint', + ); + + if (error) { + const errorData = data as ErrorResponseData; + + console.log('Example 8 Error', errorData.errorCode); + } else { + console.log('Example 8 Success', data); + } +} + example1(); example2(); example3(); @@ -347,3 +372,4 @@ example4(); example5(); example6(); example7(); +example8(); From 24082616db429147a76e040bcccb98ab3f2eb0b9 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 01:16:57 +0200 Subject: [PATCH 10/15] fix: Fallback to response.status for Network or CORS errors --- src/request-handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 3d154e5..c8ab804 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -515,7 +515,10 @@ export function createRequestHandler( PathParams, RequestBody >; - const status = error?.response?.status || error?.status || 0; + + // Fallback to response.status for Network or CORS errors + const status = + error?.response?.status || error?.status || response.status || 0; if ( attempt === retries || From 16c9e1cd72976eec16b0a6a9ab71d0f964293d54 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 02:40:40 +0200 Subject: [PATCH 11/15] feat: Append additional information to Network, CORS or any other fetch() errors --- .../fetch-error.ts} | 29 +++++++++---------- src/errors/network-error.ts | 24 +++++++++++++++ src/errors/response-error.ts | 26 +++++++++++++++++ src/request-error.ts | 18 ------------ src/request-handler.ts | 13 +++++---- src/types/request-handler.ts | 8 ++--- test/request-handler.spec.ts | 28 +++++++++--------- 7 files changed, 90 insertions(+), 56 deletions(-) rename src/{response-error.ts => errors/fetch-error.ts} (56%) create mode 100644 src/errors/network-error.ts create mode 100644 src/errors/response-error.ts delete mode 100644 src/request-error.ts diff --git a/src/response-error.ts b/src/errors/fetch-error.ts similarity index 56% rename from src/response-error.ts rename to src/errors/fetch-error.ts index 26bf213..b3c1dba 100644 --- a/src/response-error.ts +++ b/src/errors/fetch-error.ts @@ -5,9 +5,12 @@ import type { DefaultUrlParams, FetchResponse, RequestConfig, -} from './types'; +} from '../types'; -export class ResponseError< +/** + * This is a base error class + */ +export class FetchError< ResponseData = DefaultResponse, QueryParams = DefaultParams, PathParams = DefaultUrlParams, @@ -17,26 +20,22 @@ export class ResponseError< statusText: string; request: RequestConfig; config: RequestConfig; - response: FetchResponse; + response: FetchResponse | null; constructor( message: string, - requestInfo: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - >, - response: FetchResponse, + request: RequestConfig, + response: FetchResponse | null, ) { super(message); - this.name = 'ResponseError'; + this.name = 'FetchError'; + this.message = message; - this.status = response.status; - this.statusText = response.statusText; - this.request = requestInfo; - this.config = requestInfo; + this.status = response?.status || 0; + this.statusText = response?.statusText || ''; + this.request = request; + this.config = request; this.response = response; } } diff --git a/src/errors/network-error.ts b/src/errors/network-error.ts new file mode 100644 index 0000000..5039303 --- /dev/null +++ b/src/errors/network-error.ts @@ -0,0 +1,24 @@ +import { FetchError } from './fetch-error'; +import type { + DefaultParams, + DefaultPayload, + DefaultResponse, + DefaultUrlParams, + RequestConfig, +} from '../types'; + +export class NetworkError< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> extends FetchError { + constructor( + message: string, + request: RequestConfig, + ) { + super(message, request, null); + + this.name = 'NetworkError'; + } +} diff --git a/src/errors/response-error.ts b/src/errors/response-error.ts new file mode 100644 index 0000000..526ea47 --- /dev/null +++ b/src/errors/response-error.ts @@ -0,0 +1,26 @@ +import { FetchError } from './fetch-error'; +import type { + DefaultParams, + DefaultPayload, + DefaultResponse, + DefaultUrlParams, + FetchResponse, + RequestConfig, +} from '../types'; + +export class ResponseError< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> extends FetchError { + constructor( + message: string, + request: RequestConfig, + response: FetchResponse | null, + ) { + super(message, request, response); + + this.name = 'ResponseError'; + } +} diff --git a/src/request-error.ts b/src/request-error.ts deleted file mode 100644 index 7a47e17..0000000 --- a/src/request-error.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { RequestConfig } from './types'; - -export class RequestError extends Error { - response: Response; - request: RequestConfig; - - constructor(message: string, requestInfo: RequestConfig, response: Response) { - super(message); - - this.name = 'RequestError'; - this.message = message; - this.request = requestInfo; - this.response = response; - - // Clean stack trace - Error.captureStackTrace(this, RequestError); - } -} diff --git a/src/request-handler.ts b/src/request-handler.ts index c8ab804..81446dd 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -20,7 +20,7 @@ import type { QueryParams, } from './types/api-handler'; import { applyInterceptor } from './interceptor-manager'; -import { ResponseError } from './response-error'; +import { ResponseError } from './errors/response-error'; import { appendQueryParams, isJSONSerializable, @@ -516,14 +516,17 @@ export function createRequestHandler( RequestBody >; - // Fallback to response.status for Network or CORS errors - const status = - error?.response?.status || error?.status || response.status || 0; + // Append additional information to Network, CORS or any other fetch() errors + error.status = response?.status || 0; + error.statusText = response?.statusText || ''; + error.config = fetcherConfig; + error.request = fetcherConfig; + error.response = response; if ( attempt === retries || !(!shouldRetry || (await shouldRetry(error, attempt))) || - !retryOn?.includes(status) + !retryOn?.includes(error.status) ) { await processError< ResponseData, diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index bdff308..9ae44fc 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -95,11 +95,11 @@ export interface ResponseError< PathParams = DefaultUrlParams, RequestBody = DefaultPayload, > extends Error { - status?: number; - statusText?: string; - request?: RequestConfig; + status: number; + statusText: string; + request: RequestConfig; config: RequestConfig; - response?: FetchResponse; + response: FetchResponse | null; } export type RetryFunction = < diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 204ef23..24e5745 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -9,7 +9,7 @@ import type { } from '../src/types/request-handler'; import { fetchf } from '../src'; import { ABORT_ERROR } from '../src/constants'; -import type { ResponseError } from '../src/response-error'; +import type { ResponseError } from '../src/errors/response-error'; jest.mock('../src/utils', () => { const originalModule = jest.requireActual('../src/utils'); @@ -382,7 +382,7 @@ describe('Request Handler', () => { }); // Mock fetch to return a successful response every time - (globalThis.fetch as any).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, clone: jest.fn().mockReturnValue({}), json: jest.fn().mockResolvedValue({}), @@ -423,7 +423,7 @@ describe('Request Handler', () => { }); // Mock fetch to return a successful response - (globalThis.fetch as any).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, clone: jest.fn().mockReturnValue({}), json: jest.fn().mockResolvedValue({}), @@ -455,7 +455,7 @@ describe('Request Handler', () => { }); // Mock fetch to fail - (globalThis.fetch as any).mockRejectedValue({ + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, json: jest.fn().mockResolvedValue({}), }); @@ -466,7 +466,7 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); - await expect(requestHandler.request('/endpoint')).rejects.toEqual({ + await expect(requestHandler.request('/endpoint')).rejects.toMatchObject({ status: 500, json: expect.any(Function), }); @@ -501,7 +501,7 @@ describe('Request Handler', () => { }); // Mock fetch to return a successful response - (globalThis.fetch as any).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, clone: jest.fn().mockReturnValue({}), json: jest.fn().mockResolvedValue({}), @@ -535,7 +535,7 @@ describe('Request Handler', () => { }); // Mock fetch to return a successful response - (globalThis.fetch as any).mockResolvedValue({ + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, clone: jest.fn().mockReturnValue({}), json: jest.fn().mockResolvedValue({}), @@ -586,7 +586,7 @@ describe('Request Handler', () => { // Mock fetch to fail twice and then succeed let callCount = 0; - (globalThis.fetch as any).mockImplementation(() => { + (globalThis.fetch as jest.Mock).mockImplementation(() => { callCount++; if (callCount <= retryConfig.retries) { return Promise.reject({ @@ -649,7 +649,7 @@ describe('Request Handler', () => { onError: jest.fn(), }); - (globalThis.fetch as any).mockRejectedValue({ + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, json: jest.fn().mockResolvedValue({}), }); @@ -699,12 +699,12 @@ describe('Request Handler', () => { onError: jest.fn(), }); - (globalThis.fetch as any).mockRejectedValue({ + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 400, json: jest.fn().mockResolvedValue({}), }); - await expect(requestHandler.request('/endpoint')).rejects.toEqual({ + await expect(requestHandler.request('/endpoint')).rejects.toMatchObject({ status: 400, json: expect.any(Function), }); @@ -728,7 +728,7 @@ describe('Request Handler', () => { onError: jest.fn(), }); - (globalThis.fetch as any).mockRejectedValue({ + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, json: jest.fn().mockResolvedValue({}), }); @@ -771,12 +771,12 @@ describe('Request Handler', () => { onError: jest.fn(), }); - (globalThis.fetch as any).mockRejectedValue({ + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, json: jest.fn().mockResolvedValue({}), }); - await expect(requestHandler.request('/endpoint')).rejects.toEqual({ + await expect(requestHandler.request('/endpoint')).rejects.toMatchObject({ status: 500, json: expect.any(Function), }); From ed927b709585292d6a55a49ad9b485282f512e74 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 02:42:41 +0200 Subject: [PATCH 12/15] fix: Bring back response status check --- src/request-handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 81446dd..852e721 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -517,7 +517,8 @@ export function createRequestHandler( >; // Append additional information to Network, CORS or any other fetch() errors - error.status = response?.status || 0; + error.status = + error?.response?.status || error?.status || response?.status || 0; error.statusText = response?.statusText || ''; error.config = fetcherConfig; error.request = fetcherConfig; From ac22352226b4a588188620f83bc47f602fd3687e Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 02:43:21 +0200 Subject: [PATCH 13/15] fix: Bring back response status check --- src/request-handler.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 852e721..699988a 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -517,9 +517,8 @@ export function createRequestHandler( >; // Append additional information to Network, CORS or any other fetch() errors - error.status = - error?.response?.status || error?.status || response?.status || 0; - error.statusText = response?.statusText || ''; + error.status = error?.status || response?.status || 0; + error.statusText = error?.statusText || response?.statusText || ''; error.config = fetcherConfig; error.request = fetcherConfig; error.response = response; From ff73cd25971049264346dfe86a4afc82a4445096 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 18 Oct 2024 02:53:34 +0200 Subject: [PATCH 14/15] docs: Improve Error Handling section --- README.md | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c1bf794..408d638 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ To address these challenges, the `fetchf()` provides several enhancements: 1. **Consistent Error Handling:** - In JavaScript, the native `fetch()` function does not reject the Promise for HTTP error statuses such as 404 (Not Found) or 500 (Internal Server Error). Instead, `fetch()` resolves the Promise with a `Response` object, where the `ok` property indicates the success of the request. If the request encounters a network error or fails due to other issues (e.g., server downtime), `fetch()` will reject the Promise. - - This approach aligns error handling with common practices and makes it easier to manage errors consistently. + - The `fetchff` plugin aligns error handling with common practices and makes it easier to manage errors consistently by rejecting erroneous status codes. 2. **Enhanced Retry Mechanism:** @@ -127,7 +127,7 @@ To address these challenges, the `fetchf()` provides several enhancements: 3. **Improved Error Visibility:** - - **Error Wrapping:** The `createApiFetcher()` and `fetchf()` wrap errors in a custom `RequestError` class, which provides detailed information about the request and response, similarly to what Axios does. This makes debugging easier and improves visibility into what went wrong. + - **Error Wrapping:** The `createApiFetcher()` and `fetchf()` wrap errors in a custom `ResponseError` class, which provides detailed information about the request and response. This makes debugging easier and improves visibility into what went wrong. 4. **Extended settings:** - Check Settings table for more information about all settings. @@ -412,6 +412,11 @@ The following options are available for configuring interceptors in the `Request
Error handling strategies define how to manage errors that occur during requests. You can configure the strategy option to specify what should happen when an error occurs. This affects whether promises are rejected, if errors are handled silently, or if default responses are provided. You can also combine it with onError interceptor for more tailored approach. +
+
+ +The native `fetch()` API function doesn't throw exceptions for HTTP errors like `404` or `500` — it only rejects the promise if there is a network-level error (e.g., the request fails due to a DNS error, no internet connection, or CORS issues). The `fetchf()` function brings consistency and lets you align the behavior depending on chosen strategy. By default, all errors are rejected. + ### Configuration #### `strategy` @@ -474,6 +479,23 @@ async function myLoadingProcess() { myLoadingProcess(); ``` +##### How It Works + +1. **Reject Strategy**: + When using the `reject` strategy, if an error occurs, the promise is rejected, and global error handling logic is triggered. You must use `try/catch` to handle these errors. + +2. **Soft Fail Strategy**: + With `softFail`, the response object includes additional properties that provide details about the error without rejecting the promise. This allows you to handle error information directly within the response. + +3. **Default Response Strategy**: + The `defaultResponse` strategy returns a predefined default response when an error occurs. This approach prevents the promise from being rejected, allowing for default values to be used in place of error data. + +4. **Silent Strategy**: + The `silent` strategy results in the promise hanging silently on error. The promise will not be resolved or rejected, and any subsequent code will not execute. This is useful for fire-and-forget requests and can be combined with `onError` for separate error handling. + +5. **Custom Error Handling**: + Depending on the strategy chosen, you can tailor how errors are managed, either by handling them directly within response objects, using default responses, or managing them silently. + #### `onError` The `onError` option can be configured to intercept errors: @@ -521,23 +543,6 @@ if (error) { } ``` -### How It Works - -1. **Reject Strategy**: - When using the `reject` strategy, if an error occurs, the promise is rejected, and global error handling logic is triggered. You must use `try/catch` to handle these errors. - -2. **Soft Fail Strategy**: - With `softFail`, the response object includes additional properties that provide details about the error without rejecting the promise. This allows you to handle error information directly within the response. - -3. **Default Response Strategy**: - The `defaultResponse` strategy returns a predefined default response when an error occurs. This approach prevents the promise from being rejected, allowing for default values to be used in place of error data. - -4. **Silent Strategy**: - The `silent` strategy results in the promise hanging silently on error. The promise will not be resolved or rejected, and any subsequent code will not execute. This is useful for fire-and-forget requests and can be combined with `onError` for separate error handling. - -5. **Custom Error Handling**: - Depending on the strategy chosen, you can tailor how errors are managed, either by handling them directly within response objects, using default responses, or managing them silently. - ## 🗄️ Smart Cache Management From c8922cfa4177933d215a41912b4eeb8c7905d47b Mon Sep 17 00:00:00 2001 From: MWlodarczyk Date: Sat, 16 Nov 2024 22:45:26 +0100 Subject: [PATCH 15/15] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 408d638..e6f5abe 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ The following options are available for configuring interceptors in the `Request

-The native `fetch()` API function doesn't throw exceptions for HTTP errors like `404` or `500` — it only rejects the promise if there is a network-level error (e.g., the request fails due to a DNS error, no internet connection, or CORS issues). The `fetchf()` function brings consistency and lets you align the behavior depending on chosen strategy. By default, all errors are rejected. +The native `fetch()` API function doesn't throw exceptions for HTTP errors like `404` or `500` — it only rejects the promise if there is a network-level error (e.g. the request fails due to a DNS error, no internet connection, or CORS issues). The `fetchf()` function brings consistency and lets you align the behavior depending on chosen strategy. By default, all errors are rejected. ### Configuration