Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { AxiosError, type AxiosResponse } from 'axios';
|
||||
import { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
|
||||
|
||||
/**
|
||||
* Structured API error that normalizes Axios errors into a
|
||||
* predictable, serializable format.
|
||||
*
|
||||
* Replaces the legacy `ErrorRequest` class with richer metadata.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* try {
|
||||
* await apiClient.get('/users');
|
||||
* } catch (err) {
|
||||
* if (err instanceof ApiError) {
|
||||
* console.log(err.code); // ApiErrorCode.UNAUTHORIZED
|
||||
* console.log(err.status); // 401
|
||||
* console.log(err.data); // { message: "Token expired" }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
/** Structured error code for programmatic handling. */
|
||||
readonly code: ApiErrorCode;
|
||||
|
||||
/** HTTP status code (0 if no response, e.g., network error). */
|
||||
readonly status: number;
|
||||
|
||||
/** Raw response body from the server, if available. */
|
||||
readonly data: unknown;
|
||||
|
||||
/** The original Axios error, preserved for debugging. */
|
||||
readonly cause: AxiosError | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiErrorCode,
|
||||
status: number,
|
||||
data?: unknown,
|
||||
cause?: AxiosError,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
this.cause = cause;
|
||||
|
||||
// Maintain proper prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, ApiError.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: creates an ApiError from an AxiosError.
|
||||
* Automatically resolves the error code from the HTTP status.
|
||||
*/
|
||||
static fromAxiosError(error: AxiosError<unknown>): ApiError {
|
||||
// Network error (no response received)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return new ApiError(
|
||||
'Request timed out',
|
||||
ApiErrorCode.TIMEOUT,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return new ApiError(
|
||||
'Request was cancelled',
|
||||
ApiErrorCode.CANCELLED,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return new ApiError(
|
||||
error.message || 'Network error',
|
||||
ApiErrorCode.NETWORK_ERROR,
|
||||
0,
|
||||
undefined,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Server responded with an error status
|
||||
const response: AxiosResponse = error.response;
|
||||
const status = response.status;
|
||||
const data = response.data;
|
||||
const code = httpStatusToErrorCode(status);
|
||||
|
||||
// Extract message from common server response formats
|
||||
const serverMessage =
|
||||
(data && typeof data === 'object' && 'message' in data)
|
||||
? String((data as Record<string, unknown>).message)
|
||||
: `Request failed with status ${status}`;
|
||||
|
||||
return new ApiError(serverMessage, code, status, data, error);
|
||||
}
|
||||
|
||||
/** Convenience check for authentication failures. */
|
||||
get isUnauthorized(): boolean {
|
||||
return this.code === ApiErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
/** Convenience check for permission failures. */
|
||||
get isForbidden(): boolean {
|
||||
return this.code === ApiErrorCode.FORBIDDEN;
|
||||
}
|
||||
|
||||
/** Convenience check for network/connectivity issues. */
|
||||
get isNetworkError(): boolean {
|
||||
return this.code === ApiErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
/** JSON-serializable representation for logging/telemetry. */
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
status: this.status,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Enumeration of well-known API error codes.
|
||||
*
|
||||
* Use these to programmatically handle specific server responses
|
||||
* without relying on magic strings scattered across the codebase.
|
||||
*/
|
||||
export enum ApiErrorCode {
|
||||
// ─── HTTP Standard ────────────────────────────────────────────
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
CONFLICT = 'CONFLICT',
|
||||
UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY',
|
||||
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
|
||||
// ─── Client-side ──────────────────────────────────────────────
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
CANCELLED = 'CANCELLED',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps HTTP status codes to ApiErrorCode enum values.
|
||||
*/
|
||||
export function httpStatusToErrorCode(status: number): ApiErrorCode {
|
||||
switch (status) {
|
||||
case 400: return ApiErrorCode.BAD_REQUEST;
|
||||
case 401: return ApiErrorCode.UNAUTHORIZED;
|
||||
case 403: return ApiErrorCode.FORBIDDEN;
|
||||
case 404: return ApiErrorCode.NOT_FOUND;
|
||||
case 409: return ApiErrorCode.CONFLICT;
|
||||
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY;
|
||||
case 429: return ApiErrorCode.TOO_MANY_REQUESTS;
|
||||
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR;
|
||||
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE;
|
||||
default: return ApiErrorCode.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ApiError } from './api-error';
|
||||
export { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
|
||||
Reference in New Issue
Block a user