diff --git a/README.md b/README.md index 6315e6b87..bb1431746 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Usability, consistency, and performance are key focuses of jira.js, and it also - [Basic](#basic-authentication) - [OAuth 2.0](#oauth-20) - [Personal access token](#personal-access-token) + - [Error handling](#error-handling) - [Example and using algorithm](#example-and-using-algorithm) - [Decreasing Webpack bundle size](#decreasing-webpack-bundle-size) - [Take a look at our other products](#take-a-look-at-our-other-products) @@ -123,6 +124,34 @@ const client = new Version3Client({ }); ``` +#### Error handling +Starting from version 4.0.0, the library has a new error handling system. +Now, all errors are instances of + - the `HttpException` class in case the Axios has response from the server; + - the `AxiosError` class in case something went wrong before sending the request. + +The `HttpException` class tries to parse different sorts of responses from the server to provide a unified error class. + +If the original error is required, you can get it from the `cause` property of the `HttpException` class. + +```typescript +try { + const users = await this.client.userSearch.findUsers({ query: email }); + // ... +} catch (error: uknown) { + if (error instanceof HttpException) { + console.log(error.message); + console.log(error.cause); // original error (AxiosError | Error) + console.log(error.cause.response?.headers); // headers from the server + } else if (error instanceof AxiosError) { + console.log(error.message); + console.log(error.code); // error code, for instance AxiosError.ETIMEDOUT + } else { + console.log(error); + } +} +```` + #### Example and using algorithm 1. Example diff --git a/src/callback.ts b/src/callback.ts index 68b0e1166..9bcc05f52 100644 --- a/src/callback.ts +++ b/src/callback.ts @@ -1,3 +1,3 @@ -import { AxiosError } from 'axios'; +import { Config } from './config'; -export type Callback = (err: AxiosError | null, data?: T) => void; +export type Callback = (err: Config.Error | null, data?: T) => void; diff --git a/src/clients/baseClient.ts b/src/clients/baseClient.ts index c0132b82b..d3bea4ad1 100644 --- a/src/clients/baseClient.ts +++ b/src/clients/baseClient.ts @@ -1,9 +1,10 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; import type { Callback } from '../callback'; import type { Client } from './client'; import type { Config } from '../config'; import { getAuthenticationToken } from '../services/authenticationService'; import type { RequestConfig } from '../requestConfig'; +import { HttpException, isObject } from './httpException'; const STRICT_GDPR_FLAG = 'x-atlassian-force-account-id'; const ATLASSIAN_TOKEN_CHECK_FLAG = 'X-Atlassian-Token'; @@ -91,7 +92,7 @@ export class BaseClient implements Client { const response = await this.sendRequestFullResponse(requestConfig); return this.handleSuccessResponse(response.data, callback); - } catch (e: any) { + } catch (e: unknown) { return this.handleFailedResponse(e, callback); } } @@ -119,11 +120,11 @@ export class BaseClient implements Client { return responseHandler(response); } - handleFailedResponse(e: Error, callback?: Callback | never): void { - const err = axios.isAxiosError(e) && e.response ? this.buildErrorHandlingResponse(e) : e; + handleFailedResponse(e: unknown, callback?: Callback | never): void { + const err = this.buildErrorHandlingResponse(e); const callbackErrorHandler = callback && ((error: Config.Error) => callback(error)); - const defaultErrorHandler = (error: Error) => { + const defaultErrorHandler = (error: Config.Error) => { throw error; }; @@ -134,20 +135,33 @@ export class BaseClient implements Client { return errorHandler(err); } - private buildErrorHandlingResponse(error: AxiosError) { - const headers = error.response?.headers ?? {}; - const responseData = error.response?.data ?? {}; - const data = typeof responseData === 'object' ? responseData : { data: responseData }; + private buildErrorHandlingResponse(e: unknown): Config.Error { + if (axios.isAxiosError(e) && e.response) { + return new HttpException( + { + code: e.code, + message: e.message, + data: e.response.data, + status: e.response?.status, + statusText: e.response?.statusText, + }, + e.response.status, + { cause: e }, + ); + } - return { - code: error.code, - headers: this.removeUndefinedProperties({ - [RETRY_AFTER]: headers[RETRY_AFTER], - [RATE_LIMIT_RESET]: headers[RATE_LIMIT_RESET], - }), - status: error.response?.status, - statusText: error.response?.statusText, - ...data, - }; + if (axios.isAxiosError(e)) { + return e; + } + + if (isObject(e) && isObject((e as Record).response)) { + return new HttpException((e as Record).response); + } + + if (e instanceof Error) { + return new HttpException(e); + } + + return new HttpException('Unknown error occurred.', 500, { cause: e }); } } diff --git a/src/clients/httpException.ts b/src/clients/httpException.ts new file mode 100644 index 000000000..0e71522f8 --- /dev/null +++ b/src/clients/httpException.ts @@ -0,0 +1,137 @@ +export const isUndefined = (obj: any): obj is undefined => typeof obj === 'undefined'; + +export const isNil = (val: any): val is null | undefined => isUndefined(val) || val === null; + +export const isObject = (fn: any): fn is object => !isNil(fn) && typeof fn === 'object'; + +export const isString = (val: any): val is string => typeof val === 'string'; + +export const isNumber = (val: any): val is number => typeof val === 'number'; + +export interface HttpExceptionOptions { + /** Original cause of the error */ + cause?: unknown; + description?: string; +} + +export const DEFAULT_EXCEPTION_STATUS = 500; +export const DEFAULT_EXCEPTION_MESSAGE = 'Something went wrong'; +export const DEFAULT_EXCEPTION_CODE = 'INTERNAL_SERVER_ERROR'; +export const DEFAULT_EXCEPTION_STATUS_TEXT = 'Internal server error'; + +/** Defines the base HTTP exception, which is handled by the default Exceptions Handler. */ +export class HttpException extends Error { + /** + * Instantiate a plain HTTP Exception. + * + * @example + * throw new HttpException('message', HttpStatus.BAD_REQUEST); + * throw new HttpException('custom message', HttpStatus.BAD_REQUEST, { + * cause: new Error('Cause Error'), + * }); + * + * @param response String, object describing the error condition or the error cause. + * @param status HTTP response status code. + * @param options An object used to add an error cause. Configures error chaining support + * @usageNotes + * The constructor arguments define the response and the HTTP response status code. + * - The `response` argument (required) defines the JSON response body. alternatively, it can also be + * an error object that is used to define an error [cause](https://nodejs.org/en/blog/release/v16.9.0/#error-cause). + * - The `status` argument (optional) defines the HTTP Status Code. + * - The `options` argument (optional) defines additional error options. Currently, it supports the `cause` attribute, + * and can be used as an alternative way to specify the error cause: `const error = new HttpException('description', 400, { cause: new Error() });` + * + * By default, the JSON response body contains two properties: + * - `statusCode`: the Http Status Code. + * - `message`: a short description of the HTTP error by default; override this + * by supplying a string in the `response` parameter. + * + * The `status` argument is required, and should be a valid HTTP status code. + * Best practice is to use the `HttpStatus` enum imported from `nestjs/common`. + * @see https://nodejs.org/en/blog/release/v16.9.0/#error-cause + * @see https://github.com/microsoft/TypeScript/issues/45167 + */ + constructor( + public readonly response: string | Record, + status?: number, + options?: HttpExceptionOptions, + ) { + super(); + + this.name = this.initName(); + this.cause = this.initCause(response, options); + this.code = this.initCode(response); + this.message = this.initMessage(response); + this.status = this.initStatus(response, status); + this.statusText = this.initStatusText(response, this.status); + } + + public readonly cause?: unknown; + public readonly code?: string; + public readonly status: number; + public readonly statusText?: string; + + protected initMessage(response: string | Record) { + if (isString(response)) { + return response; + } + + if (isObject(response) && isString((response as Record).message)) { + return (response as Record).message; + } + + if (this.constructor) { + return this.constructor.name.match(/[A-Z][a-z]+|[0-9]+/g)?.join(' ') ?? 'Error'; + } + + return DEFAULT_EXCEPTION_MESSAGE; + } + + protected initCause(response: string | Record, options?: HttpExceptionOptions): unknown { + if (options?.cause) { + return options.cause; + } + + if (isObject(response) && isObject((response as Record).cause)) { + return (response as Record).cause; + } + + return undefined; + } + + protected initCode(response: string | Record): string { + if (isObject(response) && isString((response as Record).code)) { + return (response as Record).code; + } + + return DEFAULT_EXCEPTION_CODE; + } + + protected initName(): string { + return this.constructor.name; + } + + protected initStatus(response: string | Record, status?: number): number { + if (status) { + return status; + } + + if (isObject(response) && isNumber((response as Record).status)) { + return (response as Record).status; + } + + if (isObject(response) && isNumber((response as Record).statusCode)) { + return (response as Record).statusCode; + } + + return DEFAULT_EXCEPTION_STATUS; + } + + protected initStatusText(response: string | Record, status?: number): string | undefined { + if (isObject(response) && isString((response as Record).statusText)) { + return (response as Record).statusText; + } + + return status ? undefined : DEFAULT_EXCEPTION_STATUS_TEXT; + } +} diff --git a/src/clients/index.ts b/src/clients/index.ts index 64086d442..08682970e 100644 --- a/src/clients/index.ts +++ b/src/clients/index.ts @@ -1,5 +1,6 @@ export * from './baseClient'; export * from './client'; +export * from './httpException'; export { AgileClient, AgileModels, AgileParameters } from '../agile'; diff --git a/src/config.ts b/src/config.ts index 0ef049e40..5d661c62d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import { RequestConfig } from './requestConfig'; import { UtilityTypes } from './utilityTypes'; +import { HttpException } from './clients'; export interface Config { host: string; @@ -14,7 +15,7 @@ export interface Config { export namespace Config { export type BaseRequestConfig = RequestConfig; - export type Error = AxiosError; + export type Error = AxiosError | HttpException; export type Authentication = UtilityTypes.XOR3< {