@rscc/common-core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -23
- package/dist/index.cjs +50 -9
- package/dist/index.d.cts +69 -29
- package/dist/index.d.ts +69 -29
- package/dist/index.js +49 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @rscc/common-core
|
|
2
2
|
|
|
3
|
-
RSCC 공통 코어 — `CommonResponse` 봉투
|
|
3
|
+
RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키), SSE 파서,
|
|
4
|
+
회복탄력성 유틸(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안 유틸(마스킹·로그 리댁션·
|
|
5
|
+
웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 한국 도메인 유틸.
|
|
6
|
+
**프레임워크 무관, 런타임 의존성 0.** 같은 라이브러리의 Java·Python 구현과 동일한 와이어 계약·공유
|
|
7
|
+
테스트 벡터로 시맨틱을 맞춘다.
|
|
4
8
|
|
|
5
9
|
## 설치
|
|
6
10
|
|
|
@@ -8,43 +12,107 @@ RSCC 공통 코어 — `CommonResponse` 봉투 타입, API 클라이언트 팩
|
|
|
8
12
|
npm i @rscc/common-core
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
- **Node >= 18** (전역 `fetch` / `Headers` / `ReadableStream` 전제)
|
|
15
|
+
- **Node >= 18** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
|
|
12
16
|
- ESM + CJS 듀얼 빌드, 타입 선언(`.d.ts` / `.d.cts`) 동봉, `sideEffects: false`
|
|
17
|
+
- 메인 엔트리 `@rscc/common-core` 는 브라우저 안전. Node 전용 기능은 서브패스 `@rscc/common-core/crypto` 로만 제공
|
|
13
18
|
|
|
14
|
-
##
|
|
19
|
+
## 모듈 한눈에
|
|
15
20
|
|
|
16
21
|
| 모듈 | 공개 API | 설명 |
|
|
17
22
|
|---|---|---|
|
|
18
|
-
| types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` | 응답
|
|
19
|
-
| apiClient | `createApiClient
|
|
20
|
-
| sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 —
|
|
23
|
+
| types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` · `FieldErrorDetail` · `ValidationErrorData` · `isValidationErrorData` | 응답 봉투 · 페이지(page 0 시작) · 결과 코드(값은 문자열) · 검증 오류 봉투 타입 가드 |
|
|
24
|
+
| apiClient | `createApiClient` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 |
|
|
25
|
+
| sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크·멀티바이트 경계 안전, 모르는 키는 skip |
|
|
21
26
|
| jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
|
|
22
|
-
| masking | `maskSecret`
|
|
27
|
+
| masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
|
|
28
|
+
| datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` | 오프셋 없는 LocalDateTime 와이어 형식 — `toISOString()`(Z) 의 9시간 스큐 차단 |
|
|
29
|
+
| chosung | `toChosung` · `isChosungQuery` | 한글 초성 변환·초성 질의 판별 (자동완성) |
|
|
30
|
+
| retry | `retry` · `isRetryableStatus` · `parseRetryAfterMs` · `RetryOptions` | 지수 백오프 + full jitter, 시간 예산, `Retry-After` 파싱 |
|
|
31
|
+
| cache | `createTtlCache` · `TtlCache<K,V>` · `TtlCacheOptions` | TTL 캐시 + single-flight(동일 키 로더 1회 공유), lazy expiry |
|
|
32
|
+
| circuitBreaker | `createCircuitBreaker` · `CircuitOpenError` · `CircuitBreaker` · `CircuitBreakerOptions` · `CircuitState` | 연속 실패 서킷 브레이커 — `execute(fn)` 권장, 취소는 무집계(`onIgnore`) |
|
|
33
|
+
| tokenBucket | `createTokenBucket` · `TokenBucket` · `TokenBucketOptions` | 토큰버킷 레이트리미터 (연속 리필, 대기 없이 즉시 판정) |
|
|
34
|
+
| bulkhead | `createBulkhead` · `BulkheadFullError` · `Bulkhead` · `BulkheadOptions` | 동시 실행 + 대기 슬롯 격벽, 만석 즉시 거부 |
|
|
35
|
+
| logSanitize | `sanitizeLogValue` | 로그 인젝션 방지 — 제어 문자 공백 치환 + 길이 절단 |
|
|
36
|
+
| bizno | `normalizeBusinessNumber` · `isValidBusinessNumber` · `isValidCorporateNumber` | 사업자(10)·법인(13)번호 체크섬 검증 |
|
|
37
|
+
| idempotency | `generateIdempotencyKey` | 멱등성 키 생성 (apiClient `idempotency` 옵션으로 자동 부착 가능) |
|
|
38
|
+
| webhook | `signWebhook` · `verifyWebhook` · `WEBHOOK_SIGNATURE_HEADER` | HMAC-SHA256 웹훅 서명/검증 (WebCrypto — async), 시크릿 로테이션 |
|
|
39
|
+
| bulk | `isBulkResult` · `bulkFailures` · `createBulkResultBuilder` · `BulkResult` · `BulkResultItem` · `BulkResultBuilder` | 벌크/부분 실패 봉투 판별·생성 |
|
|
40
|
+
| upload | `sniffFile` · `kindsForExtension` · `validateUpload` · `FileKind` · `UploadValidationResult` | 업로드 프리검증 — 크기·확장자·매직바이트 대조 |
|
|
41
|
+
| query | `buildListQuery` · `ListQueryOptions` · `SortParam` | 목록 조회 쿼리(page/size/sort/필터) 빌더 |
|
|
42
|
+
| featureFlags | `parseFlag` · `createFeatureFlags` · `FeatureFlagReader` | 피처 플래그 파싱 (참 집합 `true`/`1`/`on`/`yes`) |
|
|
43
|
+
| rrn | `normalizeRrn` · `isValidRrn` · `isForeignerRrn` · `rrnChecksumOkLegacy` · `rrnBirthDate` | 주민등록번호 형식·생년월일 검증 (기본 검증은 **체크섬 미포함**) |
|
|
44
|
+
| josa | `pickJosa` · `attachJosa` · `JosaPair` | 은/는·이/가 등 조사 자동 선택 |
|
|
45
|
+
| age | `ageMan` · `ageByYear` · `ageInsurance` | 만 나이·연 나이·보험 나이 (입력 `"YYYY-MM-DD"` 문자열) |
|
|
46
|
+
| phone | `normalizePhoneNumber` · `classifyPhoneNumber` · `formatPhoneNumber` · `toE164` · `PhoneType` | 전화번호 정규화·분류·포맷·E.164 변환 |
|
|
47
|
+
| money | `toKoreanWords` · `toFormalNotation` · `abbreviateAmount` | 금액 한글 수사 · 공문서 표기(`금…원整`) · UI 약식(`1.2억`) |
|
|
48
|
+
| businessDays | `createBusinessDays` · `BusinessDays` · `BusinessDaysOptions` | 영업일 계산 (공휴일 주입형 — 내장 공휴일 없음) |
|
|
49
|
+
| jamo | `decomposeHangul` · `composeHangul` · `matchesHangul` | 두벌식 자모 분해·조합·혼합 질의 매칭 |
|
|
50
|
+
| **crypto (서브패스)** | `encryptAesGcm` · `decryptAesGcm` · `CryptoError` — `@rscc/common-core/crypto` | AES-256/GCM (Java/Python 와이어 호환). **Node 전용** (`node:crypto`) |
|
|
23
51
|
|
|
24
52
|
## 사용 예시
|
|
25
53
|
|
|
26
54
|
### createApiClient — CommonResponse 언랩 + traceId
|
|
27
55
|
|
|
28
56
|
```ts
|
|
29
|
-
import { createApiClient, ApiError } from "@rscc/common-core";
|
|
57
|
+
import { createApiClient, ApiError, isValidationErrorData } from "@rscc/common-core";
|
|
30
58
|
|
|
31
59
|
const api = createApiClient({
|
|
32
|
-
baseUrl: "https://api.example.com",
|
|
33
|
-
getToken: () =>
|
|
60
|
+
baseUrl: "https://api.example.com", // 끝 슬래시 유무 무관 — 경계 슬래시 자동 단일화
|
|
61
|
+
getToken: () => sessionStorage.getItem("token"), // 선택 — Authorization: Bearer 부착
|
|
34
62
|
onUnauthorized: () => { /* 로그아웃/리다이렉트 정책은 소비자가 결정 */ },
|
|
63
|
+
// 선택: retry: { retries: 3 }, idempotency: {}, strictJson: true, onRequest/onResponse/onError
|
|
35
64
|
});
|
|
36
65
|
|
|
37
|
-
// 성공 봉투 → data 언랩
|
|
66
|
+
// 성공 봉투 → data 언랩 (비봉투 JSON 은 그대로)
|
|
38
67
|
const user = await api.request<UserInfo>("/api/v1/users/me");
|
|
39
68
|
|
|
40
|
-
//
|
|
69
|
+
// 문자열 body → Content-Type: application/json 자동 부착
|
|
70
|
+
await api.request("/api/v1/items", { method: "POST", body: JSON.stringify(item) });
|
|
71
|
+
|
|
72
|
+
// 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId, data, retryAfterMs }
|
|
41
73
|
try {
|
|
42
74
|
await api.request("/api/v1/things/999");
|
|
43
75
|
} catch (e) {
|
|
44
|
-
if (e instanceof ApiError)
|
|
76
|
+
if (e instanceof ApiError) {
|
|
77
|
+
console.error(`[${e.traceId}] ${e.code}: ${e.message}`);
|
|
78
|
+
if (isValidationErrorData(e.data)) { /* e.data.errors — 필드별 검증 오류 */ }
|
|
79
|
+
}
|
|
45
80
|
}
|
|
81
|
+
|
|
82
|
+
// traceId·status·원본 Response 가 필요하면
|
|
83
|
+
const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1/users/me");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **Content-Type**: 호출자가 지정하지 않았고 body 가 **문자열**일 때만 `application/json` 을 붙인다.
|
|
87
|
+
body 없는 요청(GET 등)엔 붙이지 않으므로 교차 출처 GET 이 CORS preflight 를 유발하지 않는다.
|
|
88
|
+
`URLSearchParams`/`Blob`/`FormData`/`ArrayBuffer`/`ReadableStream` 은 fetch 기본값을 따른다.
|
|
89
|
+
- **traceId**: 논리 호출당 1회 생성해 `X-Trace-Id` 로 부착(재시도 전 시도 동일), 응답 에코를 우선 노출.
|
|
90
|
+
- **retry** (opt-in): 멱등 메서드(GET/HEAD/OPTIONS/PUT/DELETE)만 기본 재시도, 408/429/5xx 일부·네트워크
|
|
91
|
+
오류 대상, `Retry-After` 를 대기 하한으로 존중. POST/PATCH 는 `methods` 로 명시 옵트인.
|
|
92
|
+
- **idempotency** (opt-in): POST/PATCH 에 `Idempotency-Key` 를 논리 호출당 1회 생성해 부착(호출자 헤더 우선).
|
|
93
|
+
- **strictJson** (opt-in): 2xx 의 비-JSON 본문을 null 대신 `INVALID_JSON` ApiError 로 실패(`rawText` 앞 2048자
|
|
94
|
+
보존). 빈 본문(204 등)은 계속 null.
|
|
95
|
+
- `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
|
|
96
|
+
재호출이 가능하다.
|
|
97
|
+
|
|
98
|
+
### 서킷 브레이커 — execute 권장
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createCircuitBreaker, CircuitOpenError, retry } from "@rscc/common-core";
|
|
102
|
+
|
|
103
|
+
const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
104
|
+
|
|
105
|
+
// 게이트 → 실행 → 결과 보고를 정확히 1회 보장. OPEN 이면 fn 미호출 + CircuitOpenError
|
|
106
|
+
await retry(() => cb.execute(() => api.request("/api/v1/downstream")), {
|
|
107
|
+
shouldRetry: (e) => !(e instanceof CircuitOpenError), // OPEN 차단은 즉시 실패
|
|
108
|
+
});
|
|
46
109
|
```
|
|
47
110
|
|
|
111
|
+
- resolve → 성공, `name === "AbortError"` → 무집계(`onIgnore`), 그 외 throw → 실패로 집계 후 **같은 오류를 그대로** rethrow.
|
|
112
|
+
- 수동 게이트(`allowRequest()` + `onSuccess`/`onFailure`/`onIgnore` 중 정확히 1회)도 지원한다.
|
|
113
|
+
- 보고가 끝내 오지 않은 그랜트는 OPEN 진입 후 `2 × openDurationMs` 에 유실로 용서되어 OPEN 영구 고착을 막는다.
|
|
114
|
+
HALF_OPEN 프로브도 `openDurationMs` 동안 미보고면 유실로 간주해 새 프로브를 넘긴다(HALF_OPEN 고착 방지).
|
|
115
|
+
|
|
48
116
|
### SSE 스트림 소비
|
|
49
117
|
|
|
50
118
|
```ts
|
|
@@ -52,24 +120,38 @@ import { readSseStream } from "@rscc/common-core";
|
|
|
52
120
|
|
|
53
121
|
const res = await fetch(streamUrl, { method: "POST", body, signal });
|
|
54
122
|
await readSseStream(res, {
|
|
55
|
-
|
|
123
|
+
onConversationId: (id) => { /* 첫 프레임 */ },
|
|
56
124
|
onSources: (sources) => { /* RAG 근거 */ },
|
|
125
|
+
onDelta: (chunk) => { /* 텍스트 증분 */ },
|
|
57
126
|
onError: (message) => { /* in-band 오류 */ },
|
|
58
127
|
onDone: () => { /* data: [DONE] */ },
|
|
59
128
|
});
|
|
60
129
|
```
|
|
61
130
|
|
|
131
|
+
### AES-256/GCM — Node 전용 서브패스
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { encryptAesGcm, decryptAesGcm, CryptoError } from "@rscc/common-core/crypto";
|
|
135
|
+
|
|
136
|
+
const enc = encryptAesGcm("민감값", key); // Base64(IV(12B)∥암호문∥태그(16B)) — 매번 다름
|
|
137
|
+
const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
|
|
138
|
+
```
|
|
139
|
+
|
|
62
140
|
## 알려진 제약
|
|
63
141
|
|
|
64
|
-
- SSE 파서는 프레임 구분자
|
|
65
|
-
- `
|
|
66
|
-
-
|
|
67
|
-
-
|
|
142
|
+
- **SSE 파서는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
|
|
143
|
+
- 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 기본적으로 조용히 null 이 된다 — 명시 실패가 필요하면 `strictJson: true`.
|
|
144
|
+
- 문자열 body 는 `application/json` 으로 간주된다 — JSON 이 아닌 문자열 본문은 Content-Type 을 직접 지정할 것.
|
|
145
|
+
- 교차 출처에서 traceId 에코를 읽으려면 서버가 `Access-Control-Expose-Headers: X-Trace-Id` 를 내려야 한다
|
|
146
|
+
(못 읽으면 발신 값을 노출 — 서버가 수신 traceId 를 재사용하므로 동일 값).
|
|
147
|
+
- 1회성 body(`ReadableStream`/`FormData`)는 재전송이 불가해 `retry` 를 지정해도 1회만 실행된다.
|
|
68
148
|
- JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
|
|
149
|
+
- `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
|
|
150
|
+
- `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
|
|
151
|
+
- 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
|
|
152
|
+
- `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
|
|
69
153
|
|
|
70
|
-
##
|
|
154
|
+
## 관련 패키지
|
|
71
155
|
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
- React 훅: [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
75
|
-
- 저장소: [Jeonghyeon-Ryu/r-common](https://github.com/Jeonghyeon-Ryu/r-common) · MIT
|
|
156
|
+
- React 훅(`useDebounce`, `useSse`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
157
|
+
- 라이선스: MIT
|
package/dist/index.cjs
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
ApiError: () => ApiError,
|
|
24
24
|
BulkheadFullError: () => BulkheadFullError,
|
|
25
|
+
CircuitOpenError: () => CircuitOpenError,
|
|
25
26
|
ResultCode: () => ResultCode,
|
|
26
27
|
WEBHOOK_SIGNATURE_HEADER: () => WEBHOOK_SIGNATURE_HEADER,
|
|
27
28
|
abbreviateAmount: () => abbreviateAmount,
|
|
@@ -262,8 +263,7 @@ function createApiClient(config) {
|
|
|
262
263
|
if (idempotencyKey !== null && !headers.has(idempotencyHeader)) {
|
|
263
264
|
headers.set(idempotencyHeader, idempotencyKey);
|
|
264
265
|
}
|
|
265
|
-
|
|
266
|
-
if (!headers.has("Content-Type") && !isFormData) {
|
|
266
|
+
if (!headers.has("Content-Type") && typeof init.body === "string") {
|
|
267
267
|
headers.set("Content-Type", "application/json");
|
|
268
268
|
}
|
|
269
269
|
if (config.getToken && !headers.has("Authorization")) {
|
|
@@ -685,6 +685,12 @@ function createTtlCache(options) {
|
|
|
685
685
|
}
|
|
686
686
|
|
|
687
687
|
// src/circuitBreaker.ts
|
|
688
|
+
var CircuitOpenError = class extends Error {
|
|
689
|
+
constructor(message = "\uC11C\uD0B7 OPEN \u2014 \uC694\uCCAD \uCC28\uB2E8") {
|
|
690
|
+
super(message);
|
|
691
|
+
this.name = "CircuitOpenError";
|
|
692
|
+
}
|
|
693
|
+
};
|
|
688
694
|
function createCircuitBreaker(options) {
|
|
689
695
|
const { failureThreshold, openDurationMs, now = Date.now } = options;
|
|
690
696
|
if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) {
|
|
@@ -697,6 +703,7 @@ function createCircuitBreaker(options) {
|
|
|
697
703
|
let consecutiveFailures = 0;
|
|
698
704
|
let openedAtMs = 0;
|
|
699
705
|
let probeInFlight = false;
|
|
706
|
+
let probeStartedAtMs = 0;
|
|
700
707
|
let inFlightGrants = 0;
|
|
701
708
|
function allowRequest() {
|
|
702
709
|
if (state === "CLOSED") {
|
|
@@ -704,18 +711,24 @@ function createCircuitBreaker(options) {
|
|
|
704
711
|
return true;
|
|
705
712
|
}
|
|
706
713
|
if (state === "OPEN") {
|
|
707
|
-
|
|
708
|
-
|
|
714
|
+
const nowMs2 = now();
|
|
715
|
+
const elapsedMs = nowMs2 - openedAtMs;
|
|
716
|
+
if (elapsedMs < openDurationMs) return false;
|
|
717
|
+
if (inFlightGrants > 0) {
|
|
718
|
+
if (elapsedMs < 2 * openDurationMs) {
|
|
709
719
|
return false;
|
|
710
720
|
}
|
|
711
|
-
|
|
712
|
-
probeInFlight = true;
|
|
713
|
-
return true;
|
|
721
|
+
inFlightGrants = 0;
|
|
714
722
|
}
|
|
715
|
-
|
|
723
|
+
state = "HALF_OPEN";
|
|
724
|
+
probeInFlight = true;
|
|
725
|
+
probeStartedAtMs = nowMs2;
|
|
726
|
+
return true;
|
|
716
727
|
}
|
|
717
|
-
|
|
728
|
+
const nowMs = now();
|
|
729
|
+
if (probeInFlight && nowMs - probeStartedAtMs < openDurationMs) return false;
|
|
718
730
|
probeInFlight = true;
|
|
731
|
+
probeStartedAtMs = nowMs;
|
|
719
732
|
return true;
|
|
720
733
|
}
|
|
721
734
|
function onSuccess() {
|
|
@@ -750,13 +763,40 @@ function createCircuitBreaker(options) {
|
|
|
750
763
|
}
|
|
751
764
|
inFlightGrants = Math.max(0, inFlightGrants - 1);
|
|
752
765
|
}
|
|
766
|
+
function onIgnore() {
|
|
767
|
+
if (state === "HALF_OPEN") {
|
|
768
|
+
probeInFlight = false;
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
inFlightGrants = Math.max(0, inFlightGrants - 1);
|
|
772
|
+
}
|
|
773
|
+
async function execute(fn) {
|
|
774
|
+
if (!allowRequest()) {
|
|
775
|
+
throw new CircuitOpenError();
|
|
776
|
+
}
|
|
777
|
+
let result;
|
|
778
|
+
try {
|
|
779
|
+
result = await fn();
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (isAbortError(error)) onIgnore();
|
|
782
|
+
else onFailure();
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
onSuccess();
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
753
788
|
return {
|
|
754
789
|
allowRequest,
|
|
755
790
|
onSuccess,
|
|
756
791
|
onFailure,
|
|
792
|
+
onIgnore,
|
|
793
|
+
execute,
|
|
757
794
|
state: () => state
|
|
758
795
|
};
|
|
759
796
|
}
|
|
797
|
+
function isAbortError(error) {
|
|
798
|
+
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
799
|
+
}
|
|
760
800
|
|
|
761
801
|
// src/tokenBucket.ts
|
|
762
802
|
function createTokenBucket(options) {
|
|
@@ -1738,6 +1778,7 @@ function matchesHangul(query, target) {
|
|
|
1738
1778
|
0 && (module.exports = {
|
|
1739
1779
|
ApiError,
|
|
1740
1780
|
BulkheadFullError,
|
|
1781
|
+
CircuitOpenError,
|
|
1741
1782
|
ResultCode,
|
|
1742
1783
|
WEBHOOK_SIGNATURE_HEADER,
|
|
1743
1784
|
abbreviateAmount,
|
package/dist/index.d.cts
CHANGED
|
@@ -315,6 +315,11 @@ interface ApiClient {
|
|
|
315
315
|
*
|
|
316
316
|
* 동작 (contracts/common-response.schema.json, trace.md):
|
|
317
317
|
* - 매 요청에 traceId 를 생성해 traceIdHeader(기본 X-Trace-Id)로 부착한다.
|
|
318
|
+
* - `Content-Type: application/json` 은 호출자가 Content-Type 을 지정하지 않았고 body 가
|
|
319
|
+
* **문자열**일 때만 기본 부착한다 (JSON.stringify 관용). body 없는 요청(GET 등)엔 붙이지
|
|
320
|
+
* 않아 교차 출처 GET 의 CORS preflight 를 유발하지 않으며, URLSearchParams/Blob/FormData/
|
|
321
|
+
* ArrayBuffer/ReadableStream 등은 fetch 기본값에 맡긴다. 문자열이지만 JSON 이 아닌 본문은
|
|
322
|
+
* Content-Type 을 직접 지정할 것.
|
|
318
323
|
* - 응답이 CommonResponse 봉투면 success 검사 후 data 를 언랩한다
|
|
319
324
|
* (data 부재 시 undefined). 비봉투 JSON 은 그대로 반환.
|
|
320
325
|
* - 실패 봉투 / HTTP 에러는 code·message·traceId 를 담은 ApiError 를 throw.
|
|
@@ -575,9 +580,13 @@ declare function createTtlCache<K, V>(options: TtlCacheOptions): TtlCache<K, V>;
|
|
|
575
580
|
* 경량 서킷 브레이커. 런타임 의존성 0 (native 만).
|
|
576
581
|
*
|
|
577
582
|
* Java `com.rscc.common.util.CircuitBreaker` / Python `rscc_common.circuit_breaker` 와
|
|
578
|
-
* 시맨틱 동일 (3언어 공유 벡터 CB-01~
|
|
583
|
+
* 시맨틱 동일 (3언어 공유 벡터 CB-01~13 — 각 언어 테스트에 수동 동기화. Python 은
|
|
579
584
|
* 시간 단위가 초 — `초 × 1000 == ms`).
|
|
580
585
|
*
|
|
586
|
+
* - **권장 사용법 — `execute(fn)`**: 게이트(allowRequest) → 실행 → 결과 보고를 한 번에
|
|
587
|
+
* 처리해 보고를 **정확히 1회** 보장한다. 차단 시 {@link CircuitOpenError} 로 reject
|
|
588
|
+
* (fn 미호출), 취소(`name === "AbortError"`)는 성공·실패 어느 쪽에도 집계하지 않는다
|
|
589
|
+
* (onIgnore — CB-12). 수동 게이트(allowRequest + 보고)는 execute 로 감쌀 수 없는 경우에만.
|
|
581
590
|
* - **연속(consecutive) 실패 카운트**: `onFailure()` 가 failureThreshold 에 도달(`>=`)하면
|
|
582
591
|
* OPEN. `onSuccess()` 는 카운터를 0 으로 리셋한다 (슬라이딩 윈도 비채택 — 단순성).
|
|
583
592
|
* - **lazy 전이**: OPEN → HALF_OPEN 전이는 `allowRequest()` 호출 시점에 판정한다
|
|
@@ -586,38 +595,51 @@ declare function createTtlCache<K, V>(options: TtlCacheOptions): TtlCache<K, V>;
|
|
|
586
595
|
* - **HALF_OPEN 프로브 1개**: 프로브가 진행 중이면 나머지 `allowRequest()` 는 false.
|
|
587
596
|
* 프로브 성공 → CLOSED(카운터 0), 프로브 실패 → OPEN 재진입(openedAt 갱신 —
|
|
588
597
|
* 전체 openDurationMs 재대기).
|
|
589
|
-
* - **결과 보고 규율**: `allowRequest()==true` 를 받은 호출만 `onSuccess()`/`onFailure()
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* 바꾸지 않고(no-op — CB-08) 미보고 그랜트 잔량만
|
|
598
|
+
* - **결과 보고 규율**: `allowRequest()==true` 를 받은 호출만 `onSuccess()`/`onFailure()`/
|
|
599
|
+
* `onIgnore()` 중 하나를 **정확히 1회** 호출한다 (수동 게이트라면 try/catch 로 보고 보장 —
|
|
600
|
+
* 성공·실패로 판정할 수 없는 취소 등은 `onIgnore()` 로 그랜트만 반납). OPEN 대기 중 늦게
|
|
601
|
+
* 도착한 보고는 상태·openedAt·카운터를 바꾸지 않고(no-op — CB-08) 미보고 그랜트 잔량만
|
|
602
|
+
* 소진한다.
|
|
593
603
|
* - **스테일 그랜트 격리**: CLOSED 시절 그랜트가 아직 보고되지 않은 동안에는
|
|
594
604
|
* openDurationMs 가 경과해도 HALF_OPEN 전이(프로브)가 보류된다 — 스테일 보고가 프로브
|
|
595
|
-
* 결과로 오인되는 것을 막아 HALF_OPEN 중 도착하는 보고는
|
|
605
|
+
* 결과로 오인되는 것을 막아 HALF_OPEN 중 도착하는 보고는 (아래 용서 이후의 매우 늦은
|
|
606
|
+
* 보고를 제외하면) 항상 프로브의 것이다 (CB-09).
|
|
607
|
+
* - **유실 그랜트 용서 (2× 규칙)**: 보고가 끝내 오지 않는 그랜트(보고 누락·유실)가 위 보류를
|
|
608
|
+
* 무기한 지속시켜 서킷이 OPEN 에 영구 고착되는 것을 막는다 — OPEN 진입 후
|
|
609
|
+
* `2 × openDurationMs` 경과 시점에도 미보고 그랜트가 남아 있으면 이를 유실로 간주해 0 으로
|
|
610
|
+
* 용서하고 정상 HALF_OPEN 전이 + 프로브 획득을 수행한다 (CB-10). 용서 뒤 매우 늦은 스테일
|
|
611
|
+
* 보고가 HALF_OPEN 중 도착하면 프로브 결과로 취급되지만 무해하다 — 스테일 성공은 하류가
|
|
612
|
+
* 실제로 응답했다는 증거이고, 스테일 실패는 보수적인 OPEN 재진입일 뿐이다 (스테일
|
|
613
|
+
* onIgnore 는 프로브 슬롯을 조기 해제해 프로브가 1개 더 허가될 뿐 — 최초 보고가 판정).
|
|
614
|
+
* - **유실 프로브 용서**: HALF_OPEN 프로브가 openDurationMs(`>=`) 동안 보고되지 않으면 유실로
|
|
615
|
+
* 간주해 다음 allowRequest() 에 새 프로브를 넘긴다 — 보고 누락 프로브 1개로 HALF_OPEN 에 영구
|
|
616
|
+
* 고착되는 것을 막는다 (CB-13). 옛 프로브의 늦은 보고는 새 프로브 결과로 취급되며 위와 같은
|
|
617
|
+
* 이유로 무해하다.
|
|
618
|
+
*
|
|
619
|
+
* @example 권장 — execute 로 게이트·보고를 위임한다.
|
|
620
|
+
* ```ts
|
|
621
|
+
* const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
622
|
+
* const user = await cb.execute(() => api.request<User>("/users/1")); // OPEN 이면 CircuitOpenError
|
|
623
|
+
* ```
|
|
596
624
|
*
|
|
597
|
-
* @example 서킷 + 재시도 조합 —
|
|
598
|
-
*
|
|
625
|
+
* @example 서킷 + 재시도 조합 — 시도마다 execute 로 게이트·보고. OPEN 차단
|
|
626
|
+
* ({@link CircuitOpenError})은 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
|
|
599
627
|
* ```ts
|
|
600
628
|
* const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
* try {
|
|
606
|
-
* const r = await call();
|
|
607
|
-
* cb.onSuccess();
|
|
608
|
-
* return r;
|
|
609
|
-
* } catch (error) {
|
|
610
|
-
* cb.onFailure();
|
|
611
|
-
* throw error;
|
|
612
|
-
* }
|
|
613
|
-
* },
|
|
614
|
-
* {
|
|
615
|
-
* shouldRetry: (error) => error !== circuitOpen, // OPEN 차단은 즉시 실패
|
|
616
|
-
* onRetry: (error, attempt, delayMs) => console.warn(`재시도 ${attempt} (${delayMs}ms)`),
|
|
617
|
-
* }
|
|
618
|
-
* );
|
|
629
|
+
* await retry(() => cb.execute(() => call()), {
|
|
630
|
+
* shouldRetry: (error) => !(error instanceof CircuitOpenError), // OPEN 차단은 즉시 실패
|
|
631
|
+
* onRetry: (error, attempt, delayMs) => console.warn(`재시도 ${attempt} (${delayMs}ms)`),
|
|
632
|
+
* });
|
|
619
633
|
* ```
|
|
620
634
|
*/
|
|
635
|
+
/**
|
|
636
|
+
* 서킷 차단 거부 오류 — `execute()` 가 allowRequest()==false(OPEN 대기·프로브 보류·HALF_OPEN
|
|
637
|
+
* 프로브 경합)일 때 reject 하며, 이때 작업 함수는 **호출되지 않는다** (CB-12).
|
|
638
|
+
* Java `CircuitOpenException` / Python `CircuitOpenError` 패리티. 재시도 조합에서 비재시도 신호로 쓴다.
|
|
639
|
+
*/
|
|
640
|
+
declare class CircuitOpenError extends Error {
|
|
641
|
+
constructor(message?: string);
|
|
642
|
+
}
|
|
621
643
|
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
622
644
|
interface CircuitBreakerOptions {
|
|
623
645
|
/** OPEN 전이 임계 — 연속 실패가 이 값에 도달(`>=`)하면 OPEN. 필수 양의 정수 (아니면 RangeError). */
|
|
@@ -631,14 +653,32 @@ interface CircuitBreaker {
|
|
|
631
653
|
/**
|
|
632
654
|
* 요청 통과 여부 — CLOSED 는 true, OPEN 대기 중 false, openDurationMs 경과 시
|
|
633
655
|
* HALF_OPEN 전이 + 프로브 슬롯 획득(true) — 단 CLOSED 시절 미보고 그랜트가 남아 있으면
|
|
634
|
-
* 전이를 보류하고 false (CB-09)
|
|
635
|
-
*
|
|
656
|
+
* 전이를 보류하고 false (CB-09), 보류는 OPEN 진입 후 `2 × openDurationMs` 까지만 —
|
|
657
|
+
* 그 시점엔 미보고 그랜트를 유실로 용서하고 전이 + 프로브 획득(true) (CB-10).
|
|
658
|
+
* HALF_OPEN 프로브 경합 시 나머지 false — 단 프로브가 openDurationMs 경과(`>=`)까지
|
|
659
|
+
* 미보고면 유실로 간주해 이 호출에 새 프로브를 넘긴다(true) (CB-13).
|
|
660
|
+
* true 를 받은 호출만 결과를 onSuccess()/onFailure()/onIgnore() 중 하나로 보고해야 한다.
|
|
636
661
|
*/
|
|
637
662
|
allowRequest(): boolean;
|
|
638
663
|
/** 성공 보고 — CLOSED 카운터 리셋, HALF_OPEN 프로브 성공 시 CLOSED 복귀. OPEN 중엔 무시. */
|
|
639
664
|
onSuccess(): void;
|
|
640
665
|
/** 실패 보고 — CLOSED 연속 카운트 증가(임계 도달 시 OPEN), HALF_OPEN 프로브 실패 시 OPEN 재진입. OPEN 중엔 무시. */
|
|
641
666
|
onFailure(): void;
|
|
667
|
+
/**
|
|
668
|
+
* 결과 없는 그랜트 반납 — 취소(abort) 등 성공·실패로 판정할 수 없는 호출이 보고 대신
|
|
669
|
+
* 호출한다 (CB-11). CLOSED: 미보고 그랜트만 반납(연속 실패 카운터 무변화). HALF_OPEN:
|
|
670
|
+
* 프로브 슬롯 해제 — HALF_OPEN 유지, 다음 allowRequest() 가 새 프로브를 획득한다.
|
|
671
|
+
* OPEN: 미보고 그랜트 잔량만 소진(그 외 무변화).
|
|
672
|
+
*/
|
|
673
|
+
onIgnore(): void;
|
|
674
|
+
/**
|
|
675
|
+
* fn 래핑 (권장 사용법) — allowRequest() 게이트 → 실행 → 결과 보고를 **정확히 1회** 보장한다.
|
|
676
|
+
* 거부는 reject(CircuitOpenError) — 동기 throw 아님, 거부 시 fn 은 호출되지 않는다.
|
|
677
|
+
* resolve → onSuccess() 후 값 반환. throw/reject 는 `name === "AbortError"` 면 onIgnore(),
|
|
678
|
+
* 그 외(TimeoutError 포함)는 onFailure() 후 **같은 값을 래핑 없이** 다시 throw 한다 (CB-12).
|
|
679
|
+
* fn 의 동기 throw 도 동일하게 처리된다.
|
|
680
|
+
*/
|
|
681
|
+
execute<T>(fn: () => T | PromiseLike<T>): Promise<T>;
|
|
642
682
|
/** 현재 상태 조회 — lazy 전이 특성상 openDurationMs 경과 후에도 allowRequest() 전엔 OPEN 으로 보고된다. */
|
|
643
683
|
state(): CircuitState;
|
|
644
684
|
}
|
|
@@ -1307,4 +1347,4 @@ declare function composeHangul(s: string): string;
|
|
|
1307
1347
|
*/
|
|
1308
1348
|
declare function matchesHangul(query: string, target: string): boolean;
|
|
1309
1349
|
|
|
1310
|
-
export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
|
|
1350
|
+
export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
|
package/dist/index.d.ts
CHANGED
|
@@ -315,6 +315,11 @@ interface ApiClient {
|
|
|
315
315
|
*
|
|
316
316
|
* 동작 (contracts/common-response.schema.json, trace.md):
|
|
317
317
|
* - 매 요청에 traceId 를 생성해 traceIdHeader(기본 X-Trace-Id)로 부착한다.
|
|
318
|
+
* - `Content-Type: application/json` 은 호출자가 Content-Type 을 지정하지 않았고 body 가
|
|
319
|
+
* **문자열**일 때만 기본 부착한다 (JSON.stringify 관용). body 없는 요청(GET 등)엔 붙이지
|
|
320
|
+
* 않아 교차 출처 GET 의 CORS preflight 를 유발하지 않으며, URLSearchParams/Blob/FormData/
|
|
321
|
+
* ArrayBuffer/ReadableStream 등은 fetch 기본값에 맡긴다. 문자열이지만 JSON 이 아닌 본문은
|
|
322
|
+
* Content-Type 을 직접 지정할 것.
|
|
318
323
|
* - 응답이 CommonResponse 봉투면 success 검사 후 data 를 언랩한다
|
|
319
324
|
* (data 부재 시 undefined). 비봉투 JSON 은 그대로 반환.
|
|
320
325
|
* - 실패 봉투 / HTTP 에러는 code·message·traceId 를 담은 ApiError 를 throw.
|
|
@@ -575,9 +580,13 @@ declare function createTtlCache<K, V>(options: TtlCacheOptions): TtlCache<K, V>;
|
|
|
575
580
|
* 경량 서킷 브레이커. 런타임 의존성 0 (native 만).
|
|
576
581
|
*
|
|
577
582
|
* Java `com.rscc.common.util.CircuitBreaker` / Python `rscc_common.circuit_breaker` 와
|
|
578
|
-
* 시맨틱 동일 (3언어 공유 벡터 CB-01~
|
|
583
|
+
* 시맨틱 동일 (3언어 공유 벡터 CB-01~13 — 각 언어 테스트에 수동 동기화. Python 은
|
|
579
584
|
* 시간 단위가 초 — `초 × 1000 == ms`).
|
|
580
585
|
*
|
|
586
|
+
* - **권장 사용법 — `execute(fn)`**: 게이트(allowRequest) → 실행 → 결과 보고를 한 번에
|
|
587
|
+
* 처리해 보고를 **정확히 1회** 보장한다. 차단 시 {@link CircuitOpenError} 로 reject
|
|
588
|
+
* (fn 미호출), 취소(`name === "AbortError"`)는 성공·실패 어느 쪽에도 집계하지 않는다
|
|
589
|
+
* (onIgnore — CB-12). 수동 게이트(allowRequest + 보고)는 execute 로 감쌀 수 없는 경우에만.
|
|
581
590
|
* - **연속(consecutive) 실패 카운트**: `onFailure()` 가 failureThreshold 에 도달(`>=`)하면
|
|
582
591
|
* OPEN. `onSuccess()` 는 카운터를 0 으로 리셋한다 (슬라이딩 윈도 비채택 — 단순성).
|
|
583
592
|
* - **lazy 전이**: OPEN → HALF_OPEN 전이는 `allowRequest()` 호출 시점에 판정한다
|
|
@@ -586,38 +595,51 @@ declare function createTtlCache<K, V>(options: TtlCacheOptions): TtlCache<K, V>;
|
|
|
586
595
|
* - **HALF_OPEN 프로브 1개**: 프로브가 진행 중이면 나머지 `allowRequest()` 는 false.
|
|
587
596
|
* 프로브 성공 → CLOSED(카운터 0), 프로브 실패 → OPEN 재진입(openedAt 갱신 —
|
|
588
597
|
* 전체 openDurationMs 재대기).
|
|
589
|
-
* - **결과 보고 규율**: `allowRequest()==true` 를 받은 호출만 `onSuccess()`/`onFailure()
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* 바꾸지 않고(no-op — CB-08) 미보고 그랜트 잔량만
|
|
598
|
+
* - **결과 보고 규율**: `allowRequest()==true` 를 받은 호출만 `onSuccess()`/`onFailure()`/
|
|
599
|
+
* `onIgnore()` 중 하나를 **정확히 1회** 호출한다 (수동 게이트라면 try/catch 로 보고 보장 —
|
|
600
|
+
* 성공·실패로 판정할 수 없는 취소 등은 `onIgnore()` 로 그랜트만 반납). OPEN 대기 중 늦게
|
|
601
|
+
* 도착한 보고는 상태·openedAt·카운터를 바꾸지 않고(no-op — CB-08) 미보고 그랜트 잔량만
|
|
602
|
+
* 소진한다.
|
|
593
603
|
* - **스테일 그랜트 격리**: CLOSED 시절 그랜트가 아직 보고되지 않은 동안에는
|
|
594
604
|
* openDurationMs 가 경과해도 HALF_OPEN 전이(프로브)가 보류된다 — 스테일 보고가 프로브
|
|
595
|
-
* 결과로 오인되는 것을 막아 HALF_OPEN 중 도착하는 보고는
|
|
605
|
+
* 결과로 오인되는 것을 막아 HALF_OPEN 중 도착하는 보고는 (아래 용서 이후의 매우 늦은
|
|
606
|
+
* 보고를 제외하면) 항상 프로브의 것이다 (CB-09).
|
|
607
|
+
* - **유실 그랜트 용서 (2× 규칙)**: 보고가 끝내 오지 않는 그랜트(보고 누락·유실)가 위 보류를
|
|
608
|
+
* 무기한 지속시켜 서킷이 OPEN 에 영구 고착되는 것을 막는다 — OPEN 진입 후
|
|
609
|
+
* `2 × openDurationMs` 경과 시점에도 미보고 그랜트가 남아 있으면 이를 유실로 간주해 0 으로
|
|
610
|
+
* 용서하고 정상 HALF_OPEN 전이 + 프로브 획득을 수행한다 (CB-10). 용서 뒤 매우 늦은 스테일
|
|
611
|
+
* 보고가 HALF_OPEN 중 도착하면 프로브 결과로 취급되지만 무해하다 — 스테일 성공은 하류가
|
|
612
|
+
* 실제로 응답했다는 증거이고, 스테일 실패는 보수적인 OPEN 재진입일 뿐이다 (스테일
|
|
613
|
+
* onIgnore 는 프로브 슬롯을 조기 해제해 프로브가 1개 더 허가될 뿐 — 최초 보고가 판정).
|
|
614
|
+
* - **유실 프로브 용서**: HALF_OPEN 프로브가 openDurationMs(`>=`) 동안 보고되지 않으면 유실로
|
|
615
|
+
* 간주해 다음 allowRequest() 에 새 프로브를 넘긴다 — 보고 누락 프로브 1개로 HALF_OPEN 에 영구
|
|
616
|
+
* 고착되는 것을 막는다 (CB-13). 옛 프로브의 늦은 보고는 새 프로브 결과로 취급되며 위와 같은
|
|
617
|
+
* 이유로 무해하다.
|
|
618
|
+
*
|
|
619
|
+
* @example 권장 — execute 로 게이트·보고를 위임한다.
|
|
620
|
+
* ```ts
|
|
621
|
+
* const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
622
|
+
* const user = await cb.execute(() => api.request<User>("/users/1")); // OPEN 이면 CircuitOpenError
|
|
623
|
+
* ```
|
|
596
624
|
*
|
|
597
|
-
* @example 서킷 + 재시도 조합 —
|
|
598
|
-
*
|
|
625
|
+
* @example 서킷 + 재시도 조합 — 시도마다 execute 로 게이트·보고. OPEN 차단
|
|
626
|
+
* ({@link CircuitOpenError})은 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
|
|
599
627
|
* ```ts
|
|
600
628
|
* const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
* try {
|
|
606
|
-
* const r = await call();
|
|
607
|
-
* cb.onSuccess();
|
|
608
|
-
* return r;
|
|
609
|
-
* } catch (error) {
|
|
610
|
-
* cb.onFailure();
|
|
611
|
-
* throw error;
|
|
612
|
-
* }
|
|
613
|
-
* },
|
|
614
|
-
* {
|
|
615
|
-
* shouldRetry: (error) => error !== circuitOpen, // OPEN 차단은 즉시 실패
|
|
616
|
-
* onRetry: (error, attempt, delayMs) => console.warn(`재시도 ${attempt} (${delayMs}ms)`),
|
|
617
|
-
* }
|
|
618
|
-
* );
|
|
629
|
+
* await retry(() => cb.execute(() => call()), {
|
|
630
|
+
* shouldRetry: (error) => !(error instanceof CircuitOpenError), // OPEN 차단은 즉시 실패
|
|
631
|
+
* onRetry: (error, attempt, delayMs) => console.warn(`재시도 ${attempt} (${delayMs}ms)`),
|
|
632
|
+
* });
|
|
619
633
|
* ```
|
|
620
634
|
*/
|
|
635
|
+
/**
|
|
636
|
+
* 서킷 차단 거부 오류 — `execute()` 가 allowRequest()==false(OPEN 대기·프로브 보류·HALF_OPEN
|
|
637
|
+
* 프로브 경합)일 때 reject 하며, 이때 작업 함수는 **호출되지 않는다** (CB-12).
|
|
638
|
+
* Java `CircuitOpenException` / Python `CircuitOpenError` 패리티. 재시도 조합에서 비재시도 신호로 쓴다.
|
|
639
|
+
*/
|
|
640
|
+
declare class CircuitOpenError extends Error {
|
|
641
|
+
constructor(message?: string);
|
|
642
|
+
}
|
|
621
643
|
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
622
644
|
interface CircuitBreakerOptions {
|
|
623
645
|
/** OPEN 전이 임계 — 연속 실패가 이 값에 도달(`>=`)하면 OPEN. 필수 양의 정수 (아니면 RangeError). */
|
|
@@ -631,14 +653,32 @@ interface CircuitBreaker {
|
|
|
631
653
|
/**
|
|
632
654
|
* 요청 통과 여부 — CLOSED 는 true, OPEN 대기 중 false, openDurationMs 경과 시
|
|
633
655
|
* HALF_OPEN 전이 + 프로브 슬롯 획득(true) — 단 CLOSED 시절 미보고 그랜트가 남아 있으면
|
|
634
|
-
* 전이를 보류하고 false (CB-09)
|
|
635
|
-
*
|
|
656
|
+
* 전이를 보류하고 false (CB-09), 보류는 OPEN 진입 후 `2 × openDurationMs` 까지만 —
|
|
657
|
+
* 그 시점엔 미보고 그랜트를 유실로 용서하고 전이 + 프로브 획득(true) (CB-10).
|
|
658
|
+
* HALF_OPEN 프로브 경합 시 나머지 false — 단 프로브가 openDurationMs 경과(`>=`)까지
|
|
659
|
+
* 미보고면 유실로 간주해 이 호출에 새 프로브를 넘긴다(true) (CB-13).
|
|
660
|
+
* true 를 받은 호출만 결과를 onSuccess()/onFailure()/onIgnore() 중 하나로 보고해야 한다.
|
|
636
661
|
*/
|
|
637
662
|
allowRequest(): boolean;
|
|
638
663
|
/** 성공 보고 — CLOSED 카운터 리셋, HALF_OPEN 프로브 성공 시 CLOSED 복귀. OPEN 중엔 무시. */
|
|
639
664
|
onSuccess(): void;
|
|
640
665
|
/** 실패 보고 — CLOSED 연속 카운트 증가(임계 도달 시 OPEN), HALF_OPEN 프로브 실패 시 OPEN 재진입. OPEN 중엔 무시. */
|
|
641
666
|
onFailure(): void;
|
|
667
|
+
/**
|
|
668
|
+
* 결과 없는 그랜트 반납 — 취소(abort) 등 성공·실패로 판정할 수 없는 호출이 보고 대신
|
|
669
|
+
* 호출한다 (CB-11). CLOSED: 미보고 그랜트만 반납(연속 실패 카운터 무변화). HALF_OPEN:
|
|
670
|
+
* 프로브 슬롯 해제 — HALF_OPEN 유지, 다음 allowRequest() 가 새 프로브를 획득한다.
|
|
671
|
+
* OPEN: 미보고 그랜트 잔량만 소진(그 외 무변화).
|
|
672
|
+
*/
|
|
673
|
+
onIgnore(): void;
|
|
674
|
+
/**
|
|
675
|
+
* fn 래핑 (권장 사용법) — allowRequest() 게이트 → 실행 → 결과 보고를 **정확히 1회** 보장한다.
|
|
676
|
+
* 거부는 reject(CircuitOpenError) — 동기 throw 아님, 거부 시 fn 은 호출되지 않는다.
|
|
677
|
+
* resolve → onSuccess() 후 값 반환. throw/reject 는 `name === "AbortError"` 면 onIgnore(),
|
|
678
|
+
* 그 외(TimeoutError 포함)는 onFailure() 후 **같은 값을 래핑 없이** 다시 throw 한다 (CB-12).
|
|
679
|
+
* fn 의 동기 throw 도 동일하게 처리된다.
|
|
680
|
+
*/
|
|
681
|
+
execute<T>(fn: () => T | PromiseLike<T>): Promise<T>;
|
|
642
682
|
/** 현재 상태 조회 — lazy 전이 특성상 openDurationMs 경과 후에도 allowRequest() 전엔 OPEN 으로 보고된다. */
|
|
643
683
|
state(): CircuitState;
|
|
644
684
|
}
|
|
@@ -1307,4 +1347,4 @@ declare function composeHangul(s: string): string;
|
|
|
1307
1347
|
*/
|
|
1308
1348
|
declare function matchesHangul(query: string, target: string): boolean;
|
|
1309
1349
|
|
|
1310
|
-
export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
|
|
1350
|
+
export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
|
package/dist/index.js
CHANGED
|
@@ -171,8 +171,7 @@ function createApiClient(config) {
|
|
|
171
171
|
if (idempotencyKey !== null && !headers.has(idempotencyHeader)) {
|
|
172
172
|
headers.set(idempotencyHeader, idempotencyKey);
|
|
173
173
|
}
|
|
174
|
-
|
|
175
|
-
if (!headers.has("Content-Type") && !isFormData) {
|
|
174
|
+
if (!headers.has("Content-Type") && typeof init.body === "string") {
|
|
176
175
|
headers.set("Content-Type", "application/json");
|
|
177
176
|
}
|
|
178
177
|
if (config.getToken && !headers.has("Authorization")) {
|
|
@@ -594,6 +593,12 @@ function createTtlCache(options) {
|
|
|
594
593
|
}
|
|
595
594
|
|
|
596
595
|
// src/circuitBreaker.ts
|
|
596
|
+
var CircuitOpenError = class extends Error {
|
|
597
|
+
constructor(message = "\uC11C\uD0B7 OPEN \u2014 \uC694\uCCAD \uCC28\uB2E8") {
|
|
598
|
+
super(message);
|
|
599
|
+
this.name = "CircuitOpenError";
|
|
600
|
+
}
|
|
601
|
+
};
|
|
597
602
|
function createCircuitBreaker(options) {
|
|
598
603
|
const { failureThreshold, openDurationMs, now = Date.now } = options;
|
|
599
604
|
if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) {
|
|
@@ -606,6 +611,7 @@ function createCircuitBreaker(options) {
|
|
|
606
611
|
let consecutiveFailures = 0;
|
|
607
612
|
let openedAtMs = 0;
|
|
608
613
|
let probeInFlight = false;
|
|
614
|
+
let probeStartedAtMs = 0;
|
|
609
615
|
let inFlightGrants = 0;
|
|
610
616
|
function allowRequest() {
|
|
611
617
|
if (state === "CLOSED") {
|
|
@@ -613,18 +619,24 @@ function createCircuitBreaker(options) {
|
|
|
613
619
|
return true;
|
|
614
620
|
}
|
|
615
621
|
if (state === "OPEN") {
|
|
616
|
-
|
|
617
|
-
|
|
622
|
+
const nowMs2 = now();
|
|
623
|
+
const elapsedMs = nowMs2 - openedAtMs;
|
|
624
|
+
if (elapsedMs < openDurationMs) return false;
|
|
625
|
+
if (inFlightGrants > 0) {
|
|
626
|
+
if (elapsedMs < 2 * openDurationMs) {
|
|
618
627
|
return false;
|
|
619
628
|
}
|
|
620
|
-
|
|
621
|
-
probeInFlight = true;
|
|
622
|
-
return true;
|
|
629
|
+
inFlightGrants = 0;
|
|
623
630
|
}
|
|
624
|
-
|
|
631
|
+
state = "HALF_OPEN";
|
|
632
|
+
probeInFlight = true;
|
|
633
|
+
probeStartedAtMs = nowMs2;
|
|
634
|
+
return true;
|
|
625
635
|
}
|
|
626
|
-
|
|
636
|
+
const nowMs = now();
|
|
637
|
+
if (probeInFlight && nowMs - probeStartedAtMs < openDurationMs) return false;
|
|
627
638
|
probeInFlight = true;
|
|
639
|
+
probeStartedAtMs = nowMs;
|
|
628
640
|
return true;
|
|
629
641
|
}
|
|
630
642
|
function onSuccess() {
|
|
@@ -659,13 +671,40 @@ function createCircuitBreaker(options) {
|
|
|
659
671
|
}
|
|
660
672
|
inFlightGrants = Math.max(0, inFlightGrants - 1);
|
|
661
673
|
}
|
|
674
|
+
function onIgnore() {
|
|
675
|
+
if (state === "HALF_OPEN") {
|
|
676
|
+
probeInFlight = false;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
inFlightGrants = Math.max(0, inFlightGrants - 1);
|
|
680
|
+
}
|
|
681
|
+
async function execute(fn) {
|
|
682
|
+
if (!allowRequest()) {
|
|
683
|
+
throw new CircuitOpenError();
|
|
684
|
+
}
|
|
685
|
+
let result;
|
|
686
|
+
try {
|
|
687
|
+
result = await fn();
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (isAbortError(error)) onIgnore();
|
|
690
|
+
else onFailure();
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
onSuccess();
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
662
696
|
return {
|
|
663
697
|
allowRequest,
|
|
664
698
|
onSuccess,
|
|
665
699
|
onFailure,
|
|
700
|
+
onIgnore,
|
|
701
|
+
execute,
|
|
666
702
|
state: () => state
|
|
667
703
|
};
|
|
668
704
|
}
|
|
705
|
+
function isAbortError(error) {
|
|
706
|
+
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
707
|
+
}
|
|
669
708
|
|
|
670
709
|
// src/tokenBucket.ts
|
|
671
710
|
function createTokenBucket(options) {
|
|
@@ -1646,6 +1685,7 @@ function matchesHangul(query, target) {
|
|
|
1646
1685
|
export {
|
|
1647
1686
|
ApiError,
|
|
1648
1687
|
BulkheadFullError,
|
|
1688
|
+
CircuitOpenError,
|
|
1649
1689
|
ResultCode,
|
|
1650
1690
|
WEBHOOK_SIGNATURE_HEADER,
|
|
1651
1691
|
abbreviateAmount,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rscc/common-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "RSCC 공통 코어 — CommonResponse
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "RSCC 공통 코어 — CommonResponse 타입·apiClient(traceId·재시도·멱등성 키), SSE 파서, 회복탄력성(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안(마스킹·로그 리댁션·웹훅 서명·JWT 디코드·AES-GCM 서브패스), 와이어 계약 헬퍼, 한국 도메인 유틸. 프레임워크 무관, 런타임 의존성 0.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rscc",
|
|
7
7
|
"common-response",
|