Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Schema.String.pipe(
| File | Purpose |
|------|---------|
| `defaultFetcher.ts` | HTTP client — Effect.gen, retry 3x exponential backoff, Match |
| `effectErrorHandler.ts` | `runSafePromise`, `runSafeSync`, `unwrapCause` |
| `effectErrorHandler.ts` | `runSafePromise`, `unwrapCause` |
| `authenticator.ts` | HMAC-SHA256 auth header |
| `stringifyQuery.ts` | URL query string builder (array handling) |
| `fileToBase64.ts` | File/URL → Base64 |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pnpm docs # Generate TypeDoc documentation
- 에러: `Data.TaggedError` + environment-aware `toString()`
- 비동기: `Effect.gen` + `Effect.tryPromise`
- 검증: Effect Schema (`Schema.filter`, `Schema.transform`)
- Promise 변환: `runSafePromise()` / `runSafeSync()`
- Promise 변환: `runSafePromise()`

### Path Aliases
```
Expand Down
38 changes: 24 additions & 14 deletions src/lib/defaultFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,31 @@ type DefaultRequest = {
method: string;
};

const ERROR_MESSAGE_PREVIEW_LENGTH = 200;
const RETRYABLE_ERROR_KEYWORDS = [
'aborted',
'refused',
'reset',
'econn',
] as const;

class RetryableError extends Data.TaggedError('RetryableError')<{
readonly error?: unknown;
}> {}

const toMessage = (e: unknown): string =>
e instanceof Error ? e.message : String(e);

const isRetryableNetworkError = (error: Error): boolean => {
const cause = error.cause;
const causeCode =
cause && typeof cause === 'object' && 'code' in cause
? String(cause.code)
: '';
const message = `${error.message} ${causeCode}`.toLowerCase();
return RETRYABLE_ERROR_KEYWORDS.some(keyword => message.includes(keyword));
};
Comment on lines +32 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

isRetryableNetworkError 함수에서 error.causeError 인스턴스인 경우, code 속성뿐만 아니라 cause.message도 함께 확인하는 것이 더 안전합니다. Node.js의 fetch (undici) 등 일부 환경에서는 실제 네트워크 에러 정보가 cause.message에 담기는 경우가 많기 때문입니다.

const isRetryableNetworkError = (error: Error): boolean => {
  const cause = error.cause;
  const causeInfo =
    cause instanceof Error
      ? `${cause.message} ${'code' in cause ? String(cause.code) : ''}`
      : cause && typeof cause === 'object' && 'code' in cause
        ? String(cause.code)
        : String(cause ?? '');
  const message = `${error.message} ${causeInfo}`.toLowerCase();
  return RETRYABLE_ERROR_KEYWORDS.some(keyword => message.includes(keyword));
};


const makeParseError = (res: Response, message: string) =>
new DefaultError({
errorCode: 'ParseError',
Expand Down Expand Up @@ -62,7 +80,9 @@ const handleClientErrorResponse = (res: Response) =>
Effect.flatMap(text => {
const genericError = new ClientError({
errorCode: `HTTP_${res.status}`,
errorMessage: text.substring(0, 200) || 'Client error occurred',
errorMessage:
text.substring(0, ERROR_MESSAGE_PREVIEW_LENGTH) ||
'Client error occurred',
httpStatus: res.status,
url: res.url,
});
Expand Down Expand Up @@ -148,7 +168,8 @@ const handleServerErrorResponse = (res: Response) =>

const genericError = makeError(
`HTTP_${res.status}`,
text.substring(0, 200) || 'Server error occurred',
text.substring(0, ERROR_MESSAGE_PREVIEW_LENGTH) ||
'Server error occurred',
);

return parseServerErrorBody(text, genericError, makeError);
Expand Down Expand Up @@ -191,18 +212,7 @@ export function defaultFetcherEffect<T, R>(
}),
catch: (error: unknown) => {
if (error instanceof Error) {
const cause = error.cause;
const causeCode =
cause && typeof cause === 'object' && 'code' in cause
? String(cause.code)
: '';
const message = (error.message + ' ' + causeCode).toLowerCase();
const isRetryable =
message.includes('aborted') ||
message.includes('refused') ||
message.includes('reset') ||
message.includes('econn');
if (isRetryable) {
if (isRetryableNetworkError(error)) {
return new RetryableError({error});
}
return new NetworkError({
Expand Down
10 changes: 0 additions & 10 deletions src/lib/effectErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,6 @@ const unwrapCause = (cause: Cause.Cause<unknown>): unknown => {
return new UnhandledExitError({message});
};

export const runSafeSync = <E, A>(effect: Effect.Effect<A, E>): A => {
const exit = Effect.runSyncExit(effect);
return Exit.match(exit, {
onFailure: cause => {
throw unwrapCause(cause);
},
onSuccess: value => value,
});
};

export const runSafePromise = <E, A>(
effect: Effect.Effect<A, E>,
): Promise<A> => {
Expand Down
10 changes: 8 additions & 2 deletions src/lib/schemaUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {Schema} from 'effect';
import {ParseResult, Schema} from 'effect';
import * as Effect from 'effect/Effect';
import {BadRequestError, InvalidDateError} from '../errors/defaultError';
import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer';

/**
* Schema 디코딩 + BadRequestError 변환을 결합한 Effect 헬퍼.
* 서비스 레이어에서 반복되는 검증 패턴을 통일합니다.
* Effect 공식 ParseResult 포맷터(TreeFormatter/ArrayFormatter)로
* 에러 경로를 구조화하여 디버깅 가능성을 높입니다.
*/
export const decodeWithBadRequest = <A, I>(
schema: Schema.Schema<A, I>,
Expand All @@ -15,7 +17,11 @@ export const decodeWithBadRequest = <A, I>(
Schema.decodeUnknown(schema)(data),
error =>
new BadRequestError({
message: error.message,
message: ParseResult.TreeFormatter.formatErrorSync(error),
validationErrors: ParseResult.ArrayFormatter.formatErrorSync(error).map(
issue =>
`${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`,
),
}),
);

Expand Down
2 changes: 1 addition & 1 deletion src/models/base/kakao/kakaoAlimtalkTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {type InvalidDateError} from '@errors/defaultError';
import {safeDateTransfer} from '@lib/schemaUtils';
import {Schema} from 'effect';
import * as Effect from 'effect/Effect';
import {type InvalidDateError} from '@/errors/defaultError';
import {kakaoAlimtalkTemplateQuickReplySchema} from './kakaoAlimtalkTemplateQuickReply';
import {kakaoButtonSchema} from './kakaoButton';
import {type KakaoChannelCategory} from './kakaoChannel';
Expand Down
2 changes: 1 addition & 1 deletion src/models/base/kakao/kakaoChannel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {type InvalidDateError} from '@errors/defaultError';
import {safeDateTransfer} from '@lib/schemaUtils';
import {Schema} from 'effect';
import * as Effect from 'effect/Effect';
import {type InvalidDateError} from '@/errors/defaultError';

/**
* @description 카카오 채널 카테고리 타입
Expand Down
2 changes: 1 addition & 1 deletion src/models/base/messages/message.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {baseKakaoOptionSchema} from '@models/base/kakao/kakaoOption';
import {naverOptionSchema} from '@models/base/naver/naverOption';
import {rcsOptionSchema} from '@models/base/rcs/rcsOption';
import {voiceOptionSchema} from '@models/requests/voice/voiceOption';
import {Schema} from 'effect';
import {voiceOptionSchema} from '@/models/requests/voice/voiceOption';

export const messageTypeSchema = Schema.Literal(
'SMS',
Expand Down
2 changes: 0 additions & 2 deletions src/models/base/naver/naverOption.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {Schema} from 'effect';

// 네이버 스마트 알림 naverOptions 버튼 스키마
const naverOptionButtonSchema = Schema.Struct({
buttonName: Schema.String,
buttonType: Schema.String,
Expand All @@ -10,7 +9,6 @@ const naverOptionButtonSchema = Schema.Struct({
linkIos: Schema.optional(Schema.String),
});

// naverOptions 최상위 스키마
export const naverOptionSchema = Schema.Struct({
talkId: Schema.String,
templateId: Schema.String,
Expand Down
7 changes: 0 additions & 7 deletions src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// Base Models - Kakao BMS
export * from './base/kakao/bms';

// Base Models - Kakao
export {
decodeKakaoAlimtalkTemplate,
type KakaoAlimtalkTemplate,
Expand Down Expand Up @@ -67,12 +65,10 @@ export {
messageSchema,
messageTypeSchema,
} from './base/messages/message';
// Base Models - Naver
export {
type NaverOptionSchema,
naverOptionSchema,
} from './base/naver/naverOption';
// Base Models - RCS
export {
type RcsButton,
type RcsButtonSchema,
Expand All @@ -88,8 +84,5 @@ export {
rcsOptionSchema,
} from './base/rcs/rcsOption';

// Requests
export * from './requests/index';

// Responses
export * from './responses/index';
2 changes: 0 additions & 2 deletions src/models/requests/messages/getMessagesRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const baseGetMessagesRequestSchema = Schema.Struct({
endDate: Schema.optional(Schema.Union(Schema.String, Schema.DateFromSelf)),
});

// dateType은 startDate 또는 endDate가 함께 제공될 때만 유효
export const getMessagesRequestSchema = baseGetMessagesRequestSchema.pipe(
Schema.filter(data => {
const hasDate = data.startDate != null || data.endDate != null;
Expand Down Expand Up @@ -59,7 +58,6 @@ export type GetMessagesRequest =
| GetMessagesRequestWithStartDate
| GetMessagesRequestWithEndDate;

// 스키마 디코딩 결과 타입 (런타임 검증 후 내부에서 사용)
type GetMessagesRequestDecoded = Schema.Schema.Type<
typeof getMessagesRequestSchema
>;
Expand Down
8 changes: 4 additions & 4 deletions src/services/messages/groupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
ScheduledDateSendingRequest,
} from '@models/requests/messages/groupMessageRequest';
import {osPlatform, sdkVersion} from '@models/requests/messages/requestConfig';
import {
type RequestSendMessagesSchema,
requestSendMessageSchema,
} from '@models/requests/messages/sendMessage';
import {
AddMessageResponse,
GetGroupsResponse,
Expand All @@ -23,10 +27,6 @@ import {
RemoveGroupMessagesResponse,
} from '@models/responses/messageResponses';
import * as Effect from 'effect/Effect';
import {
type RequestSendMessagesSchema,
requestSendMessageSchema,
} from '@/models/requests/messages/sendMessage';
import DefaultService from '../defaultService';

/**
Expand Down
14 changes: 0 additions & 14 deletions src/types/commonTypes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {Schema} from 'effect';

// --- Count & Charge Types ---

export const countSchema = Schema.Struct({
total: Schema.Number,
sentTotal: Schema.Number,
Expand Down Expand Up @@ -62,8 +60,6 @@ export type MessageTypeRecord = Schema.Schema.Type<
typeof messageTypeRecordSchema
>;

// --- App & Log ---

export const appSchema = Schema.Struct({
profit: messageTypeRecordSchema,
appId: Schema.NullishOr(Schema.String),
Expand All @@ -75,8 +71,6 @@ export const logSchema = Schema.Array(
);
export type Log = Schema.Schema.Type<typeof logSchema>;

// --- Group ---

export const groupIdSchema = Schema.String;
export type GroupId = Schema.Schema.Type<typeof groupIdSchema>;

Expand All @@ -101,13 +95,9 @@ export const groupSchema = Schema.Struct({
});
export type Group = Schema.Schema.Type<typeof groupSchema>;

// --- Handle Key ---

export const handleKeySchema = Schema.String;
export type HandleKey = Schema.Schema.Type<typeof handleKeySchema>;

// --- Black (080 수신거부) ---

export const blackSchema = Schema.Struct({
handleKey: handleKeySchema,
type: Schema.Literal('DENIAL'),
Expand All @@ -118,8 +108,6 @@ export const blackSchema = Schema.Struct({
});
export type Black = Schema.Schema.Type<typeof blackSchema>;

// --- Block Group ---

export const blockGroupSchema = Schema.Struct({
blockGroupId: Schema.String,
accountId: Schema.String,
Expand All @@ -132,8 +120,6 @@ export const blockGroupSchema = Schema.Struct({
});
export type BlockGroup = Schema.Schema.Type<typeof blockGroupSchema>;

// --- Block Number ---

export const blockNumberSchema = Schema.Struct({
blockNumberId: Schema.String,
accountId: Schema.String,
Expand Down
Loading