ecos-reader 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (88) hide show
  1. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/CHANGELOG.md +15 -0
  2. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/PKG-INFO +1 -1
  3. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/pyproject.toml +1 -1
  4. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/__init__.py +5 -1
  5. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/cache.py +48 -41
  6. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/client.py +81 -38
  7. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/config.py +6 -0
  8. ecos_reader-0.2.1/src/ecos/exceptions.py +102 -0
  9. ecos_reader-0.2.1/src/ecos/indicators/_dates.py +64 -0
  10. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/bond.py +2 -20
  11. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/fiscal.py +2 -21
  12. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/growth.py +13 -33
  13. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/interest_rate.py +31 -30
  14. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/money.py +13 -40
  15. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/prices.py +6 -24
  16. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/stock.py +4 -33
  17. ecos_reader-0.2.1/tests/indicators/test_dates.py +99 -0
  18. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_interest_rate.py +58 -0
  19. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_cache.py +46 -0
  20. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_client.py +96 -0
  21. ecos_reader-0.2.1/tests/test_exceptions.py +131 -0
  22. ecos_reader-0.2.0/src/ecos/exceptions.py +0 -73
  23. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/ci.yml +0 -0
  24. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/docs.yml +0 -0
  25. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/publish.yml +0 -0
  26. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.gitignore +0 -0
  27. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.pre-commit-config.yaml +0 -0
  28. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/API_SPEC.md +0 -0
  29. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/CONTRIBUTING.md +0 -0
  30. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/IMPLEMENTATION_STATUS.md +0 -0
  31. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/LICENSE +0 -0
  32. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/README.md +0 -0
  33. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/client.md +0 -0
  34. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/exceptions.md +0 -0
  35. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/indicators.md +0 -0
  36. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/overview.md +0 -0
  37. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-item-list.md +0 -0
  38. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-meta.md +0 -0
  39. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-search.md +0 -0
  40. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-table-list.md +0 -0
  41. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-top-100.md +0 -0
  42. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-word.md +0 -0
  43. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/development/contributing.md +0 -0
  44. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/development/release.md +0 -0
  45. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/examples/basic.md +0 -0
  46. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/examples/dashboard.md +0 -0
  47. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/getting-started/installation.md +0 -0
  48. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/getting-started/quickstart.md +0 -0
  49. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/index.md +0 -0
  50. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/advanced.md +0 -0
  51. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/basic-usage.md +0 -0
  52. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/financial-markets.md +0 -0
  53. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/fiscal.md +0 -0
  54. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/growth.md +0 -0
  55. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/interest-rates.md +0 -0
  56. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/money.md +0 -0
  57. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/prices.md +0 -0
  58. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/ecos_all_statistics.csv +0 -0
  59. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/ecos_implementation_status.csv +0 -0
  60. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/examples/basic_usage.py +0 -0
  61. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/examples/macro_dashboard.py +0 -0
  62. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/mkdocs.yml +0 -0
  63. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-item-list.md +0 -0
  64. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-meta.md +0 -0
  65. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-search.md +0 -0
  66. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-table-list.md +0 -0
  67. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-top-100.md +0 -0
  68. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-word.md +0 -0
  69. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/constants.py +0 -0
  70. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/__init__.py +0 -0
  71. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/_deprecations.py +0 -0
  72. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/logging.py +0 -0
  73. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/metrics.py +0 -0
  74. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/parser.py +0 -0
  75. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/py.typed +0 -0
  76. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/types.py +0 -0
  77. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/__init__.py +0 -0
  78. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/conftest.py +0 -0
  79. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/__init__.py +0 -0
  80. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_deprecations.py +0 -0
  81. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_growth.py +0 -0
  82. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_money.py +0 -0
  83. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_prices.py +0 -0
  84. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_config.py +0 -0
  85. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_e2e.py +0 -0
  86. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_e2e_indicators.py +0 -0
  87. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_parser.py +0 -0
  88. {ecos_reader-0.2.0 → ecos_reader-0.2.1}/uv.lock +0 -0
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-05-29
9
+
10
+ > 0.3.0 은 시그니처 breaking 재설계(epic #3)를 위해 예약돼 있어, 본 하위호환
11
+ > 기능 추가는 patch(0.2.1) 로 릴리스합니다.
12
+
13
+ ### Added
14
+ - `get_base_rate` 에 `frequency` 파라미터 추가 (`"M"` 기본 / `"D"`). ECOS 통계표
15
+ `722Y001` 은 일별 원천이라 `frequency="D"` 로 변경일 단위(sparse) 시계열을
16
+ 조회할 수 있습니다. `"D"` 지정 시 날짜 형식은 `YYYYMMDD`, 기본 조회기간도
17
+ 일별 포맷으로 산출합니다. 기본값 `"M"` 으로 기존 동작은 그대로 유지(하위호환).
18
+
19
+ ### Notes
20
+ - M2 평잔·계절조정(`161Y005`/`BBHS00`) 시계열은 기존
21
+ `get_m2_variants(variant="평잔_계절조정")` 로 이미 제공됩니다 (별도 추가 없음).
22
+
8
23
  ## [0.2.0] - 2026-05-29
9
24
 
10
25
  v0.1.6 라이브 e2e 검증에서 드러난 follow-up(#2 Reliability epic)을 정리한 릴리스.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecos-reader
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: 한국은행 ECOS Open API Python 클라이언트
5
5
  Project-URL: Homepage, https://github.com/choo121600/ecos-reader
6
6
  Project-URL: Documentation, https://choo121600.github.io/ecos-reader/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ecos-reader"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "한국은행 ECOS Open API Python 클라이언트"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,7 +20,7 @@ Examples
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
- __version__ = "0.2.0"
23
+ __version__ = "0.2.1"
24
24
  __author__ = "yeonguk"
25
25
 
26
26
  # Config API
@@ -38,6 +38,8 @@ from .exceptions import (
38
38
  EcosError,
39
39
  EcosNetworkError,
40
40
  EcosRateLimitError,
41
+ EcosServerError,
42
+ EcosValidationError,
41
43
  )
42
44
 
43
45
  # Indicator APIs
@@ -111,6 +113,8 @@ __all__ = [
111
113
  "EcosConfigError",
112
114
  "EcosNetworkError",
113
115
  "EcosRateLimitError",
116
+ "EcosServerError",
117
+ "EcosValidationError",
114
118
  # Warnings
115
119
  "EcosPartialCoverageWarning",
116
120
  # 재정 지표
@@ -8,7 +8,9 @@ from __future__ import annotations
8
8
 
9
9
  import hashlib
10
10
  import json
11
+ import threading
11
12
  import time
13
+ from collections import OrderedDict
12
14
  from dataclasses import dataclass
13
15
  from typing import Any
14
16
 
@@ -53,8 +55,11 @@ class Cache:
53
55
  def __init__(self, ttl: int = 3600, maxsize: int = 100):
54
56
  self.ttl = ttl
55
57
  self.maxsize = maxsize
56
- self._cache: dict[str, CacheEntry] = {}
57
- self._access_order: list[str] = []
58
+ # OrderedDict가 LRU 순서를 직접 보유하므로 O(1) move_to_end/popitem 사용.
59
+ # 별도 _access_order 리스트(O(n) remove)와의 비일관성을 제거한다.
60
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
61
+ # 재진입 가능 락: get()이 만료 시 invalidate()를 호출하므로 RLock 필요.
62
+ self._lock = threading.RLock()
58
63
  self._enabled: bool = True
59
64
 
60
65
  @property
@@ -103,26 +108,26 @@ class Cache:
103
108
  if not self._enabled:
104
109
  return None
105
110
 
106
- entry = self._cache.get(key)
107
- if entry is None:
108
- record_cache_miss()
109
- log_cache_operation("get", key, hit=False)
110
- return None
111
+ with self._lock:
112
+ entry = self._cache.get(key)
113
+ if entry is None:
114
+ record_cache_miss()
115
+ log_cache_operation("get", key, hit=False)
116
+ return None
111
117
 
112
- if entry.is_expired():
113
- self.invalidate(key)
114
- record_cache_miss()
115
- log_cache_operation("get", key, hit=False)
116
- return None
118
+ if entry.is_expired():
119
+ # 만료 항목 능동 제거 (maxsize 미만이어도 메모리 점유 방지)
120
+ self._cache.pop(key, None)
121
+ record_cache_miss()
122
+ log_cache_operation("get", key, hit=False)
123
+ return None
117
124
 
118
- # LRU: 접근된 항목을 최신으로 이동
119
- if key in self._access_order:
120
- self._access_order.remove(key)
121
- self._access_order.append(key)
125
+ # LRU: 접근된 항목을 최신으로 이동 (O(1))
126
+ self._cache.move_to_end(key)
122
127
 
123
- record_cache_hit()
124
- log_cache_operation("get", key, hit=True)
125
- return entry.value
128
+ record_cache_hit()
129
+ log_cache_operation("get", key, hit=True)
130
+ return entry.value
126
131
 
127
132
  def set(self, key: str, value: Any) -> None:
128
133
  """
@@ -138,19 +143,19 @@ class Cache:
138
143
  if not self._enabled:
139
144
  return
140
145
 
141
- # 최대 크기 초과 시 가장 오래된 항목 제거
142
- while len(self._cache) >= self.maxsize and self._access_order:
143
- oldest_key = self._access_order.pop(0)
144
- self._cache.pop(oldest_key, None)
145
-
146
- self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
147
-
148
- if key in self._access_order:
149
- self._access_order.remove(key)
150
- self._access_order.append(key)
146
+ with self._lock:
147
+ if key in self._cache:
148
+ # 기존 키 갱신: 값 교체 후 최신으로 이동, 퇴출 불필요
149
+ self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
150
+ self._cache.move_to_end(key)
151
+ else:
152
+ # 최대 크기 초과 시 가장 오래된 항목(맨 앞) 제거 (O(1))
153
+ while self._cache and len(self._cache) >= self.maxsize:
154
+ self._cache.popitem(last=False)
155
+ self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
151
156
 
152
- record_cache_set()
153
- log_cache_operation("set", key)
157
+ record_cache_set()
158
+ log_cache_operation("set", key)
154
159
 
155
160
  def invalidate(self, key: str) -> None:
156
161
  """
@@ -161,24 +166,26 @@ class Cache:
161
166
  key : str
162
167
  무효화할 캐시 키
163
168
  """
164
- self._cache.pop(key, None)
165
- if key in self._access_order:
166
- self._access_order.remove(key)
169
+ with self._lock:
170
+ self._cache.pop(key, None)
167
171
 
168
172
  def clear(self) -> None:
169
173
  """모든 캐시를 삭제합니다."""
170
- self._cache.clear()
171
- self._access_order.clear()
172
- record_cache_clear()
173
- log_cache_operation("clear", "")
174
+ with self._lock:
175
+ self._cache.clear()
176
+ record_cache_clear()
177
+ log_cache_operation("clear", "")
174
178
 
175
179
  def __len__(self) -> int:
176
180
  """캐시된 항목 수"""
177
- return len(self._cache)
181
+ with self._lock:
182
+ return len(self._cache)
178
183
 
179
184
  def __contains__(self, key: str) -> bool:
180
- """캐시 키 존재 여부"""
181
- return key in self._cache and not self._cache[key].is_expired()
185
+ """캐시 키 존재 여부 (만료 항목은 미포함으로 간주)"""
186
+ with self._lock:
187
+ entry = self._cache.get(key)
188
+ return entry is not None and not entry.is_expired()
182
189
 
183
190
 
184
191
  # 전역 캐시 인스턴스
@@ -12,13 +12,15 @@ from typing import Any, cast
12
12
  from urllib.parse import quote
13
13
 
14
14
  import requests
15
+ from requests.adapters import HTTPAdapter
16
+ from urllib3.util import Retry
15
17
 
16
18
  from .cache import Cache, get_cache
17
19
  from .config import Settings, get_api_key
18
20
  from .exceptions import (
21
+ ECOS_ERROR_CODES,
19
22
  RETRYABLE_ERROR_CODES,
20
23
  EcosAPIError,
21
- EcosConfigError,
22
24
  EcosNetworkError,
23
25
  EcosRateLimitError,
24
26
  )
@@ -68,9 +70,40 @@ class EcosClient:
68
70
  self.timeout = timeout
69
71
  self.max_retries = max_retries
70
72
  self.use_cache = use_cache
71
- self.session = requests.Session()
73
+ self.session = self._build_session()
72
74
  self._cache: Cache | None = get_cache() if use_cache else None
73
75
 
76
+ def _build_session(self) -> requests.Session:
77
+ """재시도 정책과 커넥션 풀이 구성된 Session을 생성합니다.
78
+
79
+ 전송 계층(urllib3.Retry)에서 처리하는 것:
80
+ - 5xx/429/408 및 연결/읽기 오류 재시도 (``status_forcelist``)
81
+ - 4xx(408/429 제외)는 ``status_forcelist`` 밖이므로 재시도하지 않음
82
+ - 429/503의 ``Retry-After`` 헤더 존중 (``respect_retry_after_header``)
83
+
84
+ ECOS 비즈니스 에러(HTTP 200 + ``RESULT.CODE``)는 이 계층에서 보이지
85
+ 않으므로 ``_make_request``의 애플리케이션 루프에서 별도로 재시도한다.
86
+ """
87
+ session = requests.Session()
88
+ retry = Retry(
89
+ total=self.max_retries,
90
+ backoff_factor=Settings.RETRY_BACKOFF_FACTOR,
91
+ status_forcelist=Settings.RETRY_STATUS_FORCELIST,
92
+ allowed_methods=frozenset({"GET"}),
93
+ respect_retry_after_header=True,
94
+ # 재시도 소진 후에도 마지막 응답을 그대로 반환받아 raise_for_status로
95
+ # 일관되게 변환한다 (urllib3가 MaxRetryError를 던지지 않도록).
96
+ raise_on_status=False,
97
+ )
98
+ adapter = HTTPAdapter(
99
+ max_retries=retry,
100
+ pool_connections=Settings.POOL_CONNECTIONS,
101
+ pool_maxsize=Settings.POOL_MAXSIZE,
102
+ )
103
+ session.mount("https://", adapter)
104
+ session.mount("http://", adapter)
105
+ return session
106
+
74
107
  def _get_api_key(self) -> str:
75
108
  """
76
109
  요청 시점의 API Key를 반환합니다.
@@ -155,6 +188,9 @@ class EcosClient:
155
188
  EcosRateLimitError
156
189
  Rate Limit 초과 시
157
190
  """
191
+ # 전송 계층(urllib3.Retry)이 5xx/429/408·연결/읽기 오류를 이미 재시도한다.
192
+ # 이 루프는 HTTP 200으로 내려오는 ECOS 비즈니스 에러(ERROR-500/600/602 등
193
+ # RETRYABLE_ERROR_CODES)만 추가로 재시도한다.
158
194
  last_exception: Exception | None = None
159
195
 
160
196
  for attempt in range(self.max_retries):
@@ -170,38 +206,35 @@ class EcosClient:
170
206
  logger.debug(f"API 응답 성공: {len(data)} 바이트 수신")
171
207
  return data
172
208
 
173
- except requests.exceptions.Timeout:
174
- last_exception = EcosNetworkError(f"요청 타임아웃 ({self.timeout}초)")
175
- if attempt < self.max_retries - 1:
176
- log_retry_attempt(attempt + 1, self.max_retries, last_exception)
209
+ except requests.exceptions.Timeout as e:
210
+ # 읽기 타임아웃: urllib3 재시도까지 소진된 상태이므로 즉시 변환
211
+ raise EcosNetworkError(f"요청 타임아웃 ({self.timeout}초)") from e
177
212
 
178
213
  except requests.exceptions.ConnectionError as e:
179
- # urllib3 에러 메시지에 raw URL(api_key 포함) 끼워넣는 경우가 있어 마스킹
180
- last_exception = EcosNetworkError(f"네트워크 연결 오류: {mask_api_key(str(e))}")
181
- if attempt < self.max_retries - 1:
182
- log_retry_attempt(attempt + 1, self.max_retries, last_exception)
214
+ # 연결 오류: urllib3 재시도 소진 후 도달. raw URL(api_key) 섞일
215
+ # 있어 마스킹.
216
+ raise EcosNetworkError(f"네트워크 연결 오류: {mask_api_key(str(e))}") from e
183
217
 
184
218
  except requests.exceptions.HTTPError as e:
185
- # requests의 HTTPError"... for url: <full URL with key>" 형식이므로 마스킹 필수
186
- last_exception = EcosNetworkError(f"HTTP 오류: {mask_api_key(str(e))}")
187
- if attempt < self.max_retries - 1:
188
- log_retry_attempt(attempt + 1, self.max_retries, last_exception)
219
+ # 4xx(408/429 제외)status_forcelist 밖이라 재시도 없이 즉시 도달하고,
220
+ # 5xx/429는 어댑터가 재시도를 소진한 최종 응답이다. 어느 쪽이든 즉시 raise.
221
+ # requests HTTPError str()에 full URL(key 포함)이 들어가므로 마스킹 필수.
222
+ raise EcosNetworkError(f"HTTP 오류: {mask_api_key(str(e))}") from e
189
223
 
190
224
  except (EcosAPIError, EcosRateLimitError) as e:
191
- # 재시도 가능한 에러인지 확인
225
+ # 비즈니스 에러: 재시도 대상이 아니면 즉시 raise
192
226
  error_key = f"ERROR-{e.code}" if hasattr(e, "code") else ""
193
- if error_key in RETRYABLE_ERROR_CODES:
194
- last_exception = e
195
- if attempt < self.max_retries - 1:
196
- log_retry_attempt(attempt + 1, self.max_retries, last_exception)
197
- else:
227
+ if error_key not in RETRYABLE_ERROR_CODES:
198
228
  raise
199
229
 
200
- # 재시도 전 대기 (exponential backoff)
201
- if attempt < self.max_retries - 1:
202
- wait_time = Settings.RETRY_BACKOFF_FACTOR * (2**attempt)
203
- logger.debug(f"재시도 대기: {wait_time:.2f}초")
204
- time.sleep(wait_time)
230
+ last_exception = e
231
+ if attempt < self.max_retries - 1:
232
+ log_retry_attempt(attempt + 1, self.max_retries, e)
233
+ # 비즈니스 에러는 HTTP 200이라 Retry-After 헤더가 없으므로
234
+ # exponential backoff 적용
235
+ wait_time = Settings.RETRY_BACKOFF_FACTOR * (2**attempt)
236
+ logger.debug(f"재시도 전 대기: {wait_time:.2f}초")
237
+ time.sleep(wait_time)
205
238
 
206
239
  # 모든 재시도 실패
207
240
  if last_exception:
@@ -232,20 +265,30 @@ class EcosClient:
232
265
  if code == "INFO-200":
233
266
  return
234
267
 
235
- # 정보 코드 100: 인증키 오류
236
- if code == "INFO-100":
237
- log_error_response(code, message, url)
238
- raise EcosConfigError(message)
239
-
240
- # 에러 코드
241
- if code.startswith("ERROR"):
242
- error_num = code.split("-")[-1] if "-" in code else code.replace("ERROR", "")
243
- log_error_response(code, message, url)
244
-
245
- if error_num == "602":
246
- raise EcosRateLimitError(message)
268
+ # 카탈로그(ECOS_ERROR_CODES) 단일 조회로 코드별 예외 결정
269
+ mapping = ECOS_ERROR_CODES.get(code)
270
+ if mapping is None:
271
+ # 카탈로그 미등록 코드: ERROR 접두는 일반 API 에러로, 그 외는 정상 처리
272
+ if code.startswith("ERROR"):
273
+ log_error_response(code, message, url)
274
+ raise EcosAPIError(self._error_num(code), message)
275
+ return
247
276
 
248
- raise EcosAPIError(error_num, message)
277
+ exc_class, default_message, _retryable = mapping
278
+ log_error_response(code, message, url)
279
+ msg = message or default_message
280
+
281
+ # EcosAPIError 계열은 (code, message) 시그니처, 그 외(Config/RateLimit)는 (message)
282
+ if issubclass(exc_class, EcosAPIError):
283
+ raise exc_class(self._error_num(code), msg)
284
+ raise exc_class(msg)
285
+
286
+ @staticmethod
287
+ def _error_num(code: str) -> str:
288
+ """``ERROR-100`` → ``100``처럼 코드에서 숫자 부분을 추출합니다."""
289
+ if "-" in code:
290
+ return code.split("-", 1)[-1]
291
+ return code.replace("ERROR", "")
249
292
 
250
293
  def _cached_request(
251
294
  self,
@@ -139,6 +139,12 @@ class Settings:
139
139
  DEFAULT_TIMEOUT: int = 30 # 초
140
140
  MAX_RETRIES: int = 3
141
141
  RETRY_BACKOFF_FACTOR: float = 1.0
142
+ # 재시도 대상 HTTP 상태 코드. 4xx 중에서는 408(timeout)/429(rate limit)만 포함하고
143
+ # 나머지 4xx(클라이언트 오류)는 재시도하지 않는다.
144
+ RETRY_STATUS_FORCELIST: tuple[int, ...] = (408, 429, 500, 502, 503, 504)
145
+ # 커넥션 풀 크기 (대량 동시 요청 시 기본값 10 병목 완화)
146
+ POOL_CONNECTIONS: int = 10
147
+ POOL_MAXSIZE: int = 20
142
148
 
143
149
  # 캐시 설정
144
150
  CACHE_TTL: int = 3600 # 1시간
@@ -0,0 +1,102 @@
1
+ """
2
+ ecos-reader 예외 클래스 정의
3
+
4
+ ECOS API 에러 및 라이브러리 예외를 처리합니다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class EcosError(Exception):
11
+ """ecos-reader 기본 예외"""
12
+
13
+ pass
14
+
15
+
16
+ class EcosAPIError(EcosError):
17
+ """ECOS API 호출 에러
18
+
19
+ Attributes
20
+ ----------
21
+ code : str
22
+ ECOS API 에러 코드
23
+ message : str
24
+ 에러 메시지
25
+ """
26
+
27
+ def __init__(self, code: str, message: str):
28
+ self.code = code
29
+ self.message = message
30
+ super().__init__(f"[{code}] {message}")
31
+
32
+
33
+ class EcosConfigError(EcosError):
34
+ """설정 관련 에러 (API Key 등)"""
35
+
36
+ pass
37
+
38
+
39
+ class EcosNetworkError(EcosError):
40
+ """네트워크 연결 에러"""
41
+
42
+ pass
43
+
44
+
45
+ class EcosValidationError(EcosAPIError):
46
+ """클라이언트 책임 요청 오류 (4xx 성격)
47
+
48
+ 필수값 누락·형식/타입 오류 등 요청 자체가 잘못된 경우. 동일 요청을
49
+ 재시도해도 동일하게 실패하므로 재시도 대상이 아닙니다.
50
+ """
51
+
52
+ pass
53
+
54
+
55
+ class EcosServerError(EcosAPIError):
56
+ """ECOS 서버측 오류 (5xx 성격)
57
+
58
+ 서버 오류·DB Connection·SQL 오류 등. 일시적 오류(서버/DB)는 재시도
59
+ 대상일 수 있습니다 (`ECOS_ERROR_CODES`의 retryable 플래그 참조).
60
+ """
61
+
62
+ pass
63
+
64
+
65
+ class EcosRateLimitError(EcosError):
66
+ """Rate Limit 초과"""
67
+
68
+ def __init__(self, message: str, code: str = "602"):
69
+ self.code = code
70
+ self.message = message
71
+ super().__init__(f"[{code}] {message}")
72
+
73
+
74
+ # ECOS 에러 코드 단일 진실원천 (Single Source of Truth)
75
+ #
76
+ # 키는 ECOS API가 실제로 반환하는 하이픈 형식("ERROR-602")으로 통일한다.
77
+ # 값은 (예외 클래스, 기본 메시지, 재시도 가능 여부) 3-튜플이다.
78
+ # 클라이언트는 이 매핑을 조회해 코드별 예외를 일관되게 발생시키고,
79
+ # RETRYABLE_ERROR_CODES도 여기서 파생한다.
80
+ ECOS_ERROR_CODES: dict[str, tuple[type[EcosError], str, bool]] = {
81
+ # 정보 코드
82
+ "INFO-100": (EcosConfigError, "인증키가 유효하지 않습니다.", False),
83
+ "INFO-200": (EcosAPIError, "해당하는 데이터가 없습니다.", False), # 빈 결과로 처리
84
+ # 클라이언트 책임(요청 검증) 에러 — 재시도해도 동일 실패
85
+ "ERROR-100": (EcosValidationError, "필수 값이 누락되어 있습니다.", False),
86
+ "ERROR-101": (EcosValidationError, "주기와 다른 형식의 날짜 형식입니다.", False),
87
+ "ERROR-200": (EcosValidationError, "파일타입 값이 누락 혹은 유효하지 않습니다.", False),
88
+ "ERROR-300": (EcosValidationError, "조회건수 값이 누락되어 있습니다.", False),
89
+ "ERROR-301": (EcosValidationError, "조회건수 값의 타입이 유효하지 않습니다.", False),
90
+ "ERROR-400": (EcosAPIError, "검색범위가 적정범위를 초과하여 TIMEOUT이 발생하였습니다.", False),
91
+ # 서버측 에러 — 서버/DB는 일시적일 수 있어 재시도, SQL 오류는 비재시도
92
+ "ERROR-500": (EcosServerError, "서버 오류입니다.", True),
93
+ "ERROR-600": (EcosServerError, "DB Connection 오류입니다.", True),
94
+ "ERROR-601": (EcosServerError, "SQL 오류입니다.", False),
95
+ # Rate limit
96
+ "ERROR-602": (EcosRateLimitError, "과도한 OpenAPI 호출로 이용이 제한되었습니다.", True),
97
+ }
98
+
99
+ # 재시도 가능한 에러 코드 — ECOS_ERROR_CODES의 retryable 플래그에서 파생
100
+ RETRYABLE_ERROR_CODES: frozenset[str] = frozenset(
101
+ code for code, (_exc, _msg, retryable) in ECOS_ERROR_CODES.items() if retryable
102
+ )
@@ -0,0 +1,64 @@
1
+ """
2
+ indicators 공용 날짜 헬퍼.
3
+
4
+ 기본 조회 기간(시작/종료)을 빈도별로 계산하는 단일 진실원천 모듈입니다.
5
+ 모든 함수는 ECOS API가 요구하는 문자열 형식을 반환합니다:
6
+
7
+ - ``default_daily`` → ``YYYYMMDD``
8
+ - ``default_monthly`` → ``YYYYMM``
9
+ - ``default_quarterly`` → ``YYYYQN``
10
+ - ``default_annual`` → ``YYYY``
11
+
12
+ 각 함수는 ``today`` 인자로 기준일을 주입할 수 있어 테스트에서 결정적으로
13
+ 동작합니다 (기본값은 ``datetime.now()``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime, timedelta
19
+
20
+
21
+ def _reference(today: datetime | None) -> datetime:
22
+ """기준일을 반환합니다. ``None``이면 현재 시각."""
23
+ return today if today is not None else datetime.now()
24
+
25
+
26
+ def default_daily(days_back: int = 365, *, today: datetime | None = None) -> tuple[str, str]:
27
+ """일별 기본 조회 기간을 ``YYYYMMDD`` 형식으로 반환합니다.
28
+
29
+ ``timedelta`` 기반이므로 윤년 2/29 경계에서도
30
+ ``ValueError: day is out of range`` 없이 안전합니다.
31
+ """
32
+ end = _reference(today)
33
+ start = end - timedelta(days=days_back)
34
+ return start.strftime("%Y%m%d"), end.strftime("%Y%m%d")
35
+
36
+
37
+ def default_monthly(months_back: int = 12, *, today: datetime | None = None) -> tuple[str, str]:
38
+ """월별 기본 조회 기간을 ``YYYYMM`` 형식으로 반환합니다."""
39
+ end = _reference(today)
40
+
41
+ total_months = end.year * 12 + end.month
42
+ start_total_months = total_months - months_back
43
+
44
+ start_year = (start_total_months - 1) // 12
45
+ start_month = (start_total_months - 1) % 12 + 1
46
+
47
+ return f"{start_year}{start_month:02d}", f"{end.year}{end.month:02d}"
48
+
49
+
50
+ def default_quarterly(years_back: int = 5, *, today: datetime | None = None) -> tuple[str, str]:
51
+ """분기별 기본 조회 기간을 ``YYYYQN`` 형식으로 반환합니다.
52
+
53
+ 시작은 ``years_back`` 년 전의 1분기, 종료는 기준일이 속한 분기입니다.
54
+ """
55
+ end = _reference(today)
56
+ start_year = end.year - years_back
57
+ current_quarter = (end.month - 1) // 3 + 1
58
+ return f"{start_year}Q1", f"{end.year}Q{current_quarter}"
59
+
60
+
61
+ def default_annual(years_back: int = 10, *, today: datetime | None = None) -> tuple[str, str]:
62
+ """연간 기본 조회 기간을 ``YYYY`` 형식으로 반환합니다."""
63
+ end = _reference(today)
64
+ return str(end.year - years_back), str(end.year)
@@ -6,7 +6,6 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from datetime import datetime
10
9
  from typing import Literal
11
10
 
12
11
  import pandas as pd
@@ -18,27 +17,10 @@ from ..constants import (
18
17
  STAT_BOND_YIELD_TYPE,
19
18
  )
20
19
  from ..parser import normalize_stat_result, parse_response
20
+ from ._dates import default_monthly
21
21
  from ._deprecations import warn_partial_coverage as _warn_partial_coverage
22
22
 
23
23
 
24
- def _get_default_dates(months_back: int = 24) -> tuple[str, str]:
25
- """기본 조회 기간을 반환합니다 (기본 2년)."""
26
- end_date = datetime.now()
27
-
28
- # 총 개월 수 계산
29
- total_months = end_date.year * 12 + end_date.month
30
- start_total_months = total_months - months_back
31
-
32
- # 연도와 월 계산
33
- start_year = (start_total_months - 1) // 12
34
- start_month = (start_total_months - 1) % 12 + 1
35
-
36
- start_str = f"{start_year}{start_month:02d}"
37
- end_str = f"{end_date.year}{end_date.month:02d}"
38
-
39
- return start_str, end_str
40
-
41
-
42
24
  def get_bond_yield(
43
25
  bond_type: Literal["종류별", "시장별"] = "종류별",
44
26
  start_date: str | None = None,
@@ -106,7 +88,7 @@ def get_bond_yield(
106
88
 
107
89
  # 기본 날짜 설정
108
90
  if start_date is None or end_date is None:
109
- default_start, default_end = _get_default_dates(24)
91
+ default_start, default_end = default_monthly(24)
110
92
  start_date = start_date or default_start
111
93
  end_date = end_date or default_end
112
94
 
@@ -6,8 +6,6 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from datetime import datetime
10
-
11
9
  import pandas as pd
12
10
 
13
11
  from ..client import get_client
@@ -17,24 +15,7 @@ from ..constants import (
17
15
  STAT_FISCAL_BALANCE,
18
16
  )
19
17
  from ..parser import normalize_stat_result, parse_response
20
-
21
-
22
- def _get_default_dates(months_back: int = 24) -> tuple[str, str]:
23
- """기본 조회 기간을 반환합니다 (기본 2년)."""
24
- end_date = datetime.now()
25
-
26
- # 총 개월 수 계산
27
- total_months = end_date.year * 12 + end_date.month
28
- start_total_months = total_months - months_back
29
-
30
- # 연도와 월 계산
31
- start_year = (start_total_months - 1) // 12
32
- start_month = (start_total_months - 1) % 12 + 1
33
-
34
- start_str = f"{start_year}{start_month:02d}"
35
- end_str = f"{end_date.year}{end_date.month:02d}"
36
-
37
- return start_str, end_str
18
+ from ._dates import default_monthly
38
19
 
39
20
 
40
21
  def get_fiscal_balance(
@@ -84,7 +65,7 @@ def get_fiscal_balance(
84
65
  """
85
66
  # 기본 날짜 설정
86
67
  if start_date is None or end_date is None:
87
- default_start, default_end = _get_default_dates(24)
68
+ default_start, default_end = default_monthly(24)
88
69
  start_date = start_date or default_start
89
70
  end_date = end_date or default_end
90
71