@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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # @rscc/common-core
2
2
 
3
- RSCC 공통 코어 — `CommonResponse` 봉투 타입, API 클라이언트 팩토리(traceId 발신/에코), SSE 프레임 파서, JWT 디코더, 시크릿 마스킹 유틸. **프레임워크 무관, 런타임 의존성 0.**
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
- ## 공개 API
19
+ ## 모듈 한눈에
15
20
 
16
21
  | 모듈 | 공개 API | 설명 |
17
22
  |---|---|---|
18
- | types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` | 응답 봉투·페이지네이션(page 0 시작)·결과 코드(값은 문자열) |
19
- | apiClient | `createApiClient(config)` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiResult<T>` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 |
20
- | sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크/멀티바이트 경계 안전, 모르는 키는 skip |
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` | 앞4+뒤4 노출, 8자 이하 전량 마스킹 (java `MaskingUtils` 와 동일 규칙) |
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: () => localStorage.getItem("token"), // 선택 — Authorization: Bearer 부착
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
- // 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId }
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) console.error(`[${e.traceId}] ${e.code}: ${e.message}`);
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
- onDelta: (chunk) => { /* 텍스트 증분 */ },
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 파서는 프레임 구분자 **LF(`\n\n`) 고정** — CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
65
- - `baseUrl` 은 **끝 슬래시 없이** 지정할 것 — path 와 단순 연결하며 정규화하지 않는다.
66
- - `requestWithMeta` 의 `ApiResult.response` 는 body 가 이미 소비된 상태 — headers/status 조회용.
67
- - 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 조용히 null 이 된다.
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
- - 상세 문서: [js/README.md](https://github.com/Jeonghyeon-Ryu/r-common/blob/master/js/README.md)
73
- - 와이어 계약(단일 소스): [contracts/](https://github.com/Jeonghyeon-Ryu/r-common/tree/master/contracts)
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
- const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData;
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
- if (now() - openedAtMs >= openDurationMs) {
708
- if (inFlightGrants > 0) {
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
- state = "HALF_OPEN";
712
- probeInFlight = true;
713
- return true;
721
+ inFlightGrants = 0;
714
722
  }
715
- return false;
723
+ state = "HALF_OPEN";
724
+ probeInFlight = true;
725
+ probeStartedAtMs = nowMs2;
726
+ return true;
716
727
  }
717
- if (probeInFlight) return false;
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~09 — 각 언어 테스트에 수동 동기화. Python 은
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
- * 를 **정확히 1회** 호출한다 (try/finally 로 보고 보장 권장 — 보고 누락은 아래 프로브
591
- * 보류를 무기한 지속시킨다). OPEN 대기 중 늦게 도착한 보고는 상태·openedAt·카운터를
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 중 도착하는 보고는 항상 프로브의 것이다 (CB-09).
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 서킷 + 재시도 조합 — 시도 직전 allowRequest 게이트, 결과를 브레이커에 보고.
598
- * OPEN 차단 오류는 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
625
+ * @example 서킷 + 재시도 조합 — 시도마다 execute 로 게이트·보고. OPEN 차단
626
+ * ({@link CircuitOpenError})은 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
599
627
  * ```ts
600
628
  * const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
601
- * const circuitOpen = new Error("서킷 OPEN — 요청 차단");
602
- * await retry(
603
- * async () => {
604
- * if (!cb.allowRequest()) throw circuitOpen;
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). HALF_OPEN 프로브 경합 시 나머지 false.
635
- * true 를 받은 호출만 결과를 onSuccess()/onFailure() 로 보고해야 한다.
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~09 — 각 언어 테스트에 수동 동기화. Python 은
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
- * 를 **정확히 1회** 호출한다 (try/finally 로 보고 보장 권장 — 보고 누락은 아래 프로브
591
- * 보류를 무기한 지속시킨다). OPEN 대기 중 늦게 도착한 보고는 상태·openedAt·카운터를
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 중 도착하는 보고는 항상 프로브의 것이다 (CB-09).
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 서킷 + 재시도 조합 — 시도 직전 allowRequest 게이트, 결과를 브레이커에 보고.
598
- * OPEN 차단 오류는 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
625
+ * @example 서킷 + 재시도 조합 — 시도마다 execute 로 게이트·보고. OPEN 차단
626
+ * ({@link CircuitOpenError})은 shouldRetry 에서 비재시도로 걸러 즉시 실패시킨다(빠른 실패 유지).
599
627
  * ```ts
600
628
  * const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
601
- * const circuitOpen = new Error("서킷 OPEN — 요청 차단");
602
- * await retry(
603
- * async () => {
604
- * if (!cb.allowRequest()) throw circuitOpen;
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). HALF_OPEN 프로브 경합 시 나머지 false.
635
- * true 를 받은 호출만 결과를 onSuccess()/onFailure() 로 보고해야 한다.
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
- const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData;
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
- if (now() - openedAtMs >= openDurationMs) {
617
- if (inFlightGrants > 0) {
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
- state = "HALF_OPEN";
621
- probeInFlight = true;
622
- return true;
629
+ inFlightGrants = 0;
623
630
  }
624
- return false;
631
+ state = "HALF_OPEN";
632
+ probeInFlight = true;
633
+ probeStartedAtMs = nowMs2;
634
+ return true;
625
635
  }
626
- if (probeInFlight) return false;
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.2.0",
4
- "description": "RSCC 공통 코어 — CommonResponse 타입, apiClient 팩토리(traceId 발신/에코), SSE 프레임 파서, JWT 디코더, 마스킹 유틸. 프레임워크 무관, 런타임 의존성 0.",
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",