rscc-common 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,97 @@
1
+ """rscc-common — RSCC 공통 Python 라이브러리.
2
+
3
+ 기본 설치는 표준 라이브러리만 사용한다. 여기서 재노출하는 심볼은 모두 무의존 모듈의 것이다.
4
+ `rscc_common.response` (pydantic 필요) 와 `rscc_common.handlers` (fastapi 필요) 는
5
+ `rscc-common[fastapi]` extra 설치 후 서브모듈로 직접 임포트한다 — 패키지 임포트만으로
6
+ pydantic/fastapi 를 요구하지 않기 위해 여기서 재노출하지 않는다.
7
+
8
+ 무의존 유틸 모듈(jwt/datetimes/masking/sse)은 서브모듈 네임스페이스로 재노출한다
9
+ (`from rscc_common import jwt; jwt.decode_token(...)`). 함수가 많고 이름이 일반적이라
10
+ 플랫 재노출 대신 네임스페이스로 묶었다.
11
+ """
12
+
13
+ from . import (
14
+ api_key,
15
+ bizno,
16
+ bulkhead,
17
+ cache,
18
+ circuit_breaker,
19
+ client_ip,
20
+ constant_time,
21
+ datetimes,
22
+ jwt,
23
+ log_sanitize,
24
+ masking,
25
+ metrics,
26
+ rate_limit,
27
+ retry,
28
+ sse,
29
+ validation,
30
+ )
31
+ from .access_log import install_access_log
32
+ from .errors import CommonException, ResultCode
33
+ from .logging import configure_logging
34
+ from .rate_limit import install_rate_limit
35
+ from .security_headers import install_security_headers
36
+ from .trace import (
37
+ async_trace_request_hook,
38
+ get_trace_id,
39
+ install_trace_middleware,
40
+ new_trace_id,
41
+ reset_trace_id,
42
+ set_trace_id,
43
+ trace_request_hook,
44
+ )
45
+ from .user_context import (
46
+ UserContext,
47
+ async_user_context_request_hook,
48
+ get_user_context,
49
+ install_user_context_middleware,
50
+ reset_user_context,
51
+ set_user_context,
52
+ user_context_request_hook,
53
+ )
54
+
55
+ # 패키지 버전의 단일 소스 — pyproject.toml 은 dynamic version 으로 이 값을 읽는다.
56
+ # 릴리스 절차: 이 값 수정 → py-vX.Y.Z 태그 push (pyproject 는 건드릴 것 없음).
57
+ __version__ = "0.2.0"
58
+
59
+ __all__ = [
60
+ "CommonException",
61
+ "ResultCode",
62
+ "UserContext",
63
+ "api_key",
64
+ "async_trace_request_hook",
65
+ "async_user_context_request_hook",
66
+ "bizno",
67
+ "bulkhead",
68
+ "cache",
69
+ "circuit_breaker",
70
+ "client_ip",
71
+ "configure_logging",
72
+ "constant_time",
73
+ "datetimes",
74
+ "get_trace_id",
75
+ "get_user_context",
76
+ "install_access_log",
77
+ "install_rate_limit",
78
+ "install_security_headers",
79
+ "install_trace_middleware",
80
+ "install_user_context_middleware",
81
+ "jwt",
82
+ "log_sanitize",
83
+ "masking",
84
+ "metrics",
85
+ "new_trace_id",
86
+ "rate_limit",
87
+ "reset_trace_id",
88
+ "reset_user_context",
89
+ "retry",
90
+ "set_trace_id",
91
+ "set_user_context",
92
+ "sse",
93
+ "trace_request_hook",
94
+ "user_context_request_hook",
95
+ "validation",
96
+ "__version__",
97
+ ]
@@ -0,0 +1,200 @@
1
+ """PII-세이프 액세스 로그 — 순수 ASGI 미들웨어. 표준 라이브러리만.
2
+
3
+ Java ``com.rscc.common.web.AccessLogFilter`` 와 동일 계약(수동 동기화) —
4
+ 요청당 1라인 INFO 로그, 고정 포맷::
5
+
6
+ method=GET path=/v1/items status=200 durationMs=12 traceId=abc12345
7
+
8
+ (+ 설정된 헤더마다 `` header.<설정표기>=<값>``)
9
+
10
+ ``slow_threshold_ms`` 활성 시(옵트인) 임계 이상(``>=``) 요청은 WARN 으로 승격되고
11
+ traceId **직후·header 토큰 앞** 고정 위치에 `` slow=true`` additive 토큰이 붙는다 —
12
+ Java ``AccessLogFilter`` 와 동일 규칙(수동 동기화).
13
+
14
+ PII 안전 장치:
15
+
16
+ - **query string 은 기본 미포함** — 검색어·토큰 등 PII 가 흔해 ``include_query_string``
17
+ 옵트인으로만 포함한다.
18
+ - **헤더 allowlist** — ``headers`` 로 지정한 헤더만 로깅하고, 그중 민감 고정 집합
19
+ (:data:`SENSITIVE_HEADERS`, 소문자 비교)은 :func:`~rscc_common.masking.mask_secret`
20
+ 으로 마스킹 후 로깅한다.
21
+ - **바디 로깅은 비범위** — SSE/스트리밍 응답 버퍼링과 충돌하고 PII 유입 위험이 커서
22
+ 1차 범위에서 제외했다.
23
+
24
+ 미들웨어는 trace.py 와 동일한 순수 ASGI 관용구다 — fastapi/starlette 임포트가
25
+ 전혀 없어 기본 설치(무의존)에서 모듈 임포트가 깨지지 않는다.
26
+ """
27
+
28
+ import asyncio
29
+ import logging
30
+ import time
31
+ from collections.abc import Sequence
32
+
33
+ from .masking import mask_secret
34
+ from .trace import TRACE_HEADER, TRACE_ID_PATTERN, get_trace_id
35
+
36
+ #: 값이 마스킹되는 민감 헤더 집합(소문자 비교) — Java ``AccessLogFilter.SENSITIVE_HEADERS`` 동일
37
+ SENSITIVE_HEADERS = frozenset(
38
+ {"authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"}
39
+ )
40
+
41
+ _TRACE_HEADER_LOWER = TRACE_HEADER.lower().encode("latin-1")
42
+
43
+
44
+ class _AccessLogMiddleware:
45
+ """순수 ASGI 미들웨어 — 응답 완료(바디 송신 포함) 후 1라인 INFO 액세스 로그."""
46
+
47
+ def __init__(
48
+ self,
49
+ app,
50
+ *,
51
+ logger_name: str = "rscc_common.access",
52
+ headers: Sequence[str] = (),
53
+ include_query_string: bool = False,
54
+ slow_threshold_ms: int | None = None,
55
+ ) -> None:
56
+ if slow_threshold_ms is not None and slow_threshold_ms <= 0:
57
+ # 0 이하는 "비활성 의도인지 오타인지" 모호한 설정 오류 — fail-fast (비활성은 None)
58
+ raise ValueError(f"slow_threshold_ms 는 0 보다 커야 합니다: {slow_threshold_ms}")
59
+ self.app = app
60
+ self._logger = logging.getLogger(logger_name)
61
+ # (설정 표기, 소문자 비교용 바이트) 쌍 — 로그 키는 설정한 표기 그대로 쓴다
62
+ self._headers = [(h, h.lower().encode("latin-1")) for h in headers]
63
+ self._include_query_string = include_query_string
64
+ self._slow_threshold_ms = slow_threshold_ms
65
+
66
+ async def __call__(self, scope, receive, send) -> None:
67
+ if scope["type"] != "http":
68
+ await self.app(scope, receive, send)
69
+ return
70
+
71
+ start = time.perf_counter()
72
+ captured_status: list[int | None] = [None]
73
+
74
+ async def send_with_capture(message) -> None:
75
+ if message["type"] == "http.response.start":
76
+ captured_status[0] = message["status"]
77
+ await send(message)
78
+
79
+ try:
80
+ await self.app(scope, receive, send_with_capture)
81
+ except asyncio.CancelledError:
82
+ # 태스크 취소(클라이언트 조기 이탈·서버 셧다운)는 오류가 아니다 — 응답이 이미
83
+ # 시작됐으면 실제 송신 상태로 남기고, 아니면 로깅 없이 재raise 한다
84
+ # (500 오류율 지표 오염 방지 — Java AccessLogFilter 는 취소 계열을 잡지 않는다)
85
+ if captured_status[0] is not None:
86
+ self._log(scope, captured_status[0], start)
87
+ raise
88
+ except BaseException:
89
+ # 미처리 예외 경로 — 응답 시작 후면 클라이언트가 실제 받은 상태를, 아니면 500 을
90
+ # 로깅하고 그대로 재raise (응답 송신은 바깥 계층 몫)
91
+ status = captured_status[0]
92
+ self._log(scope, status if status is not None else 500, start)
93
+ raise
94
+ status = captured_status[0]
95
+ self._log(scope, status if status is not None else 500, start)
96
+
97
+ def _resolve_trace_id(self, headers) -> str:
98
+ """traceId — contextvar 우선, 없으면 수신 X-Trace-Id 검증 폴백, 그래도 없으면 '-'.
99
+
100
+ 폴백 덕에 trace 미들웨어보다 **바깥**에 등록돼(contextvar 가 이미 정리된 시점)도
101
+ 수신 traceId 를 잃지 않는다 — 등록 순서 무관 동작 (install_access_log docstring).
102
+ """
103
+ trace_id = get_trace_id()
104
+ if trace_id is not None:
105
+ return trace_id
106
+ for key, value in headers:
107
+ if key.lower() == _TRACE_HEADER_LOWER:
108
+ candidate = value.decode("latin-1")
109
+ if TRACE_ID_PATTERN.fullmatch(candidate):
110
+ return candidate
111
+ break
112
+ return "-"
113
+
114
+ def _log(self, scope, status: int, start: float) -> None:
115
+ duration_ms = int((time.perf_counter() - start) * 1000)
116
+ path = scope.get("path", "")
117
+ if self._include_query_string:
118
+ query = scope.get("query_string", b"")
119
+ if query:
120
+ path = path + "?" + query.decode("latin-1")
121
+ headers = scope.get("headers", [])
122
+ parts = [
123
+ f"method={scope.get('method', '-')}",
124
+ f"path={path}",
125
+ f"status={status}",
126
+ f"durationMs={duration_ms}",
127
+ f"traceId={self._resolve_trace_id(headers)}",
128
+ ]
129
+ # slow 판정(>=) — traceId 직후·header.* 앞 고정 위치 (계약 §4, Java 와 동일)
130
+ slow = self._slow_threshold_ms is not None and duration_ms >= self._slow_threshold_ms
131
+ if slow:
132
+ parts.append("slow=true")
133
+ for display, lower in self._headers:
134
+ value: str | None = None
135
+ for key, raw in headers:
136
+ if key.lower() == lower:
137
+ value = raw.decode("latin-1")
138
+ break
139
+ if value is None:
140
+ continue # 미수신 헤더는 키 자체 생략
141
+ if display.lower() in SENSITIVE_HEADERS:
142
+ value = mask_secret(value)
143
+ parts.append(f"header.{display}={value}")
144
+ log = self._logger.warning if slow else self._logger.info
145
+ log("%s", " ".join(parts))
146
+
147
+
148
+ def install_access_log(
149
+ app,
150
+ *,
151
+ logger_name: str = "rscc_common.access",
152
+ headers: Sequence[str] = (),
153
+ include_query_string: bool = False,
154
+ slow_threshold_ms: int | None = None,
155
+ ) -> None:
156
+ """FastAPI/Starlette 앱에 액세스 로그 미들웨어를 등록한다 (opt-in, 명시 호출형).
157
+
158
+ 사용::
159
+
160
+ from fastapi import FastAPI
161
+ from rscc_common import install_access_log
162
+
163
+ app = FastAPI()
164
+ install_access_log(app, headers=["Authorization"]) # Authorization 은 마스킹 로깅
165
+
166
+ 미들웨어 자체는 순수 ASGI 라 이 함수는 ``app.add_middleware`` 만 호출한다 —
167
+ fastapi 를 이 모듈이 임포트하지 않으므로 기본 설치에서도 임포트 안전.
168
+
169
+ trace 미들웨어와의 등록 순서는 **무관**하다 — Starlette 의 ``add_middleware`` 는
170
+ 나중에 등록한 미들웨어가 더 바깥에서 실행되는데, 이 미들웨어가 trace 미들웨어보다
171
+ 바깥이면 로깅 시점에 contextvar 가 이미 정리된 뒤라서 수신 ``X-Trace-Id`` 헤더를
172
+ :data:`~rscc_common.trace.TRACE_ID_PATTERN` 으로 검증해 폴백으로 쓴다
173
+ (신규 생성된 traceId 까지 남기려면 trace 미들웨어를 나중에 등록해 바깥에 둘 것).
174
+
175
+ durationMs 는 앱 호출 완료(응답 바디 송신 포함)까지의 정수 ms 다. 미처리 예외는
176
+ ``status=500`` (응답 시작 후면 실제 송신 상태) 으로 로깅 후 재raise 되고,
177
+ ``asyncio.CancelledError`` 는 오류로 취급하지 않는다 — 응답 시작 전이면 로깅 없이
178
+ 재raise 된다(오류율 지표 오염 방지). 요청/응답 **바디 로깅은 비범위**
179
+ (모듈 docstring 참고).
180
+
181
+ :param logger_name: 로그를 남길 로거 이름. 기본 ``rscc_common.access``.
182
+ :param headers: 로깅할 요청 헤더 allowlist — 미지정(빈 목록)이면 헤더 미로깅.
183
+ 민감 헤더(:data:`SENSITIVE_HEADERS`)는 값이 마스킹된다.
184
+ :param include_query_string: path 에 query string 포함 여부 (기본 False — PII 옵트인).
185
+ :param slow_threshold_ms: 이 값(ms) **이상**(``>=``) 걸린 요청을 WARN 으로 승격하고
186
+ traceId 직후에 ``slow=true`` 토큰을 붙인다. ``None`` = 비활성(기본).
187
+ 0 이하 값은 설정 오류로 보고 즉시 ``ValueError`` (fail-fast — 비활성은 None 으로).
188
+ 실패(예외) 경로는 슬로우 판정 없음 — 예외가 이미 시그널이다.
189
+ """
190
+ if slow_threshold_ms is not None and slow_threshold_ms <= 0:
191
+ # add_middleware 는 등록만 하고 인스턴스화는 첫 요청 시점이라, 여기서 먼저
192
+ # 검증해야 설정 오류가 install 호출 지점에서 바로 드러난다 (fail-fast)
193
+ raise ValueError(f"slow_threshold_ms 는 0 보다 커야 합니다: {slow_threshold_ms}")
194
+ app.add_middleware(
195
+ _AccessLogMiddleware,
196
+ logger_name=logger_name,
197
+ headers=headers,
198
+ include_query_string=include_query_string,
199
+ slow_threshold_ms=slow_threshold_ms,
200
+ )
rscc_common/api_key.py ADDED
@@ -0,0 +1,90 @@
1
+ """API 키 발급/검증 유틸 — ``contracts/api-key.md`` 형식 규약의 Python 구현.
2
+
3
+ 표준 라이브러리만 사용(``secrets``·``zlib.crc32``·``hashlib``). Java
4
+ ``com.rscc.common.crypto.ApiKeyUtils`` 와 수동 동기화(공유 골든 벡터 AK-01~08).
5
+
6
+ 형식 — ``rscc_<env>_<body><checksum>``, 총 48자 고정:
7
+
8
+ - env: ``"live"`` | ``"test"`` — 고정 접두 ``rscc_live_``/``rscc_test_`` 는
9
+ 시크릿 스캐너(GitHub secret scanning 등) 등록용.
10
+ - body: base62 32자 — CSPRNG(:mod:`secrets`) 생성, 키의 비밀 엔트로피(약 190bit).
11
+ - checksum: base62 6자 — 앞 42자 ASCII 의 CRC32(IEEE)를 base62 인코딩 후 좌측
12
+ '0' 패딩 (62^6 > 2^32 — 항상 6자 이내). 오탈자·잘림 검출과 스캐너 오탐 제거용이며
13
+ **보안 기능이 아니다** — 체크섬 통과 ≠ 유효한 키.
14
+
15
+ 검증 순서: :func:`is_well_formed` (형식) → :func:`checksum_ok` (무결성) →
16
+ 저장 해시 대조(인증). 앞 두 단계는 저장소 조회 전에 무자격 요청을 차단하는 게이트다.
17
+
18
+ 저장: 원문 저장 금지 — :func:`sha256_hex` 의 lowercase hex 64자를 저장하고,
19
+ 대조는 상수시간 비교(:func:`rscc_common.constant_time.compare_digest`)로 한다.
20
+ 키 원문은 발급 응답에서 1회만 노출하고 재조회 불가로 설계한다.
21
+
22
+ 인증 미들웨어, 키 회전·폐기 수명주기, 권한(scope) 모델은 비범위.
23
+ """
24
+
25
+ import hashlib
26
+ import re
27
+ import secrets
28
+ import zlib
29
+
30
+ #: base62 알파벳 — 인덱스 0~61 고정(계약값, 순서 변경 금지)
31
+ _BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
32
+ #: 형식 정규식 — 접두(live|test) + base62 38자(body 32 + checksum 6), 총 48자
33
+ _KEY_PATTERN = re.compile(r"^rscc_(live|test)_[0-9A-Za-z]{38}$")
34
+ _BODY_LENGTH = 32
35
+ _CHECKSUM_LENGTH = 6
36
+ #: 체크섬 계산 대상 프리픽스 길이 — ``rscc_<env>_``(10자) + body(32자)
37
+ _CHECKSUM_INPUT_LENGTH = 42
38
+ _ENVS = frozenset({"live", "test"})
39
+
40
+
41
+ def issue(env: str) -> str:
42
+ """새 API 키를 발급한다 — CSPRNG 로 base62 32자 body 를 생성하고 체크섬을 부착한다.
43
+
44
+ :param env: 키 환경 — ``"live"`` | ``"test"``
45
+ :return: 48자 고정 키 (예: ``rscc_live_...``)
46
+ :raises ValueError: env 가 ``"live"``/``"test"`` 가 아닌 경우
47
+ """
48
+ if env not in _ENVS:
49
+ raise ValueError(f'env 는 "live" 또는 "test" 여야 합니다: {env!r}')
50
+ prefix = f"rscc_{env}_" + "".join(
51
+ secrets.choice(_BASE62_ALPHABET) for _ in range(_BODY_LENGTH)
52
+ )
53
+ return prefix + _checksum_of(prefix)
54
+
55
+
56
+ def is_well_formed(key: str | None) -> bool:
57
+ """형식 검사 — 접두·길이(48자)·base62 문자 집합만 본다(체크섬 값 검증은
58
+ :func:`checksum_ok`). ``None`` 은 ``False`` — 예외 없음, 게이트 용도."""
59
+ return key is not None and _KEY_PATTERN.fullmatch(key) is not None
60
+
61
+
62
+ def checksum_ok(key: str | None) -> bool:
63
+ """무결성 검사 — :func:`is_well_formed` 통과 후 CRC32 체크섬 재계산 일치 여부.
64
+
65
+ 오타·잘림 검출용이며 인증 판정이 아니다(인증은 저장 해시 대조로만).
66
+ ``None``/형식 불일치는 ``False`` — 예외 없음.
67
+ """
68
+ if not is_well_formed(key):
69
+ return False
70
+ assert key is not None # is_well_formed 통과 — 타입 내로잉
71
+ return _checksum_of(key[:_CHECKSUM_INPUT_LENGTH]) == key[_CHECKSUM_INPUT_LENGTH:]
72
+
73
+
74
+ def sha256_hex(key: str) -> str:
75
+ """저장용 해시 — 키 전체 48자 ASCII 의 SHA-256 을 lowercase hex 64자로 반환한다.
76
+
77
+ 원문 저장 금지 규약(``contracts/api-key.md``)의 저장 형식. 대조는
78
+ :func:`rscc_common.constant_time.compare_digest` 로 한다.
79
+ """
80
+ return hashlib.sha256(key.encode("ascii")).hexdigest()
81
+
82
+
83
+ def _checksum_of(prefix42: str) -> str:
84
+ """앞 42자 ASCII 의 CRC32(IEEE, 부호 없는 32bit) → base62 → 6자 좌측 '0' 패딩."""
85
+ value = zlib.crc32(prefix42.encode("ascii")) # zlib.crc32 는 항상 부호 없는 32bit
86
+ out = ""
87
+ while value:
88
+ value, rem = divmod(value, 62)
89
+ out = _BASE62_ALPHABET[rem] + out
90
+ return out.rjust(_CHECKSUM_LENGTH, "0")
rscc_common/bizno.py ADDED
@@ -0,0 +1,75 @@
1
+ """사업자등록번호·법인등록번호 체크섬 검증 — contracts 무관 내부 유틸.
2
+
3
+ Java ``com.rscc.common.util.BusinessNumberUtils`` / JS ``@rscc/common-core`` 의
4
+ ``bizno.ts`` 와 1:1 로 맞춘 Python 대응(수동 동기화). **표준 라이브러리만** 사용한다.
5
+
6
+ **체크섬만 검증한다** — 번호의 실존 여부(국세청 등록)·구분코드의 의미 검증은
7
+ 범위 밖이다. 형식·체크 디지트가 맞는 합성 번호도 유효로 판정된다.
8
+
9
+ 정규화 규칙: 하이픈(``-``)·스페이스(`` ``)·탭(``\\t``)만 제거한다(위치 불문).
10
+ 그 외 문자는 보존되어 검증 단계에서 걸러진다.
11
+
12
+ 주의: Python 정규식 ``\\d`` 는 전각 숫자(``123``)도 매칭하므로 반드시
13
+ ``[0-9]`` 를 쓴다 — 전각 숫자는 3언어 모두 무효 판정(파리티 규칙).
14
+ """
15
+
16
+ import re
17
+
18
+ # 정규화 제거 대상 — 하이픈·스페이스·탭만 (그 외 문자는 보존).
19
+ _SEPARATORS = re.compile(r"[-\t ]")
20
+
21
+ # ASCII 숫자만 — \d 금지(전각 숫자 매칭 사고 방지). 사업자 10자리 / 법인 13자리.
22
+ _BUSINESS_DIGITS = re.compile(r"^[0-9]{10}$")
23
+ _CORPORATE_DIGITS = re.compile(r"^[0-9]{13}$")
24
+
25
+ # 사업자등록번호 가중치 — d0..d8 에 적용 (d8 은 추가로 ×5//10 보정).
26
+ _BUSINESS_WEIGHTS = (1, 3, 7, 1, 3, 7, 1, 3, 5)
27
+ # 법인등록번호 가중치 — 1,2 반복 12개, d0..d11 에 적용.
28
+ _CORPORATE_WEIGHTS = (1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2)
29
+
30
+
31
+ def normalize_business_number(value: str) -> str:
32
+ """하이픈·스페이스·탭을 제거한다 (예: ``"123-45-67891"`` → ``"1234567891"``).
33
+
34
+ 그 외 문자는 보존한다 — 유효성 판정은 :func:`is_valid_business_number` /
35
+ :func:`is_valid_corporate_number` 의 몫.
36
+ """
37
+ return _SEPARATORS.sub("", value)
38
+
39
+
40
+ def is_valid_business_number(value: str | None) -> bool:
41
+ """사업자등록번호(10자리) 체크섬 검증.
42
+
43
+ ``None`` / 정규화 후 ASCII 숫자 10자리가 아니면 False.
44
+ 검증식(3언어 공유): d0..d9, 가중치 ``[1,3,7,1,3,7,1,3,5]``::
45
+
46
+ sum = Σ(i=0..8) dᵢ×wᵢ + (d₈×5)//10
47
+ check = (10 − sum%10) % 10 → 유효 ⟺ check == d₉
48
+ """
49
+ if value is None:
50
+ return False
51
+ digits = normalize_business_number(value)
52
+ if not _BUSINESS_DIGITS.fullmatch(digits):
53
+ return False
54
+ d = [int(c) for c in digits]
55
+ total = sum(d[i] * w for i, w in enumerate(_BUSINESS_WEIGHTS)) + (d[8] * 5) // 10
56
+ return (10 - total % 10) % 10 == d[9]
57
+
58
+
59
+ def is_valid_corporate_number(value: str | None) -> bool:
60
+ """법인등록번호(13자리) 체크섬 검증.
61
+
62
+ ``None`` / 정규화 후 ASCII 숫자 13자리가 아니면 False.
63
+ 검증식(3언어 공유): d0..d12, 가중치 ``1,2`` 반복 12개::
64
+
65
+ sum = Σ(i=0..11) dᵢ×wᵢ
66
+ check = (10 − sum%10) % 10 → 유효 ⟺ check == d₁₂
67
+ """
68
+ if value is None:
69
+ return False
70
+ digits = normalize_business_number(value)
71
+ if not _CORPORATE_DIGITS.fullmatch(digits):
72
+ return False
73
+ d = [int(c) for c in digits]
74
+ total = sum(d[i] * w for i, w in enumerate(_CORPORATE_WEIGHTS))
75
+ return (10 - total % 10) % 10 == d[12]
@@ -0,0 +1,133 @@
1
+ """동시성 제한 Bulkhead — 실행 슬롯 + 유한 대기 슬롯, 초과는 즉시 거부. 표준 라이브러리만.
2
+
3
+ 특정 다운스트림/작업의 동시 실행 수를 ``max_concurrent`` 로 격벽(bulkhead) 치고,
4
+ ``max_queue`` (기본 0) 만큼만 대기를 허용한다. 실행 여유 → 즉시 진입, 대기 여유 →
5
+ 슬롯 대기, 둘 다 만석 → 즉시 :class:`BulkheadFullError` (대기 없음 — 빠른 실패로
6
+ 부하 전파를 차단한다). 거부 시 보호 대상 코드 블록은 **실행되지 않는다** (BH-04).
7
+ Java ``Bulkhead``(``BulkheadFullException``) / JS ``createBulkhead``(``BulkheadFullError``)
8
+ 와 시맨틱을 맞췄다 (수동 동기화 — 공유 벡터 BH-01~05).
9
+
10
+ 동기 :class:`Bulkhead` (``with``) 와 비동기 :class:`AsyncBulkhead` (``async with``)
11
+ 두 벌을 제공한다. 슬롯 해제는 ``__exit__``/``__aexit__`` 가 보장한다 — 블록이
12
+ 예외로 종료해도 반환된다 (BH-03).
13
+
14
+ 대기 순서: 동기 구현의 :class:`threading.Semaphore` 는 대기자를 깨우는 순서를
15
+ **보장하지 않는다** (Java 는 fair Semaphore FIFO / JS 는 FIFO 큐 — Python 만
16
+ 순서 비보장, 공유 벡터는 순서 비의존 설계).
17
+ """
18
+
19
+ import asyncio
20
+ import threading
21
+
22
+
23
+ class BulkheadFullError(Exception):
24
+ """실행·대기 슬롯 모두 만석 — 즉시 거부.
25
+ Java ``BulkheadFullException`` / JS ``BulkheadFullError`` 대칭."""
26
+
27
+
28
+ def _validate(max_concurrent: int, max_queue: int) -> None:
29
+ if max_concurrent <= 0:
30
+ raise ValueError(f"max_concurrent 는 0 보다 커야 합니다: {max_concurrent!r}")
31
+ if max_queue < 0:
32
+ raise ValueError(f"max_queue 는 0 이상이어야 합니다: {max_queue!r}")
33
+
34
+
35
+ class Bulkhead:
36
+ """동기 Bulkhead — ``threading.Semaphore`` + 대기 카운터.
37
+
38
+ 사용::
39
+
40
+ bh = Bulkhead(max_concurrent=8, max_queue=16)
41
+ try:
42
+ with bh:
43
+ call()
44
+ except BulkheadFullError:
45
+ ... # 즉시 거부 — 대기 없이 빠른 실패
46
+
47
+ :param max_concurrent: 동시 실행 슬롯 수 (필수 > 0).
48
+ :param max_queue: 대기 슬롯 수 (기본 0 — 대기 불허, >= 0).
49
+ """
50
+
51
+ def __init__(self, *, max_concurrent: int, max_queue: int = 0) -> None:
52
+ _validate(max_concurrent, max_queue)
53
+ self._sem = threading.Semaphore(max_concurrent)
54
+ self._max_queue = max_queue
55
+ self._lock = threading.Lock()
56
+ self._waiting = 0
57
+
58
+ def __enter__(self) -> "Bulkhead":
59
+ """슬롯 획득 — 실행 여유면 즉시, 대기 여유면 블로킹 대기, 만석이면 즉시
60
+ :class:`BulkheadFullError` (보호 블록 미실행)."""
61
+ if self._sem.acquire(blocking=False):
62
+ return self
63
+ with self._lock:
64
+ # 대기 슬롯 판정 — 임계 도달(>=)이면 즉시 거부 (BH-01/BH-02)
65
+ if self._waiting >= self._max_queue:
66
+ raise BulkheadFullError(
67
+ f"동시 실행·대기 슬롯 만석 — 즉시 거부 "
68
+ f"(max_concurrent 초과, max_queue={self._max_queue})"
69
+ )
70
+ self._waiting += 1
71
+ try:
72
+ self._sem.acquire()
73
+ finally:
74
+ with self._lock:
75
+ self._waiting -= 1
76
+ return self
77
+
78
+ def __exit__(self, *exc) -> None:
79
+ """슬롯 반환 — 블록이 예외로 종료해도 보장된다 (BH-03)."""
80
+ self._sem.release()
81
+
82
+
83
+ class AsyncBulkhead:
84
+ """비동기 Bulkhead — ``asyncio.Semaphore`` + 대기 카운터.
85
+
86
+ **단일 이벤트 루프 전제** — 내부 상태를 락 없이 다루므로 여러 이벤트 루프/
87
+ 스레드에서 같은 인스턴스를 공유하면 안 된다 (그 경우 :class:`Bulkhead` 를 쓸 것,
88
+ AsyncTtlCache 선례). 시맨틱은 :class:`Bulkhead` 와 동일하다.
89
+
90
+ 사용::
91
+
92
+ bh = AsyncBulkhead(max_concurrent=8, max_queue=16)
93
+ try:
94
+ async with bh:
95
+ await call()
96
+ except BulkheadFullError:
97
+ ...
98
+
99
+ :param max_concurrent: 동시 실행 슬롯 수 (필수 > 0).
100
+ :param max_queue: 대기 슬롯 수 (기본 0 — 대기 불허, >= 0).
101
+ """
102
+
103
+ def __init__(self, *, max_concurrent: int, max_queue: int = 0) -> None:
104
+ _validate(max_concurrent, max_queue)
105
+ self._sem = asyncio.Semaphore(max_concurrent)
106
+ self._max_queue = max_queue
107
+ self._waiting = 0
108
+
109
+ async def __aenter__(self) -> "AsyncBulkhead":
110
+ """슬롯 획득 — :meth:`Bulkhead.__enter__` 의 비동기 버전.
111
+
112
+ ``locked()`` 판정과 대기 카운트 증가 사이에 await 가 없어 단일 루프에서
113
+ 원자적이다 (여유 있을 때의 ``acquire()`` 도 내부 양보 없이 즉시 획득).
114
+ """
115
+ if self._sem.locked():
116
+ # 대기 슬롯 판정 — 임계 도달(>=)이면 즉시 거부 (BH-01/BH-02)
117
+ if self._waiting >= self._max_queue:
118
+ raise BulkheadFullError(
119
+ f"동시 실행·대기 슬롯 만석 — 즉시 거부 "
120
+ f"(max_concurrent 초과, max_queue={self._max_queue})"
121
+ )
122
+ self._waiting += 1
123
+ try:
124
+ await self._sem.acquire()
125
+ finally:
126
+ self._waiting -= 1
127
+ else:
128
+ await self._sem.acquire()
129
+ return self
130
+
131
+ async def __aexit__(self, *exc) -> None:
132
+ """슬롯 반환 — 블록이 예외로 종료해도 보장된다 (BH-03)."""
133
+ self._sem.release()