buda-client 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.
- buda/__init__.py +22 -0
- buda/core/__init__.py +0 -0
- buda/core/auth.py +55 -0
- buda/core/limiter.py +176 -0
- buda/core/providers.py +58 -0
- buda/core/retry.py +114 -0
- buda/core/settings.py +99 -0
- buda/rest/__init__.py +0 -0
- buda/rest/client/__init__.py +0 -0
- buda/rest/client/async_.py +386 -0
- buda/rest/client/base.py +46 -0
- buda/rest/client/sync_.py +383 -0
- buda/rest/endpoints/__init__.py +0 -0
- buda/rest/endpoints/account.py +22 -0
- buda/rest/endpoints/base.py +24 -0
- buda/rest/endpoints/markets.py +30 -0
- buda/rest/endpoints/orders.py +82 -0
- buda/rest/models/__init__.py +0 -0
- buda/rest/models/account.py +125 -0
- buda/rest/models/common.py +41 -0
- buda/rest/models/markets.py +65 -0
- buda/rest/models/orders.py +176 -0
- buda/socket/__init__.py +9 -0
- buda/socket/channels.py +50 -0
- buda/socket/client.py +169 -0
- buda_client-0.1.0.dist-info/METADATA +232 -0
- buda_client-0.1.0.dist-info/RECORD +29 -0
- buda_client-0.1.0.dist-info/WHEEL +4 -0
- buda_client-0.1.0.dist-info/licenses/LICENSE +201 -0
buda/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from buda.core.providers import DotEnvCredentials, EnvCredentials, StaticCredentials
|
|
4
|
+
from buda.core.settings import BudaSettings
|
|
5
|
+
from buda.rest.client.async_ import AsyncBudaClient
|
|
6
|
+
from buda.rest.client.sync_ import BudaClient
|
|
7
|
+
from buda.rest.models.orders import OrderCreate
|
|
8
|
+
from buda.socket import BudaWebSocketClient, Channel
|
|
9
|
+
|
|
10
|
+
__version__ = version("buda-client")
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"AsyncBudaClient",
|
|
14
|
+
"BudaClient",
|
|
15
|
+
"BudaSettings",
|
|
16
|
+
"BudaWebSocketClient",
|
|
17
|
+
"Channel",
|
|
18
|
+
"DotEnvCredentials",
|
|
19
|
+
"EnvCredentials",
|
|
20
|
+
"OrderCreate",
|
|
21
|
+
"StaticCredentials",
|
|
22
|
+
)
|
buda/core/__init__.py
ADDED
|
File without changes
|
buda/core/auth.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from httpx import Request
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
|
|
14
|
+
from httpx import Response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BudaAuth(httpx.Auth):
|
|
18
|
+
"""Attach Buda HMAC authentication to the Request object."""
|
|
19
|
+
|
|
20
|
+
__slots__ = (
|
|
21
|
+
"api_key",
|
|
22
|
+
"api_secret",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(self, api_key: str, api_secret: str):
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.api_secret = api_secret
|
|
28
|
+
|
|
29
|
+
def get_nonce(self) -> str:
|
|
30
|
+
"""Generate a nonce (timestamp in microseconds)"""
|
|
31
|
+
return str(int(time.time() * 1e6))
|
|
32
|
+
|
|
33
|
+
def sign(self, request: httpx.Request, nonce: str) -> str:
|
|
34
|
+
"""Create the HMAC signature for the request."""
|
|
35
|
+
components = [request.method, request.url.path]
|
|
36
|
+
|
|
37
|
+
if request.content:
|
|
38
|
+
body = base64.b64encode(request.content).decode()
|
|
39
|
+
components.append(body)
|
|
40
|
+
components.append(nonce)
|
|
41
|
+
message = " ".join(components)
|
|
42
|
+
|
|
43
|
+
hash = hmac.new(key=self.api_secret.encode(), msg=message.encode(), digestmod="sha384")
|
|
44
|
+
|
|
45
|
+
return hash.hexdigest()
|
|
46
|
+
|
|
47
|
+
def auth_flow(self, request: Request) -> Generator[Request, Response]:
|
|
48
|
+
nonce = self.get_nonce()
|
|
49
|
+
signature = self.sign(request, nonce)
|
|
50
|
+
|
|
51
|
+
request.headers["X-SBTC-APIKEY"] = self.api_key
|
|
52
|
+
request.headers["X-SBTC-SIGNATURE"] = signature
|
|
53
|
+
request.headers["X-SBTC-NONCE"] = nonce
|
|
54
|
+
|
|
55
|
+
yield request
|
buda/core/limiter.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from buda.core.settings import BudaSettings
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("buda.limiter")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncRateLimiter:
|
|
17
|
+
"""Proactive sliding-window rate limiter for synchronous requests.
|
|
18
|
+
|
|
19
|
+
Tracks per-second (shared) and per-minute (auth vs unauth) request
|
|
20
|
+
timestamps and sleeps when the next request would exceed limits.
|
|
21
|
+
|
|
22
|
+
Each ``BudaClient`` instance owns its own limiter, since Buda rate
|
|
23
|
+
limits are scoped per IP (unauthenticated) and per API key (authenticated).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = (
|
|
27
|
+
"_enabled",
|
|
28
|
+
"_lock",
|
|
29
|
+
"_per_minute_auth",
|
|
30
|
+
"_per_minute_auth_limit",
|
|
31
|
+
"_per_minute_unauth",
|
|
32
|
+
"_per_minute_unauth_limit",
|
|
33
|
+
"_per_second",
|
|
34
|
+
"_per_second_limit",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __init__(self, settings: BudaSettings) -> None:
|
|
38
|
+
self._enabled = settings.rate_limit_enabled
|
|
39
|
+
self._per_second_limit = settings.rate_limit_per_second
|
|
40
|
+
self._per_minute_auth_limit = settings.rate_limit_auth_per_minute
|
|
41
|
+
self._per_minute_unauth_limit = settings.rate_limit_unauth_per_minute
|
|
42
|
+
|
|
43
|
+
self._per_second: deque[float] = deque()
|
|
44
|
+
self._per_minute_auth: deque[float] = deque()
|
|
45
|
+
self._per_minute_unauth: deque[float] = deque()
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
|
|
48
|
+
def _prune(self, window: deque[float], cutoff: float) -> None:
|
|
49
|
+
while window and window[0] <= cutoff:
|
|
50
|
+
window.popleft()
|
|
51
|
+
|
|
52
|
+
def acquire(self, *, authenticated: bool) -> None:
|
|
53
|
+
if not self._enabled:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
with self._lock:
|
|
57
|
+
while True:
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
|
|
60
|
+
# Prune expired timestamps
|
|
61
|
+
self._prune(self._per_second, now - 1.0)
|
|
62
|
+
if authenticated:
|
|
63
|
+
self._prune(self._per_minute_auth, now - 60.0)
|
|
64
|
+
else:
|
|
65
|
+
self._prune(self._per_minute_unauth, now - 60.0)
|
|
66
|
+
|
|
67
|
+
# Calculate how long we need to wait (if at all)
|
|
68
|
+
wait = 0.0
|
|
69
|
+
|
|
70
|
+
if len(self._per_second) >= self._per_second_limit:
|
|
71
|
+
wait = max(wait, self._per_second[0] + 1.0 - now)
|
|
72
|
+
|
|
73
|
+
if authenticated:
|
|
74
|
+
if len(self._per_minute_auth) >= self._per_minute_auth_limit:
|
|
75
|
+
wait = max(wait, self._per_minute_auth[0] + 60.0 - now)
|
|
76
|
+
else:
|
|
77
|
+
if len(self._per_minute_unauth) >= self._per_minute_unauth_limit:
|
|
78
|
+
wait = max(wait, self._per_minute_unauth[0] + 60.0 - now)
|
|
79
|
+
|
|
80
|
+
if wait > 0:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"Rate limit approaching — sleeping %.2fs before request (authenticated=%s)",
|
|
83
|
+
wait,
|
|
84
|
+
authenticated,
|
|
85
|
+
)
|
|
86
|
+
# Release lock while sleeping so other threads aren't blocked
|
|
87
|
+
self._lock.release()
|
|
88
|
+
try:
|
|
89
|
+
time.sleep(wait)
|
|
90
|
+
finally:
|
|
91
|
+
self._lock.acquire()
|
|
92
|
+
continue # Re-check after sleeping
|
|
93
|
+
|
|
94
|
+
# Record the request timestamp
|
|
95
|
+
self._per_second.append(now)
|
|
96
|
+
if authenticated:
|
|
97
|
+
self._per_minute_auth.append(now)
|
|
98
|
+
else:
|
|
99
|
+
self._per_minute_unauth.append(now)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncRateLimiter:
|
|
104
|
+
"""Proactive sliding-window rate limiter for asynchronous requests.
|
|
105
|
+
|
|
106
|
+
Same logic as :class:`SyncRateLimiter` but uses ``asyncio.Lock`` and
|
|
107
|
+
``asyncio.sleep``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
__slots__ = (
|
|
111
|
+
"_enabled",
|
|
112
|
+
"_lock",
|
|
113
|
+
"_per_minute_auth",
|
|
114
|
+
"_per_minute_auth_limit",
|
|
115
|
+
"_per_minute_unauth",
|
|
116
|
+
"_per_minute_unauth_limit",
|
|
117
|
+
"_per_second",
|
|
118
|
+
"_per_second_limit",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def __init__(self, settings: BudaSettings) -> None:
|
|
122
|
+
self._enabled = settings.rate_limit_enabled
|
|
123
|
+
self._per_second_limit = settings.rate_limit_per_second
|
|
124
|
+
self._per_minute_auth_limit = settings.rate_limit_auth_per_minute
|
|
125
|
+
self._per_minute_unauth_limit = settings.rate_limit_unauth_per_minute
|
|
126
|
+
|
|
127
|
+
self._per_second: deque[float] = deque()
|
|
128
|
+
self._per_minute_auth: deque[float] = deque()
|
|
129
|
+
self._per_minute_unauth: deque[float] = deque()
|
|
130
|
+
self._lock = asyncio.Lock()
|
|
131
|
+
|
|
132
|
+
def _prune(self, window: deque[float], cutoff: float) -> None:
|
|
133
|
+
while window and window[0] <= cutoff:
|
|
134
|
+
window.popleft()
|
|
135
|
+
|
|
136
|
+
async def acquire(self, *, authenticated: bool) -> None:
|
|
137
|
+
if not self._enabled:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
async with self._lock:
|
|
141
|
+
while True:
|
|
142
|
+
now = time.monotonic()
|
|
143
|
+
|
|
144
|
+
self._prune(self._per_second, now - 1.0)
|
|
145
|
+
if authenticated:
|
|
146
|
+
self._prune(self._per_minute_auth, now - 60.0)
|
|
147
|
+
else:
|
|
148
|
+
self._prune(self._per_minute_unauth, now - 60.0)
|
|
149
|
+
|
|
150
|
+
wait = 0.0
|
|
151
|
+
|
|
152
|
+
if len(self._per_second) >= self._per_second_limit:
|
|
153
|
+
wait = max(wait, self._per_second[0] + 1.0 - now)
|
|
154
|
+
|
|
155
|
+
if authenticated:
|
|
156
|
+
if len(self._per_minute_auth) >= self._per_minute_auth_limit:
|
|
157
|
+
wait = max(wait, self._per_minute_auth[0] + 60.0 - now)
|
|
158
|
+
else:
|
|
159
|
+
if len(self._per_minute_unauth) >= self._per_minute_unauth_limit:
|
|
160
|
+
wait = max(wait, self._per_minute_unauth[0] + 60.0 - now)
|
|
161
|
+
|
|
162
|
+
if wait > 0:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Rate limit approaching — sleeping %.2fs before request (authenticated=%s)",
|
|
165
|
+
wait,
|
|
166
|
+
authenticated,
|
|
167
|
+
)
|
|
168
|
+
await asyncio.sleep(wait)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
self._per_second.append(now)
|
|
172
|
+
if authenticated:
|
|
173
|
+
self._per_minute_auth.append(now)
|
|
174
|
+
else:
|
|
175
|
+
self._per_minute_unauth.append(now)
|
|
176
|
+
return
|
buda/core/providers.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, SecretStr, model_serializer
|
|
6
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BudaCredentials(BaseSettings):
|
|
10
|
+
"""Buda API credentials."""
|
|
11
|
+
|
|
12
|
+
api_key: SecretStr = Field(..., validation_alias="buda_api_key")
|
|
13
|
+
api_secret: SecretStr = Field(..., validation_alias="buda_api_secret")
|
|
14
|
+
|
|
15
|
+
@model_serializer(mode="plain", return_type=dict[str, str])
|
|
16
|
+
def serialize(self) -> dict[str, str]:
|
|
17
|
+
return {
|
|
18
|
+
"api_key": self.api_key.get_secret_value(),
|
|
19
|
+
"api_secret": self.api_secret.get_secret_value(),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StaticCredentials(BudaCredentials):
|
|
24
|
+
"""Provider that uses static credentials."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, api_key: str, api_secret: str) -> None:
|
|
27
|
+
super().__init__(buda_api_key=api_key, buda_api_secret=api_secret) # type: ignore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnvCredentials(BudaCredentials):
|
|
31
|
+
"""Provider that loads credentials from environment variables."""
|
|
32
|
+
|
|
33
|
+
model_config = SettingsConfigDict(
|
|
34
|
+
env_prefix="BUDA_",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
38
|
+
super().__init__(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DotEnvCredentials(BudaCredentials):
|
|
42
|
+
"""Provider that loads credentials from a .env file."""
|
|
43
|
+
|
|
44
|
+
model_config = SettingsConfigDict(env_file=".env")
|
|
45
|
+
|
|
46
|
+
def __init__(self, env_file: str = ".env", **kwargs: Any) -> None:
|
|
47
|
+
super().__init__(_env_file=env_file, **kwargs) # type: ignore
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def settings_customise_sources(
|
|
51
|
+
cls,
|
|
52
|
+
settings_cls: type[BaseSettings],
|
|
53
|
+
init_settings: PydanticBaseSettingsSource,
|
|
54
|
+
env_settings: PydanticBaseSettingsSource,
|
|
55
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
56
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
57
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
58
|
+
return (dotenv_settings,)
|
buda/core/retry.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from httpx import HTTPStatusError
|
|
8
|
+
from tenacity import (
|
|
9
|
+
AsyncRetrying,
|
|
10
|
+
RetryCallState,
|
|
11
|
+
Retrying,
|
|
12
|
+
before_sleep_log,
|
|
13
|
+
retry_if_exception,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from tenacity.wait import WaitBaseT
|
|
22
|
+
|
|
23
|
+
from buda.core.settings import BudaSettings
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("buda.retry")
|
|
26
|
+
|
|
27
|
+
RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 503})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_retryable_error(exc: BaseException) -> bool:
|
|
31
|
+
"""Return ``True`` if *exc* is an HTTP error with a retryable status code."""
|
|
32
|
+
return isinstance(exc, HTTPStatusError) and exc.response.status_code in RETRYABLE_STATUS_CODES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _RetryAfterWait:
|
|
36
|
+
"""Tenacity wait strategy that honours the ``Retry-After`` header.
|
|
37
|
+
|
|
38
|
+
If the last exception is a 429 with a ``Retry-After`` header the wait
|
|
39
|
+
time will be *at least* the value specified by the server. Otherwise
|
|
40
|
+
it falls back to zero so a chained exponential strategy takes over.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __call__(self, retry_state: RetryCallState) -> float:
|
|
44
|
+
exc = retry_state.outcome and retry_state.outcome.exception()
|
|
45
|
+
if isinstance(exc, HTTPStatusError) and exc.response.status_code == 429:
|
|
46
|
+
retry_after = exc.response.headers.get("Retry-After")
|
|
47
|
+
if retry_after is not None:
|
|
48
|
+
try:
|
|
49
|
+
wait = float(retry_after)
|
|
50
|
+
logger.info(
|
|
51
|
+
"Server returned Retry-After: %.2fs — will wait at least that long",
|
|
52
|
+
wait,
|
|
53
|
+
)
|
|
54
|
+
return wait
|
|
55
|
+
except (ValueError, OverflowError):
|
|
56
|
+
pass
|
|
57
|
+
return 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_wait(settings: BudaSettings) -> WaitBaseT:
|
|
61
|
+
"""Build a combined wait strategy: Retry-After header OR exponential backoff."""
|
|
62
|
+
retry_after: Any = _RetryAfterWait()
|
|
63
|
+
exponential: Any = wait_exponential(
|
|
64
|
+
min=settings.retry_min_wait,
|
|
65
|
+
max=settings.retry_max_wait,
|
|
66
|
+
exp_base=settings.retry_exponential_base,
|
|
67
|
+
)
|
|
68
|
+
# For each attempt pick the larger of [Retry-After header, exponential backoff].
|
|
69
|
+
return retry_after + exponential
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sync_retry_on_error(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
73
|
+
"""Decorator for synchronous methods that retries on 429 / 500 / 503.
|
|
74
|
+
|
|
75
|
+
Reads retry configuration from ``self._settings`` at call time, so each
|
|
76
|
+
client instance can have its own retry policy.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
@functools.wraps(fn)
|
|
80
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
if not self._settings.retry_enabled:
|
|
82
|
+
return fn(self, *args, **kwargs)
|
|
83
|
+
retrying = Retrying(
|
|
84
|
+
retry=retry_if_exception(is_retryable_error),
|
|
85
|
+
stop=stop_after_attempt(self._settings.retry_max_attempts),
|
|
86
|
+
wait=_build_wait(self._settings),
|
|
87
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
88
|
+
reraise=True,
|
|
89
|
+
)
|
|
90
|
+
return retrying(fn, self, *args, **kwargs)
|
|
91
|
+
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def async_retry_on_error(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
96
|
+
"""Decorator for async methods that retries on 429 / 500 / 503.
|
|
97
|
+
|
|
98
|
+
Same semantics as :func:`sync_retry_on_error` but for coroutines.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@functools.wraps(fn)
|
|
102
|
+
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
103
|
+
if not self._settings.retry_enabled:
|
|
104
|
+
return await fn(self, *args, **kwargs)
|
|
105
|
+
retrying = AsyncRetrying(
|
|
106
|
+
retry=retry_if_exception(is_retryable_error),
|
|
107
|
+
stop=stop_after_attempt(self._settings.retry_max_attempts),
|
|
108
|
+
wait=_build_wait(self._settings),
|
|
109
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
110
|
+
reraise=True,
|
|
111
|
+
)
|
|
112
|
+
return await retrying(fn, self, *args, **kwargs)
|
|
113
|
+
|
|
114
|
+
return wrapper
|
buda/core/settings.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator, Field, HttpUrl, WebsocketUrl
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
BaseUrl = Annotated[str, BeforeValidator(lambda v: HttpUrl(v).encoded_string())]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
12
|
+
class BudaSettings:
|
|
13
|
+
"""Settings for Buda API clients."""
|
|
14
|
+
|
|
15
|
+
# REST API Settings
|
|
16
|
+
base_url: BaseUrl = Field(
|
|
17
|
+
default="https://www.buda.com/api/v2",
|
|
18
|
+
description="Base URL for the Buda API",
|
|
19
|
+
)
|
|
20
|
+
timeout: float | int = Field(
|
|
21
|
+
default=10.0,
|
|
22
|
+
union_mode="left_to_right",
|
|
23
|
+
description="Timeout for API requests in seconds",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# REST API - Retry Settings (tenacity)
|
|
27
|
+
retry_enabled: bool = Field(
|
|
28
|
+
default=True,
|
|
29
|
+
description="Enable automatic retry for 429, 500, and 503 HTTP errors",
|
|
30
|
+
)
|
|
31
|
+
retry_max_attempts: int = Field(
|
|
32
|
+
default=3,
|
|
33
|
+
description="Maximum number of retry attempts",
|
|
34
|
+
)
|
|
35
|
+
retry_min_wait: float = Field(
|
|
36
|
+
default=1.0,
|
|
37
|
+
description="Minimum wait time between retries in seconds",
|
|
38
|
+
)
|
|
39
|
+
retry_max_wait: float = Field(
|
|
40
|
+
default=30.0,
|
|
41
|
+
description="Maximum wait time between retries in seconds",
|
|
42
|
+
)
|
|
43
|
+
retry_exponential_base: float = Field(
|
|
44
|
+
default=2.0,
|
|
45
|
+
description="Base for exponential backoff between retries",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# REST API - Rate Limit Settings
|
|
49
|
+
rate_limit_enabled: bool = Field(
|
|
50
|
+
default=True,
|
|
51
|
+
description="Enable proactive rate limiting",
|
|
52
|
+
)
|
|
53
|
+
rate_limit_per_second: int = Field(
|
|
54
|
+
default=20,
|
|
55
|
+
description="Maximum requests per second (shared across auth/unauth)",
|
|
56
|
+
)
|
|
57
|
+
rate_limit_auth_per_minute: int = Field(
|
|
58
|
+
default=375,
|
|
59
|
+
description="Maximum authenticated requests per minute (per API key)",
|
|
60
|
+
)
|
|
61
|
+
rate_limit_unauth_per_minute: int = Field(
|
|
62
|
+
default=120,
|
|
63
|
+
description="Maximum unauthenticated requests per minute (per IP)",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# WebSocket API Settings
|
|
67
|
+
base_uri: WebsocketUrl = Field(
|
|
68
|
+
default=WebsocketUrl("wss://realtime.buda.com"),
|
|
69
|
+
description="Base URI for the Buda WebSocket API",
|
|
70
|
+
)
|
|
71
|
+
open_timeout: float | None = Field(
|
|
72
|
+
default=10.0,
|
|
73
|
+
description="Timeout for opening WebSocket connections in seconds",
|
|
74
|
+
)
|
|
75
|
+
ping_interval: float | None = Field(
|
|
76
|
+
default=10.0,
|
|
77
|
+
description="Interval for sending WebSocket pings in seconds",
|
|
78
|
+
)
|
|
79
|
+
ping_timeout: float | None = Field(
|
|
80
|
+
default=20.0,
|
|
81
|
+
description="Timeout for WebSocket pings in seconds",
|
|
82
|
+
)
|
|
83
|
+
close_timeout: float | None = Field(
|
|
84
|
+
default=10.0,
|
|
85
|
+
description="Timeout for closing WebSocket connections in seconds",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Common Settings
|
|
89
|
+
user_agent: str = Field(
|
|
90
|
+
default="python-buda-client/0.1.0",
|
|
91
|
+
description="User agent for the Buda API client",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def headers(self) -> dict[str, str]:
|
|
96
|
+
"""Default headers for API requests."""
|
|
97
|
+
return {
|
|
98
|
+
"User-Agent": self.user_agent,
|
|
99
|
+
}
|
buda/rest/__init__.py
ADDED
|
File without changes
|
|
File without changes
|