cluefin-openapi 0.1.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.
- cluefin_openapi/__init__.py +2 -0
- cluefin_openapi/kiwoom/__init__.py +31 -0
- cluefin_openapi/kiwoom/_auth.py +96 -0
- cluefin_openapi/kiwoom/_auth_types.py +18 -0
- cluefin_openapi/kiwoom/_cache.py +130 -0
- cluefin_openapi/kiwoom/_client.py +386 -0
- cluefin_openapi/kiwoom/_domestic_account.py +1129 -0
- cluefin_openapi/kiwoom/_domestic_account_types.py +937 -0
- cluefin_openapi/kiwoom/_domestic_chart.py +587 -0
- cluefin_openapi/kiwoom/_domestic_chart_types.py +1072 -0
- cluefin_openapi/kiwoom/_domestic_credit_order.py +35 -0
- cluefin_openapi/kiwoom/_domestic_etf.py +322 -0
- cluefin_openapi/kiwoom/_domestic_etf_types.py +188 -0
- cluefin_openapi/kiwoom/_domestic_foreign.py +131 -0
- cluefin_openapi/kiwoom/_domestic_foreign_types.py +70 -0
- cluefin_openapi/kiwoom/_domestic_market_condition.py +909 -0
- cluefin_openapi/kiwoom/_domestic_market_condition_types.py +672 -0
- cluefin_openapi/kiwoom/_domestic_order.py +262 -0
- cluefin_openapi/kiwoom/_domestic_order_types.py +38 -0
- cluefin_openapi/kiwoom/_domestic_rank_info.py +1495 -0
- cluefin_openapi/kiwoom/_domestic_rank_info_types.py +554 -0
- cluefin_openapi/kiwoom/_domestic_realtime.py +22 -0
- cluefin_openapi/kiwoom/_domestic_sector.py +244 -0
- cluefin_openapi/kiwoom/_domestic_sector_types.py +192 -0
- cluefin_openapi/kiwoom/_domestic_stock_info.py +1280 -0
- cluefin_openapi/kiwoom/_domestic_stock_info_types.py +752 -0
- cluefin_openapi/kiwoom/_domestic_theme.py +101 -0
- cluefin_openapi/kiwoom/_domestic_theme_types.py +45 -0
- cluefin_openapi/kiwoom/_exceptions.py +77 -0
- cluefin_openapi/kiwoom/_model.py +28 -0
- cluefin_openapi/kiwoom/_rate_limiter.py +90 -0
- cluefin_openapi/kiwoom/py.typed +0 -0
- cluefin_openapi-0.1.0.dist-info/METADATA +65 -0
- cluefin_openapi-0.1.0.dist-info/RECORD +35 -0
- cluefin_openapi-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Cluefin Kiwoom API Client Package."""
|
|
2
|
+
|
|
3
|
+
from ._auth import Auth
|
|
4
|
+
from ._client import Client
|
|
5
|
+
from ._exceptions import (
|
|
6
|
+
KiwoomAPIError,
|
|
7
|
+
KiwoomAuthenticationError,
|
|
8
|
+
KiwoomAuthorizationError,
|
|
9
|
+
KiwoomNetworkError,
|
|
10
|
+
KiwoomRateLimitError,
|
|
11
|
+
KiwoomServerError,
|
|
12
|
+
KiwoomTimeoutError,
|
|
13
|
+
KiwoomValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Auth",
|
|
18
|
+
"Client",
|
|
19
|
+
"KiwoomAPIError",
|
|
20
|
+
"KiwoomAuthenticationError",
|
|
21
|
+
"KiwoomAuthorizationError",
|
|
22
|
+
"KiwoomNetworkError",
|
|
23
|
+
"KiwoomRateLimitError",
|
|
24
|
+
"KiwoomServerError",
|
|
25
|
+
"KiwoomTimeoutError",
|
|
26
|
+
"KiwoomValidationError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hello() -> str:
|
|
31
|
+
return "Hello from cluefin-kiwoom!"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Authentication module for Kiwoom API.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for generating and revoking API tokens
|
|
4
|
+
using client credentials authentication flow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from ._auth_types import TokenResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Auth:
|
|
15
|
+
"""Initialize the Auth client.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
app_key: The application key provided by Kiwoom.
|
|
19
|
+
secret_key: The secret key provided by Kiwoom.
|
|
20
|
+
env: The environment to use. Either "dev" or "prod".
|
|
21
|
+
Defaults to "dev".
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If an invalid environment is provided.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, app_key: str, secret_key: str, env: Literal["dev", "prod"] = "dev") -> None:
|
|
28
|
+
self.app_key = app_key
|
|
29
|
+
self.secret_key = secret_key
|
|
30
|
+
|
|
31
|
+
if env == "dev":
|
|
32
|
+
self.url = "https://mockapi.kiwoom.com"
|
|
33
|
+
elif env == "prod":
|
|
34
|
+
self.url = "https://api.kiwoom.com"
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError("Invalid environment. Must be either 'dev' or 'prod'.")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def token(self) -> TokenResponse:
|
|
40
|
+
"""Get the current token data.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
TokenResponse: The current token data.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
AttributeError: If no token has been generated yet.
|
|
47
|
+
"""
|
|
48
|
+
return self._token_data
|
|
49
|
+
|
|
50
|
+
def generate_token(self) -> TokenResponse:
|
|
51
|
+
"""Generate a new access token.
|
|
52
|
+
|
|
53
|
+
Calls the Kiwoom OAuth2 token endpoint to generate a new access token
|
|
54
|
+
using the client credentials flow.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
TokenResponse: The generated token data including access token
|
|
58
|
+
and expiration.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
requests.exceptions.HTTPError: If the API request fails.
|
|
62
|
+
"""
|
|
63
|
+
headers = {
|
|
64
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
65
|
+
}
|
|
66
|
+
data = {"grant_type": "client_credentials", "appkey": self.app_key, "secretkey": self.secret_key}
|
|
67
|
+
|
|
68
|
+
response = requests.post(f"{self.url}/oauth2/token", headers=headers, json=data)
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
|
|
71
|
+
token_data = TokenResponse(**response.json())
|
|
72
|
+
self._token_data = token_data
|
|
73
|
+
|
|
74
|
+
return self._token_data
|
|
75
|
+
|
|
76
|
+
def revoke_token(self, token: str) -> bool:
|
|
77
|
+
"""Revoke an access token.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
token: The token to revoke.
|
|
81
|
+
Returns:
|
|
82
|
+
bool: True if the token was successfully revoked.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
requests.exceptions.HTTPError: If the API request fails.
|
|
86
|
+
"""
|
|
87
|
+
headers = {
|
|
88
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
data = {"appkey": self.app_key, "secretkey": self.secret_key, "token": token}
|
|
92
|
+
|
|
93
|
+
response = requests.post(f"{self.url}/oauth2/revoke", headers=headers, json=data)
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
|
|
96
|
+
return True
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TokenResponse(BaseModel):
|
|
7
|
+
expires_dt: datetime
|
|
8
|
+
token_type: str
|
|
9
|
+
token: str
|
|
10
|
+
|
|
11
|
+
@field_validator("expires_dt", mode="before")
|
|
12
|
+
def parse_expires_dt(cls, v):
|
|
13
|
+
if isinstance(v, str):
|
|
14
|
+
try:
|
|
15
|
+
return datetime.strptime(v, "%Y%m%d%H%M%S")
|
|
16
|
+
except ValueError:
|
|
17
|
+
pass # 다른 형식도 추가 가능
|
|
18
|
+
return v
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Caching utilities for Kiwoom API client."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SimpleCache:
|
|
9
|
+
"""Simple in-memory cache with TTL support."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, default_ttl: int = 300):
|
|
12
|
+
"""
|
|
13
|
+
Initialize cache.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
default_ttl: Default time-to-live in seconds (default: 5 minutes)
|
|
17
|
+
"""
|
|
18
|
+
self.default_ttl = default_ttl
|
|
19
|
+
self._cache: Dict[str, Dict[str, Any]] = {}
|
|
20
|
+
|
|
21
|
+
def get(self, key: str) -> Optional[Any]:
|
|
22
|
+
"""
|
|
23
|
+
Get value from cache.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
key: Cache key
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Cached value or None if not found/expired
|
|
30
|
+
"""
|
|
31
|
+
if key in self._cache:
|
|
32
|
+
entry = self._cache[key]
|
|
33
|
+
if time.time() < entry["expires_at"]:
|
|
34
|
+
return entry["value"]
|
|
35
|
+
else:
|
|
36
|
+
# Remove expired entry
|
|
37
|
+
del self._cache[key]
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Set value in cache.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
key: Cache key
|
|
46
|
+
value: Value to cache
|
|
47
|
+
ttl: Time-to-live in seconds (uses default if None)
|
|
48
|
+
"""
|
|
49
|
+
if ttl is None:
|
|
50
|
+
ttl = self.default_ttl
|
|
51
|
+
|
|
52
|
+
self._cache[key] = {
|
|
53
|
+
"value": value,
|
|
54
|
+
"expires_at": time.time() + ttl,
|
|
55
|
+
"created_at": time.time(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def delete(self, key: str) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Delete key from cache.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: Cache key
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if key was deleted, False if not found
|
|
67
|
+
"""
|
|
68
|
+
if key in self._cache:
|
|
69
|
+
del self._cache[key]
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def clear(self) -> None:
|
|
74
|
+
"""Clear all cached entries."""
|
|
75
|
+
self._cache.clear()
|
|
76
|
+
|
|
77
|
+
def cleanup_expired(self) -> int:
|
|
78
|
+
"""
|
|
79
|
+
Remove expired entries from cache.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Number of entries removed
|
|
83
|
+
"""
|
|
84
|
+
current_time = time.time()
|
|
85
|
+
expired_keys = [key for key, entry in self._cache.items() if current_time >= entry["expires_at"]]
|
|
86
|
+
|
|
87
|
+
for key in expired_keys:
|
|
88
|
+
del self._cache[key]
|
|
89
|
+
|
|
90
|
+
return len(expired_keys)
|
|
91
|
+
|
|
92
|
+
def cache_info(self) -> Dict[str, Any]:
|
|
93
|
+
"""Get cache statistics."""
|
|
94
|
+
current_time = time.time()
|
|
95
|
+
valid_entries = sum(1 for entry in self._cache.values() if current_time < entry["expires_at"])
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"total_entries": len(self._cache),
|
|
99
|
+
"valid_entries": valid_entries,
|
|
100
|
+
"expired_entries": len(self._cache) - valid_entries,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def create_cache_key(url: str, headers: Dict[str, str], body: Dict[str, Any]) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Create a cache key from request parameters.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
url: Request URL
|
|
110
|
+
headers: Request headers (excluding auth and dynamic headers)
|
|
111
|
+
body: Request body
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Cache key string
|
|
115
|
+
"""
|
|
116
|
+
# Filter out dynamic headers that shouldn't affect caching
|
|
117
|
+
cacheable_headers = {
|
|
118
|
+
k: v for k, v in headers.items() if k.lower() not in ["authorization", "user-agent", "content-length"]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Create a deterministic string representation
|
|
122
|
+
cache_data = {
|
|
123
|
+
"url": url,
|
|
124
|
+
"headers": sorted(cacheable_headers.items()),
|
|
125
|
+
"body": sorted(body.items()) if isinstance(body, dict) else body,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Create hash of the cache data
|
|
129
|
+
cache_string = str(cache_data)
|
|
130
|
+
return hashlib.sha256(cache_string.encode()).hexdigest()[:16] # First 16 chars for brevity
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, List, Literal, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from ._cache import SimpleCache, create_cache_key
|
|
9
|
+
from ._exceptions import (
|
|
10
|
+
KiwoomAPIError,
|
|
11
|
+
KiwoomAuthenticationError,
|
|
12
|
+
KiwoomAuthorizationError,
|
|
13
|
+
KiwoomNetworkError,
|
|
14
|
+
KiwoomRateLimitError,
|
|
15
|
+
KiwoomServerError,
|
|
16
|
+
KiwoomTimeoutError,
|
|
17
|
+
KiwoomValidationError,
|
|
18
|
+
)
|
|
19
|
+
from ._rate_limiter import TokenBucket
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MockResponse:
|
|
23
|
+
"""Mock response object for caching."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int, headers: dict, content: bytes, json_data: Optional[dict] = None):
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.headers = headers
|
|
28
|
+
self.content = content
|
|
29
|
+
self._json_data = json_data
|
|
30
|
+
|
|
31
|
+
def json(self):
|
|
32
|
+
if self._json_data is not None:
|
|
33
|
+
return self._json_data
|
|
34
|
+
return json.loads(self.content.decode())
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def text(self):
|
|
38
|
+
return self.content.decode()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client(object):
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
token: str,
|
|
45
|
+
env: Literal["dev", "prod"],
|
|
46
|
+
timeout: int = 30,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
debug: bool = False,
|
|
49
|
+
rate_limit_requests_per_second: float = 10.0,
|
|
50
|
+
rate_limit_burst: int = 20,
|
|
51
|
+
enable_caching: bool = False,
|
|
52
|
+
cache_ttl: int = 300,
|
|
53
|
+
):
|
|
54
|
+
self.token = token
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.max_retries = max_retries
|
|
57
|
+
self.debug = debug
|
|
58
|
+
self.enable_caching = enable_caching
|
|
59
|
+
|
|
60
|
+
if env == "dev":
|
|
61
|
+
self.url = "https://mockapi.kiwoom.com"
|
|
62
|
+
elif env == "prod":
|
|
63
|
+
self.url = "https://api.kiwoom.com"
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError("Invalid environment")
|
|
66
|
+
|
|
67
|
+
# Create a reusable session for connection pooling
|
|
68
|
+
self._session = requests.Session()
|
|
69
|
+
|
|
70
|
+
# Set common headers for all requests
|
|
71
|
+
self._session.headers.update(
|
|
72
|
+
{
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
"Accept": "application/json",
|
|
75
|
+
"User-Agent": "cluefin-kiwoom/0.1.0",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Initialize rate limiter
|
|
80
|
+
self._rate_limiter = TokenBucket(capacity=rate_limit_burst, refill_rate=rate_limit_requests_per_second)
|
|
81
|
+
|
|
82
|
+
# Initialize cache if enabled
|
|
83
|
+
self._cache = SimpleCache(default_ttl=cache_ttl) if enable_caching else None
|
|
84
|
+
|
|
85
|
+
# Configure logging
|
|
86
|
+
if self.debug:
|
|
87
|
+
logger.enable("cluefin_openapi.kiwoom")
|
|
88
|
+
else:
|
|
89
|
+
logger.disable("cluefin_openapi.kiwoom")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def account(self):
|
|
93
|
+
from ._domestic_account import DomesticAccount
|
|
94
|
+
|
|
95
|
+
return DomesticAccount(self)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def chart(self):
|
|
99
|
+
from ._domestic_chart import DomesticChart
|
|
100
|
+
|
|
101
|
+
return DomesticChart(self)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def credit_order(self):
|
|
105
|
+
from ._domestic_credit_order import DomesticCreditOrder
|
|
106
|
+
|
|
107
|
+
return DomesticCreditOrder(self)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def etf(self):
|
|
111
|
+
from ._domestic_etf import DomesticETF
|
|
112
|
+
|
|
113
|
+
return DomesticETF(self)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def foreign(self):
|
|
117
|
+
from ._domestic_foreign import DomesticForeign
|
|
118
|
+
|
|
119
|
+
return DomesticForeign(self)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def market_conditions(self):
|
|
123
|
+
from ._domestic_market_condition import DomesticMarketCondition
|
|
124
|
+
|
|
125
|
+
return DomesticMarketCondition(self)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def order(self):
|
|
129
|
+
from ._domestic_order import DomesticOrder
|
|
130
|
+
|
|
131
|
+
return DomesticOrder(self)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def rank_info(self):
|
|
135
|
+
from ._domestic_rank_info import DomesticRankInfo
|
|
136
|
+
|
|
137
|
+
return DomesticRankInfo(self)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def sector(self):
|
|
141
|
+
from ._domestic_sector import DomesticSector
|
|
142
|
+
|
|
143
|
+
return DomesticSector(self)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def stock_info(self):
|
|
147
|
+
from ._domestic_stock_info import DomesticStockInfo
|
|
148
|
+
|
|
149
|
+
return DomesticStockInfo(self)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def theme(self):
|
|
153
|
+
from ._domestic_theme import DomesticTheme
|
|
154
|
+
|
|
155
|
+
return DomesticTheme(self)
|
|
156
|
+
|
|
157
|
+
def _post(self, path: str, headers: dict, body: dict, use_cache: bool = True):
|
|
158
|
+
"""Make a POST request with improved error handling and logging."""
|
|
159
|
+
# Check cache first if enabled
|
|
160
|
+
cache_key = None
|
|
161
|
+
if self._cache and use_cache:
|
|
162
|
+
cache_key = create_cache_key(f"{self.url}{path}", headers, body)
|
|
163
|
+
cached_response = self._cache.get(cache_key)
|
|
164
|
+
if cached_response:
|
|
165
|
+
if self.debug:
|
|
166
|
+
logger.debug(f"Cache hit for {path}")
|
|
167
|
+
return cached_response
|
|
168
|
+
|
|
169
|
+
# Apply rate limiting
|
|
170
|
+
if not self._rate_limiter.wait_for_tokens(timeout=self.timeout):
|
|
171
|
+
raise KiwoomRateLimitError(
|
|
172
|
+
"Rate limit timeout - could not acquire token within timeout period",
|
|
173
|
+
request_context={"url": f"{self.url}{path}", "path": path},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
url = f"{self.url}{path}"
|
|
177
|
+
|
|
178
|
+
# Merge headers with authentication
|
|
179
|
+
merged_headers = headers.copy()
|
|
180
|
+
merged_headers["Authorization"] = f"Bearer {self.token}"
|
|
181
|
+
|
|
182
|
+
# Log request details in debug mode
|
|
183
|
+
if self.debug:
|
|
184
|
+
logger.debug(f"Making POST request to {url}")
|
|
185
|
+
logger.debug(f"Headers: {merged_headers}")
|
|
186
|
+
logger.debug(f"Body: {body}")
|
|
187
|
+
logger.debug(f"Rate limiter tokens available: {self._rate_limiter.available_tokens:.2f}")
|
|
188
|
+
|
|
189
|
+
request_context = {
|
|
190
|
+
"url": url,
|
|
191
|
+
"path": path,
|
|
192
|
+
"method": "POST",
|
|
193
|
+
"headers": merged_headers,
|
|
194
|
+
"body": body,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for attempt in range(self.max_retries + 1):
|
|
198
|
+
try:
|
|
199
|
+
start_time = time.time()
|
|
200
|
+
|
|
201
|
+
response = self._session.post(
|
|
202
|
+
url=url,
|
|
203
|
+
headers=merged_headers,
|
|
204
|
+
data=json.dumps(body),
|
|
205
|
+
timeout=self.timeout,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
duration = time.time() - start_time
|
|
209
|
+
|
|
210
|
+
# Log response details in debug mode
|
|
211
|
+
if self.debug:
|
|
212
|
+
logger.debug(f"Response received in {duration:.3f}s - Status: {response.status_code}")
|
|
213
|
+
logger.debug(f"Response headers: {dict(response.headers)}")
|
|
214
|
+
|
|
215
|
+
# Handle different HTTP status codes
|
|
216
|
+
if response.status_code == 200:
|
|
217
|
+
# Cache successful responses if caching is enabled
|
|
218
|
+
if self._cache and use_cache and cache_key:
|
|
219
|
+
# Create a mock response object to cache
|
|
220
|
+
cached_response = MockResponse(
|
|
221
|
+
status_code=response.status_code,
|
|
222
|
+
headers=dict(response.headers),
|
|
223
|
+
content=response.content,
|
|
224
|
+
json_data=self._safe_json(response),
|
|
225
|
+
)
|
|
226
|
+
self._cache.set(cache_key, cached_response)
|
|
227
|
+
if self.debug:
|
|
228
|
+
logger.debug(f"Cached response for {path}")
|
|
229
|
+
|
|
230
|
+
return response
|
|
231
|
+
elif response.status_code == 400:
|
|
232
|
+
raise KiwoomValidationError(
|
|
233
|
+
f"Bad request: {response.text}",
|
|
234
|
+
status_code=response.status_code,
|
|
235
|
+
response_data=self._safe_json(response),
|
|
236
|
+
request_context=request_context,
|
|
237
|
+
)
|
|
238
|
+
elif response.status_code == 401:
|
|
239
|
+
raise KiwoomAuthenticationError(
|
|
240
|
+
"Authentication failed - invalid or expired token",
|
|
241
|
+
status_code=response.status_code,
|
|
242
|
+
response_data=self._safe_json(response),
|
|
243
|
+
request_context=request_context,
|
|
244
|
+
)
|
|
245
|
+
elif response.status_code == 403:
|
|
246
|
+
raise KiwoomAuthorizationError(
|
|
247
|
+
"Access forbidden - insufficient permissions",
|
|
248
|
+
status_code=response.status_code,
|
|
249
|
+
response_data=self._safe_json(response),
|
|
250
|
+
request_context=request_context,
|
|
251
|
+
)
|
|
252
|
+
elif response.status_code == 429:
|
|
253
|
+
retry_after = self._get_retry_after(response)
|
|
254
|
+
if attempt < self.max_retries:
|
|
255
|
+
wait_time = retry_after or (2**attempt)
|
|
256
|
+
logger.warning(
|
|
257
|
+
f"Rate limit hit, waiting {wait_time}s before retry {attempt + 1}/{self.max_retries}"
|
|
258
|
+
)
|
|
259
|
+
time.sleep(wait_time)
|
|
260
|
+
continue
|
|
261
|
+
else:
|
|
262
|
+
raise KiwoomRateLimitError(
|
|
263
|
+
f"Rate limit exceeded after {self.max_retries} retries",
|
|
264
|
+
status_code=response.status_code,
|
|
265
|
+
response_data=self._safe_json(response),
|
|
266
|
+
request_context=request_context,
|
|
267
|
+
retry_after=retry_after,
|
|
268
|
+
)
|
|
269
|
+
elif 500 <= response.status_code < 600:
|
|
270
|
+
if attempt < self.max_retries:
|
|
271
|
+
wait_time = 2**attempt
|
|
272
|
+
logger.warning(f"Server error {response.status_code}, retrying in {wait_time}s")
|
|
273
|
+
time.sleep(wait_time)
|
|
274
|
+
continue
|
|
275
|
+
else:
|
|
276
|
+
raise KiwoomServerError(
|
|
277
|
+
f"Server error: {response.text}",
|
|
278
|
+
status_code=response.status_code,
|
|
279
|
+
response_data=self._safe_json(response),
|
|
280
|
+
request_context=request_context,
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
raise KiwoomAPIError(
|
|
284
|
+
f"Unexpected status code {response.status_code}: {response.text}",
|
|
285
|
+
status_code=response.status_code,
|
|
286
|
+
response_data=self._safe_json(response),
|
|
287
|
+
request_context=request_context,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
except requests.exceptions.Timeout as e:
|
|
291
|
+
if attempt < self.max_retries:
|
|
292
|
+
wait_time = 2**attempt
|
|
293
|
+
logger.warning(f"Request timeout, retrying in {wait_time}s")
|
|
294
|
+
time.sleep(wait_time)
|
|
295
|
+
continue
|
|
296
|
+
else:
|
|
297
|
+
raise KiwoomTimeoutError(
|
|
298
|
+
f"Request timeout after {self.max_retries} retries",
|
|
299
|
+
request_context=request_context,
|
|
300
|
+
) from e
|
|
301
|
+
except requests.exceptions.ConnectionError as e:
|
|
302
|
+
if attempt < self.max_retries:
|
|
303
|
+
wait_time = 2**attempt
|
|
304
|
+
logger.warning(f"Connection error, retrying in {wait_time}s")
|
|
305
|
+
time.sleep(wait_time)
|
|
306
|
+
continue
|
|
307
|
+
else:
|
|
308
|
+
raise KiwoomNetworkError(
|
|
309
|
+
f"Network connection failed: {str(e)}",
|
|
310
|
+
request_context=request_context,
|
|
311
|
+
) from e
|
|
312
|
+
except requests.exceptions.RequestException as e:
|
|
313
|
+
raise KiwoomNetworkError(
|
|
314
|
+
f"Request failed: {str(e)}",
|
|
315
|
+
request_context=request_context,
|
|
316
|
+
) from e
|
|
317
|
+
|
|
318
|
+
# This should never be reached, but just in case
|
|
319
|
+
raise KiwoomAPIError("Maximum retries exceeded", request_context=request_context)
|
|
320
|
+
|
|
321
|
+
def _safe_json(self, response: requests.Response) -> Optional[Dict]:
|
|
322
|
+
"""Safely parse JSON response, returning None if parsing fails."""
|
|
323
|
+
try:
|
|
324
|
+
return response.json()
|
|
325
|
+
except (ValueError, json.JSONDecodeError):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def _get_retry_after(self, response: requests.Response) -> Optional[int]:
|
|
329
|
+
"""Extract retry-after value from response headers."""
|
|
330
|
+
retry_after = response.headers.get("Retry-After")
|
|
331
|
+
if retry_after:
|
|
332
|
+
try:
|
|
333
|
+
return int(retry_after)
|
|
334
|
+
except ValueError:
|
|
335
|
+
pass
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def close(self):
|
|
339
|
+
"""Close the HTTP session."""
|
|
340
|
+
if hasattr(self, "_session"):
|
|
341
|
+
self._session.close()
|
|
342
|
+
|
|
343
|
+
def batch_post(self, requests_data: List[Tuple[str, dict, dict]]) -> List[requests.Response]:
|
|
344
|
+
"""
|
|
345
|
+
Execute multiple POST requests with rate limiting.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
requests_data: List of tuples (path, headers, body)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of response objects
|
|
352
|
+
"""
|
|
353
|
+
responses = []
|
|
354
|
+
for path, headers, body in requests_data:
|
|
355
|
+
try:
|
|
356
|
+
response = self._post(path, headers, body)
|
|
357
|
+
responses.append(response)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
# For batch requests, we collect errors instead of raising immediately
|
|
360
|
+
error_response = MockResponse(
|
|
361
|
+
status_code=0,
|
|
362
|
+
headers={},
|
|
363
|
+
content=json.dumps({"error": str(e)}).encode(),
|
|
364
|
+
json_data={"error": str(e), "exception_type": type(e).__name__},
|
|
365
|
+
)
|
|
366
|
+
responses.append(error_response)
|
|
367
|
+
return responses
|
|
368
|
+
|
|
369
|
+
def clear_cache(self):
|
|
370
|
+
"""Clear the request cache if caching is enabled."""
|
|
371
|
+
if self._cache:
|
|
372
|
+
self._cache.clear()
|
|
373
|
+
if self.debug:
|
|
374
|
+
logger.debug("Cache cleared")
|
|
375
|
+
|
|
376
|
+
def cache_info(self) -> Optional[Dict]:
|
|
377
|
+
"""Get cache statistics if caching is enabled."""
|
|
378
|
+
if self._cache:
|
|
379
|
+
return self._cache.cache_info()
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
def cleanup_cache(self) -> int:
|
|
383
|
+
"""Remove expired entries from cache if caching is enabled."""
|
|
384
|
+
if self._cache:
|
|
385
|
+
return self._cache.cleanup_expired()
|
|
386
|
+
return 0
|