Skip to content

Commit abebea3

Browse files
feat(errors): Introduce ClientError and ServerError classes
- Added ClientError class for handling 4xx client errors with enhanced toString method for better error reporting in production and development environments. - Introduced ServerError class for managing 5xx server errors, including detailed response body handling for non-production environments. - Deprecated ApiError in favor of ClientError for improved clarity and consistency. - Updated defaultFetcher and effectErrorHandler to utilize new error classes, ensuring better error management across the application. This update enhances error handling capabilities and improves the overall robustness of the application.
1 parent e2a2381 commit abebea3

17 files changed

Lines changed: 852 additions & 158 deletions

src/errors/defaultError.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,41 @@ export class NetworkError extends Data.TaggedError('NetworkError')<{
8282
}
8383
}
8484

85-
export class ApiError extends Data.TaggedError('ApiError')<{
85+
// 4xx 클라이언트 에러용
86+
export class ClientError extends Data.TaggedError('ClientError')<{
8687
readonly errorCode: string;
8788
readonly errorMessage: string;
8889
readonly httpStatus: number;
8990
readonly url?: string;
9091
}> {
9192
toString(): string {
92-
return `${this.errorCode}: ${this.errorMessage}`;
93+
if (process.env.NODE_ENV === 'production') {
94+
return `${this.errorCode}: ${this.errorMessage}`;
95+
}
96+
return `ClientError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage}\nURL: ${this.url}`;
97+
}
98+
}
99+
100+
/** @deprecated Use ClientError instead */
101+
export const ApiError = ClientError;
102+
/** @deprecated Use ClientError instead */
103+
export type ApiError = ClientError;
104+
105+
// 5xx 서버 에러용
106+
export class ServerError extends Data.TaggedError('ServerError')<{
107+
readonly errorCode: string;
108+
readonly errorMessage: string;
109+
readonly httpStatus: number;
110+
readonly url?: string;
111+
readonly responseBody?: string;
112+
}> {
113+
toString(): string {
114+
const isProduction = process.env.NODE_ENV === 'production';
115+
if (isProduction) {
116+
return `ServerError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage}`;
117+
}
118+
return `ServerError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage}
119+
URL: ${this.url}
120+
Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`;
93121
}
94122
}

src/lib/defaultFetcher.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {Data, Effect, Match, pipe, Schedule} from 'effect';
22
import {
3-
ApiError,
3+
ClientError,
44
DefaultError,
55
ErrorResponse,
66
NetworkError,
7+
ServerError,
78
} from '../errors/defaultError';
89
import getAuthInfo, {AuthenticationParameter} from './authenticator';
910
import {runSafePromise} from './effectErrorHandler';
@@ -51,7 +52,7 @@ const handleClientErrorResponse = (res: Response) =>
5152
}),
5253
Effect.flatMap(error =>
5354
Effect.fail(
54-
new ApiError({
55+
new ClientError({
5556
errorCode: error.errorCode,
5657
errorMessage: error.errorMessage,
5758
httpStatus: res.status,
@@ -75,18 +76,38 @@ const handleServerErrorResponse = (res: Response) =>
7576
},
7677
}),
7778
}),
78-
Effect.flatMap(text =>
79-
Effect.fail(
80-
new DefaultError({
81-
errorCode: 'UnknownError',
82-
errorMessage: text,
83-
context: {
84-
responseStatus: res.status,
85-
responseUrl: res.url,
86-
},
79+
Effect.flatMap(text => {
80+
const isProduction = process.env.NODE_ENV === 'production';
81+
82+
// JSON 파싱 시도
83+
try {
84+
const json = JSON.parse(text) as Partial<ErrorResponse>;
85+
if (json.errorCode && json.errorMessage) {
86+
return Effect.fail(
87+
new ServerError({
88+
errorCode: json.errorCode,
89+
errorMessage: json.errorMessage,
90+
httpStatus: res.status,
91+
url: res.url,
92+
responseBody: isProduction ? undefined : text,
93+
}),
94+
);
95+
}
96+
} catch {
97+
// JSON 파싱 실패 시 무시하고 fallback
98+
}
99+
100+
// JSON이 아니거나 필드가 없는 경우
101+
return Effect.fail(
102+
new ServerError({
103+
errorCode: `HTTP_${res.status}`,
104+
errorMessage: text.substring(0, 200) || 'Server error occurred',
105+
httpStatus: res.status,
106+
url: res.url,
107+
responseBody: isProduction ? undefined : text,
87108
}),
88-
),
89-
),
109+
);
110+
}),
90111
);
91112

92113
/**

src/lib/effectErrorHandler.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const formatError = (error: unknown): string => {
2323
if (error instanceof EffectError.NetworkError) {
2424
return error.toString();
2525
}
26-
if (error instanceof EffectError.ApiError) {
26+
if (error instanceof EffectError.ClientError) {
27+
return error.toString();
28+
}
29+
if (error instanceof EffectError.ServerError) {
2730
return error.toString();
2831
}
2932
if (error instanceof VariableValidationError) {
@@ -65,7 +68,11 @@ export const runSafeSync = <E, A>(effect: Effect.Effect<A, E>): A => {
6568
if (firstDefect instanceof Error) {
6669
throw firstDefect;
6770
}
68-
throw new Error(`Uncaught defect: ${String(firstDefect)}`);
71+
const isProduction = process.env.NODE_ENV === 'production';
72+
const message = isProduction
73+
? `Unexpected error: ${String(firstDefect)}`
74+
: `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`;
75+
throw new Error(message);
6976
}
7077
throw new Error(`Unhandled Exit: ${Cause.pretty(cause)}`);
7178
},
@@ -94,10 +101,12 @@ export const runSafePromise = <E, A>(
94101
// 원본 Error 객체를 그대로 반환
95102
return Promise.reject(firstDefect);
96103
}
97-
// Error 객체가 아니면 새로 생성
98-
return Promise.reject(
99-
new Error(`Uncaught defect: ${String(firstDefect)}`),
100-
);
104+
// Error 객체가 아니면 환경에 따라 상세 정보 포함
105+
const isProduction = process.env.NODE_ENV === 'production';
106+
const message = isProduction
107+
? `Unexpected error: ${String(firstDefect)}`
108+
: `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`;
109+
return Promise.reject(new Error(message));
101110
}
102111

103112
// 3. 그 외 (예: 중단)의 경우, Cause를 문자열로 변환하여 반환
@@ -147,10 +156,10 @@ export const toCompatibleError = (effectError: unknown): Error => {
147156
return error;
148157
}
149158

150-
// ApiError 보존
151-
if (effectError instanceof EffectError.ApiError) {
159+
// ClientError 보존 (하위 호환성을 위해 error.name은 'ApiError' 유지)
160+
if (effectError instanceof EffectError.ClientError) {
152161
const error = new Error(effectError.toString());
153-
error.name = 'ApiError';
162+
error.name = 'ApiError'; // 하위 호환성
154163
Object.defineProperties(error, {
155164
errorCode: {
156165
value: effectError.errorCode,
@@ -175,6 +184,43 @@ export const toCompatibleError = (effectError: unknown): Error => {
175184
return error;
176185
}
177186

187+
// ServerError 보존
188+
if (effectError instanceof EffectError.ServerError) {
189+
const error = new Error(effectError.toString());
190+
error.name = 'ServerError';
191+
const props: PropertyDescriptorMap = {
192+
errorCode: {
193+
value: effectError.errorCode,
194+
writable: false,
195+
enumerable: true,
196+
},
197+
errorMessage: {
198+
value: effectError.errorMessage,
199+
writable: false,
200+
enumerable: true,
201+
},
202+
httpStatus: {
203+
value: effectError.httpStatus,
204+
writable: false,
205+
enumerable: true,
206+
},
207+
url: {value: effectError.url, writable: false, enumerable: true},
208+
};
209+
// 개발환경에서만 responseBody 포함
210+
if (!isProduction && effectError.responseBody) {
211+
props.responseBody = {
212+
value: effectError.responseBody,
213+
writable: false,
214+
enumerable: true,
215+
};
216+
}
217+
Object.defineProperties(error, props);
218+
if (isProduction) {
219+
delete (error as Error).stack;
220+
}
221+
return error;
222+
}
223+
178224
// DefaultError 보존
179225
if (effectError instanceof EffectError.DefaultError) {
180226
const error = new Error(effectError.toString());

0 commit comments

Comments
 (0)