bitvavo-api-upgraded 4.3.1__tar.gz → 4.4.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.
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/PKG-INFO +1 -1
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/pyproject.toml +2 -2
- bitvavo_api_upgraded-4.4.1/src/bitvavo_client/auth/rate_limit.py +324 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/settings.py +1 -1
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/facade.py +2 -2
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/transport/http.py +92 -2
- bitvavo_api_upgraded-4.3.1/src/bitvavo_client/auth/rate_limit.py +0 -146
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/README.md +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/bitvavo.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/dataframe_utils.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/helper_funcs.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/py.typed +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/settings.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/type_aliases.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/adapters/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/adapters/returns_adapter.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/auth/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/auth/signing.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/errors.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/model_preferences.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/private_models.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/public_models.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/types.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/validation_helpers.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/df/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/df/convert.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/base.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/common.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/private.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/public.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/py.typed +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/schemas/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/schemas/private_schemas.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/schemas/public_schemas.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/transport/__init__.py +0 -0
- {bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/ws/__init__.py +0 -0
@@ -6,7 +6,7 @@ build-backend = "uv_build"
|
|
6
6
|
# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
7
7
|
[project]
|
8
8
|
name = "bitvavo-api-upgraded"
|
9
|
-
version = "4.
|
9
|
+
version = "4.4.1"
|
10
10
|
description = "A unit-tested fork of the Bitvavo API"
|
11
11
|
readme = "README.md"
|
12
12
|
requires-python = ">=3.10"
|
@@ -108,7 +108,7 @@ dev-dependencies = [
|
|
108
108
|
]
|
109
109
|
|
110
110
|
[tool.bumpversion]
|
111
|
-
current_version = "4.
|
111
|
+
current_version = "4.4.1"
|
112
112
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
113
113
|
serialize = ["{major}.{minor}.{patch}"]
|
114
114
|
search = "{current_version}"
|
@@ -0,0 +1,324 @@
|
|
1
|
+
"""Rate limiting manager for Bitvavo API."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import time
|
6
|
+
from typing import Protocol
|
7
|
+
|
8
|
+
from structlog.stdlib import get_logger
|
9
|
+
|
10
|
+
logger = get_logger()
|
11
|
+
|
12
|
+
|
13
|
+
class RateLimitStrategy(Protocol):
|
14
|
+
"""Protocol for custom rate limit handling strategies."""
|
15
|
+
|
16
|
+
def __call__(self, manager: RateLimitManager, idx: int, weight: int) -> None: ...
|
17
|
+
|
18
|
+
|
19
|
+
class DefaultRateLimitStrategy(RateLimitStrategy):
|
20
|
+
"""Default RateLimitStrategy implementation that sleeps until the key's rate limit resets."""
|
21
|
+
|
22
|
+
def __call__(self, manager: RateLimitManager, idx: int, _: int) -> None:
|
23
|
+
manager.sleep_until_reset(idx)
|
24
|
+
|
25
|
+
|
26
|
+
class RateLimitManager:
|
27
|
+
"""Manages rate limiting for multiple API keys.
|
28
|
+
|
29
|
+
Each API key index has its own rate limit state.
|
30
|
+
"""
|
31
|
+
|
32
|
+
def __init__(self, default_remaining: int, buffer: int, strategy: RateLimitStrategy | None = None) -> None:
|
33
|
+
"""Initialize rate limit manager.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
default_remaining: Default rate limit amount
|
37
|
+
buffer: Buffer to keep before hitting limit
|
38
|
+
strategy: Optional strategy callback when rate limit exceeded
|
39
|
+
"""
|
40
|
+
self.default_remaining: int = default_remaining
|
41
|
+
self.state: dict[int, dict[str, int]] = {}
|
42
|
+
self.buffer: int = buffer
|
43
|
+
|
44
|
+
self._strategy: RateLimitStrategy = strategy or DefaultRateLimitStrategy()
|
45
|
+
|
46
|
+
logger.info(
|
47
|
+
"rate-limit-manager-initialized",
|
48
|
+
default_remaining=default_remaining,
|
49
|
+
buffer=buffer,
|
50
|
+
strategy=type(self._strategy).__name__,
|
51
|
+
)
|
52
|
+
|
53
|
+
def ensure_key(self, idx: int) -> None:
|
54
|
+
"""Ensure a key index exists in the state."""
|
55
|
+
if idx not in self.state:
|
56
|
+
self.state[idx] = {"remaining": self.default_remaining, "resetAt": 0}
|
57
|
+
logger.debug(
|
58
|
+
"rate-limit-key-initialized",
|
59
|
+
key_idx=idx,
|
60
|
+
default_remaining=self.default_remaining,
|
61
|
+
)
|
62
|
+
|
63
|
+
def has_budget(self, idx: int, weight: int) -> bool:
|
64
|
+
"""Check if there's enough rate limit budget for a request.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
idx: API key index
|
68
|
+
weight: Weight of the request
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
True if request can be made within rate limits
|
72
|
+
"""
|
73
|
+
self.ensure_key(idx)
|
74
|
+
has_budget = (self.state[idx]["remaining"] - weight) >= self.buffer
|
75
|
+
|
76
|
+
logger.debug(
|
77
|
+
"rate-limit-budget-check",
|
78
|
+
key_idx=idx,
|
79
|
+
weight=weight,
|
80
|
+
remaining=self.state[idx]["remaining"],
|
81
|
+
buffer=self.buffer,
|
82
|
+
has_budget=has_budget,
|
83
|
+
)
|
84
|
+
|
85
|
+
return has_budget
|
86
|
+
|
87
|
+
def record_call(self, idx: int, weight: int) -> None:
|
88
|
+
"""Record a request by decreasing the remaining budget.
|
89
|
+
|
90
|
+
This should be called whenever an API request is made to ensure
|
91
|
+
the local rate limit state reflects all outgoing calls, even when
|
92
|
+
the response doesn't include rate limit headers.
|
93
|
+
|
94
|
+
Args:
|
95
|
+
idx: API key index
|
96
|
+
weight: Weight of the request
|
97
|
+
"""
|
98
|
+
self.ensure_key(idx)
|
99
|
+
old_remaining = self.state[idx]["remaining"]
|
100
|
+
self.state[idx]["remaining"] = max(0, self.state[idx]["remaining"] - weight)
|
101
|
+
|
102
|
+
logger.debug(
|
103
|
+
"rate-limit-call-recorded",
|
104
|
+
key_idx=idx,
|
105
|
+
weight=weight,
|
106
|
+
old_remaining=old_remaining,
|
107
|
+
new_remaining=self.state[idx]["remaining"],
|
108
|
+
)
|
109
|
+
|
110
|
+
def update_from_headers(self, idx: int, headers: dict[str, str]) -> None:
|
111
|
+
"""Update rate limit state from response headers.
|
112
|
+
|
113
|
+
Args:
|
114
|
+
idx: API key index
|
115
|
+
headers: HTTP response headers
|
116
|
+
"""
|
117
|
+
self.ensure_key(idx)
|
118
|
+
|
119
|
+
remaining = headers.get("bitvavo-ratelimit-remaining")
|
120
|
+
reset_at = headers.get("bitvavo-ratelimit-resetat")
|
121
|
+
|
122
|
+
old_state = dict(self.state[idx])
|
123
|
+
|
124
|
+
if remaining is not None:
|
125
|
+
self.state[idx]["remaining"] = int(remaining)
|
126
|
+
if reset_at is not None:
|
127
|
+
self.state[idx]["resetAt"] = int(reset_at)
|
128
|
+
|
129
|
+
logger.debug(
|
130
|
+
"rate-limit-updated-from-headers",
|
131
|
+
key_idx=idx,
|
132
|
+
old_remaining=old_state["remaining"],
|
133
|
+
new_remaining=self.state[idx]["remaining"],
|
134
|
+
old_reset_at=old_state["resetAt"],
|
135
|
+
new_reset_at=self.state[idx]["resetAt"],
|
136
|
+
has_remaining_header=remaining is not None,
|
137
|
+
has_reset_header=reset_at is not None,
|
138
|
+
)
|
139
|
+
|
140
|
+
def update_from_error(self, idx: int, _err: dict[str, object]) -> None:
|
141
|
+
"""Update rate limit state from API error response.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
idx: API key index
|
145
|
+
_err: Error response from API (unused but kept for interface compatibility)
|
146
|
+
"""
|
147
|
+
self.ensure_key(idx)
|
148
|
+
old_remaining = self.state[idx]["remaining"]
|
149
|
+
old_reset_at = self.state[idx]["resetAt"]
|
150
|
+
|
151
|
+
self.state[idx]["remaining"] = 0
|
152
|
+
self.state[idx]["resetAt"] = int(time.time() * 1000) + 60_000
|
153
|
+
|
154
|
+
logger.warning(
|
155
|
+
"rate-limit-updated-from-error",
|
156
|
+
key_idx=idx,
|
157
|
+
old_remaining=old_remaining,
|
158
|
+
old_reset_at=old_reset_at,
|
159
|
+
new_reset_at=self.state[idx]["resetAt"],
|
160
|
+
)
|
161
|
+
|
162
|
+
def sleep_until_reset(self, idx: int) -> None:
|
163
|
+
"""Sleep until rate limit resets for given key index.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
idx: API key index
|
167
|
+
"""
|
168
|
+
|
169
|
+
self.ensure_key(idx)
|
170
|
+
now = int(time.time() * 1000)
|
171
|
+
ms_left = max(0, self.state[idx]["resetAt"] - now)
|
172
|
+
sleep_seconds = ms_left / 1000 + 1
|
173
|
+
|
174
|
+
logger.info(
|
175
|
+
"rate-limit-exceeded",
|
176
|
+
key_idx=idx,
|
177
|
+
sleep_seconds=sleep_seconds,
|
178
|
+
reset_at=self.state[idx]["resetAt"],
|
179
|
+
)
|
180
|
+
time.sleep(sleep_seconds)
|
181
|
+
|
182
|
+
logger.info(
|
183
|
+
"rate-limit-sleep-completed",
|
184
|
+
key_idx=idx,
|
185
|
+
slept_seconds=sleep_seconds,
|
186
|
+
)
|
187
|
+
|
188
|
+
def handle_limit(self, idx: int, weight: int) -> None:
|
189
|
+
"""Invoke the configured strategy when rate limit is exceeded."""
|
190
|
+
logger.info(
|
191
|
+
"rate-limit-handling-strategy",
|
192
|
+
key_idx=idx,
|
193
|
+
weight=weight,
|
194
|
+
strategy=type(self._strategy).__name__,
|
195
|
+
)
|
196
|
+
self._strategy(self, idx, weight)
|
197
|
+
|
198
|
+
def reset_key(self, idx: int) -> None:
|
199
|
+
"""Reset the remaining budget and reset time for a key index."""
|
200
|
+
self.ensure_key(idx)
|
201
|
+
old_remaining = self.state[idx]["remaining"]
|
202
|
+
old_reset_at = self.state[idx]["resetAt"]
|
203
|
+
|
204
|
+
self.state[idx]["remaining"] = self.default_remaining
|
205
|
+
self.state[idx]["resetAt"] = 0
|
206
|
+
|
207
|
+
logger.info(
|
208
|
+
"rate-limit-key-reset",
|
209
|
+
key_idx=idx,
|
210
|
+
old_remaining=old_remaining,
|
211
|
+
old_reset_at=old_reset_at,
|
212
|
+
new_remaining=self.default_remaining,
|
213
|
+
)
|
214
|
+
|
215
|
+
def get_remaining(self, idx: int) -> int:
|
216
|
+
"""Get remaining rate limit for key index.
|
217
|
+
|
218
|
+
Args:
|
219
|
+
idx: API key index
|
220
|
+
|
221
|
+
Returns:
|
222
|
+
Remaining rate limit count
|
223
|
+
"""
|
224
|
+
self.ensure_key(idx)
|
225
|
+
return self.state[idx]["remaining"]
|
226
|
+
|
227
|
+
def get_reset_at(self, idx: int) -> int:
|
228
|
+
"""Get reset timestamp for key index.
|
229
|
+
|
230
|
+
Args:
|
231
|
+
idx: API key index
|
232
|
+
|
233
|
+
Returns:
|
234
|
+
Reset timestamp in milliseconds
|
235
|
+
"""
|
236
|
+
self.ensure_key(idx)
|
237
|
+
return self.state[idx]["resetAt"]
|
238
|
+
|
239
|
+
def find_best_available_key(self, available_keys: list[int], weight: int) -> int | None:
|
240
|
+
"""Find the best API key for a request with given weight.
|
241
|
+
|
242
|
+
Prioritizes keys by:
|
243
|
+
1. Keys with sufficient budget (remaining - weight >= buffer)
|
244
|
+
2. Keys with the most remaining budget
|
245
|
+
3. Keys with the earliest reset time (if all are rate limited)
|
246
|
+
|
247
|
+
Args:
|
248
|
+
available_keys: List of available key indices
|
249
|
+
weight: Weight of the request
|
250
|
+
|
251
|
+
Returns:
|
252
|
+
Best key index or None if no keys are suitable
|
253
|
+
"""
|
254
|
+
if not available_keys:
|
255
|
+
logger.debug("rate-limit-no-keys-available", weight=weight)
|
256
|
+
return None
|
257
|
+
|
258
|
+
suitable_keys = []
|
259
|
+
fallback_keys = []
|
260
|
+
|
261
|
+
for idx in available_keys:
|
262
|
+
self.ensure_key(idx)
|
263
|
+
if self.has_budget(idx, weight):
|
264
|
+
suitable_keys.append((idx, self.state[idx]["remaining"]))
|
265
|
+
else:
|
266
|
+
fallback_keys.append((idx, self.state[idx]["resetAt"]))
|
267
|
+
|
268
|
+
# Return key with most remaining budget if any have sufficient budget
|
269
|
+
if suitable_keys:
|
270
|
+
best_key = max(suitable_keys, key=lambda x: x[1])[0]
|
271
|
+
logger.debug(
|
272
|
+
"rate-limit-best-key-found",
|
273
|
+
key_idx=best_key,
|
274
|
+
weight=weight,
|
275
|
+
remaining=self.state[best_key]["remaining"],
|
276
|
+
suitable_keys_count=len(suitable_keys),
|
277
|
+
)
|
278
|
+
return best_key
|
279
|
+
|
280
|
+
# If no keys have budget, return the one that resets earliest
|
281
|
+
if fallback_keys:
|
282
|
+
fallback_key = min(fallback_keys, key=lambda x: x[1])[0]
|
283
|
+
logger.warning(
|
284
|
+
"rate-limit-using-fallback-key",
|
285
|
+
key_idx=fallback_key,
|
286
|
+
weight=weight,
|
287
|
+
reset_at=self.state[fallback_key]["resetAt"],
|
288
|
+
fallback_keys_count=len(fallback_keys),
|
289
|
+
)
|
290
|
+
return fallback_key
|
291
|
+
|
292
|
+
logger.warning("rate-limit-no-suitable-keys", weight=weight, available_keys=available_keys)
|
293
|
+
return None
|
294
|
+
|
295
|
+
def get_earliest_reset_time(self, key_indices: list[int]) -> int:
|
296
|
+
"""Get the earliest reset time among the given keys.
|
297
|
+
|
298
|
+
Args:
|
299
|
+
key_indices: List of key indices to check
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
Earliest reset timestamp in milliseconds, or 0 if no keys have reset times
|
303
|
+
"""
|
304
|
+
if not key_indices:
|
305
|
+
logger.debug("rate-limit-no-keys-for-reset-time")
|
306
|
+
return 0
|
307
|
+
|
308
|
+
reset_times = []
|
309
|
+
for idx in key_indices:
|
310
|
+
self.ensure_key(idx)
|
311
|
+
reset_at = self.state[idx]["resetAt"]
|
312
|
+
if reset_at > 0: # Only consider keys that actually have a reset time
|
313
|
+
reset_times.append(reset_at)
|
314
|
+
|
315
|
+
earliest_reset = min(reset_times) if reset_times else 0
|
316
|
+
|
317
|
+
logger.debug(
|
318
|
+
"rate-limit-earliest-reset-time",
|
319
|
+
key_indices=key_indices,
|
320
|
+
keys_with_reset=len(reset_times),
|
321
|
+
earliest_reset=earliest_reset,
|
322
|
+
)
|
323
|
+
|
324
|
+
return earliest_reset
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/settings.py
RENAMED
@@ -30,7 +30,7 @@ class BitvavoSettings(BaseSettings):
|
|
30
30
|
|
31
31
|
# Client behavior
|
32
32
|
default_rate_limit: int = Field(default=1_000, description="Default rate limit for new API keys")
|
33
|
-
rate_limit_buffer: int = Field(default=
|
33
|
+
rate_limit_buffer: int = Field(default=50, description="Rate limit buffer to avoid hitting limits")
|
34
34
|
lag_ms: int = Field(default=0, description="Artificial lag to add to requests in milliseconds")
|
35
35
|
debugging: bool = Field(default=False, description="Enable debug logging")
|
36
36
|
|
@@ -38,8 +38,8 @@ class BitvavoClient:
|
|
38
38
|
"""
|
39
39
|
self.settings = settings or BitvavoSettings()
|
40
40
|
self.rate_limiter = RateLimitManager(
|
41
|
-
self.settings.default_rate_limit,
|
42
|
-
self.settings.rate_limit_buffer,
|
41
|
+
default_remaining=self.settings.default_rate_limit,
|
42
|
+
buffer=self.settings.rate_limit_buffer,
|
43
43
|
)
|
44
44
|
self.http = HTTPClient(self.settings, self.rate_limiter)
|
45
45
|
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/transport/http.py
RENAMED
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|
7
7
|
|
8
8
|
import httpx
|
9
9
|
from returns.result import Failure, Result
|
10
|
+
from structlog.stdlib import get_logger
|
10
11
|
|
11
12
|
from bitvavo_client.adapters.returns_adapter import (
|
12
13
|
BitvavoError,
|
@@ -19,6 +20,8 @@ if TYPE_CHECKING: # pragma: no cover
|
|
19
20
|
from bitvavo_client.core.settings import BitvavoSettings
|
20
21
|
from bitvavo_client.core.types import AnyDict
|
21
22
|
|
23
|
+
logger = get_logger(__name__)
|
24
|
+
|
22
25
|
|
23
26
|
class HTTPClient:
|
24
27
|
"""HTTP client for Bitvavo REST API with rate limiting and authentication."""
|
@@ -30,11 +33,13 @@ class HTTPClient:
|
|
30
33
|
settings: Bitvavo settings configuration
|
31
34
|
rate_limiter: Rate limit manager instance
|
32
35
|
"""
|
36
|
+
|
33
37
|
self.settings: BitvavoSettings = settings
|
34
38
|
self.rate_limiter: RateLimitManager = rate_limiter
|
35
39
|
self._keys: list[tuple[str, str]] = [(item["key"], item["secret"]) for item in self.settings.api_keys]
|
36
40
|
if not self._keys:
|
37
41
|
msg = "API keys are required"
|
42
|
+
logger.error("api-keys-required")
|
38
43
|
raise ValueError(msg)
|
39
44
|
|
40
45
|
for idx in range(len(self._keys)):
|
@@ -47,6 +52,7 @@ class HTTPClient:
|
|
47
52
|
|
48
53
|
key, secret = self._keys[0]
|
49
54
|
self.configure_key(key, secret, 0)
|
55
|
+
logger.info("http-client-initialized", key_count=len(self._keys))
|
50
56
|
|
51
57
|
def configure_key(self, key: str, secret: str, index: int) -> None:
|
52
58
|
"""Configure API key for authenticated requests.
|
@@ -59,18 +65,23 @@ class HTTPClient:
|
|
59
65
|
self.api_key = key
|
60
66
|
self.api_secret = secret
|
61
67
|
self.key_index = index
|
68
|
+
key_suffix = key[-4:] if len(key) >= 4 else "short"
|
69
|
+
logger.debug("api-key-configured", index=index, key_suffix=key_suffix)
|
62
70
|
|
63
71
|
def select_key(self, index: int) -> None:
|
64
72
|
"""Select a specific API key by index."""
|
65
73
|
if not (0 <= index < len(self._keys)):
|
66
74
|
msg = "API key index out of range"
|
75
|
+
logger.error("api-key-index-out-of-range", index=index, available_keys=len(self._keys))
|
67
76
|
raise IndexError(msg)
|
68
77
|
key, secret = self._keys[index]
|
69
78
|
self.configure_key(key, secret, index)
|
79
|
+
logger.debug("api-key-selected", index=index)
|
70
80
|
|
71
81
|
def _rotate_key(self) -> bool:
|
72
82
|
"""Rotate to the next configured API key if available."""
|
73
83
|
if len(self._keys) <= 1:
|
84
|
+
logger.debug("key-rotation-skipped", reason="only-one-key")
|
74
85
|
return False
|
75
86
|
|
76
87
|
next_idx = (self.key_index + 1) % len(self._keys)
|
@@ -78,14 +89,65 @@ class HTTPClient:
|
|
78
89
|
reset_at = self.rate_limiter.get_reset_at(next_idx)
|
79
90
|
|
80
91
|
if now < reset_at:
|
92
|
+
logger.info("rotating-to-key-with-sleep", next_index=next_idx)
|
81
93
|
self.rate_limiter.sleep_until_reset(next_idx)
|
82
94
|
self.rate_limiter.reset_key(next_idx)
|
83
95
|
elif self.rate_limiter.get_remaining(next_idx) <= self.rate_limiter.buffer:
|
96
|
+
logger.debug("rotating-to-key-with-reset", next_index=next_idx, reason="low-remaining-budget")
|
84
97
|
self.rate_limiter.reset_key(next_idx)
|
85
98
|
|
86
99
|
self.select_key(next_idx)
|
100
|
+
logger.info("key-rotated", from_index=self.key_index, to_index=next_idx)
|
87
101
|
return True
|
88
102
|
|
103
|
+
def _find_available_key(self, weight: int) -> int | None:
|
104
|
+
"""Find the best available API key for a request with given weight.
|
105
|
+
|
106
|
+
Args:
|
107
|
+
weight: Weight of the request
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
Best key index or None if all keys are rate limited
|
111
|
+
"""
|
112
|
+
available_keys = list(range(len(self._keys)))
|
113
|
+
best_key = self.rate_limiter.find_best_available_key(available_keys, weight)
|
114
|
+
if best_key is not None:
|
115
|
+
logger.debug("available-key-found", key_index=best_key, weight=weight)
|
116
|
+
else:
|
117
|
+
logger.warning("no-available-keys", weight=weight)
|
118
|
+
return best_key
|
119
|
+
|
120
|
+
def _handle_rate_limit_exhaustion(self, weight: int) -> None:
|
121
|
+
"""Handle situation when all API keys are rate limited.
|
122
|
+
|
123
|
+
Sleeps until the earliest key reset time and then resets that key.
|
124
|
+
|
125
|
+
Args:
|
126
|
+
weight: Weight of the original request
|
127
|
+
"""
|
128
|
+
logger.warning("all-keys-rate-limited", weight=weight)
|
129
|
+
all_keys = list(range(len(self._keys)))
|
130
|
+
earliest_reset = self.rate_limiter.get_earliest_reset_time(all_keys)
|
131
|
+
|
132
|
+
if earliest_reset > 0:
|
133
|
+
# Find which key has the earliest reset time
|
134
|
+
now = int(time.time() * 1000)
|
135
|
+
for idx in all_keys:
|
136
|
+
if self.rate_limiter.get_reset_at(idx) == earliest_reset:
|
137
|
+
if now < earliest_reset:
|
138
|
+
# Sleep until this key resets
|
139
|
+
sleep_ms = earliest_reset - now
|
140
|
+
logger.info("sleeping-until-key-reset", key_index=idx, sleep_ms=sleep_ms)
|
141
|
+
self.rate_limiter.sleep_until_reset(idx)
|
142
|
+
self.rate_limiter.reset_key(idx)
|
143
|
+
self.select_key(idx)
|
144
|
+
logger.info("key-reset-after-exhaustion", key_index=idx)
|
145
|
+
return
|
146
|
+
|
147
|
+
# Fallback to current key's rate limit strategy
|
148
|
+
logger.debug("fallback-to-current-key", key_index=self.key_index)
|
149
|
+
self.rate_limiter.handle_limit(self.key_index, weight)
|
150
|
+
|
89
151
|
def request(
|
90
152
|
self,
|
91
153
|
method: str,
|
@@ -108,10 +170,20 @@ class HTTPClient:
|
|
108
170
|
Raises:
|
109
171
|
HTTPError: On transport-level failures
|
110
172
|
"""
|
173
|
+
logger.debug("making-request", method=method, endpoint=endpoint, weight=weight)
|
111
174
|
idx = self.key_index
|
112
175
|
self._ensure_rate_limit_initialized()
|
113
176
|
|
177
|
+
# Try to find the best available key for this request
|
178
|
+
best_key = self._find_available_key(weight)
|
179
|
+
if best_key is not None and best_key != idx:
|
180
|
+
logger.debug("switching-to-best-key", from_index=idx, to_index=best_key)
|
181
|
+
self.select_key(best_key)
|
182
|
+
idx = best_key
|
183
|
+
|
184
|
+
# If current key doesn't have budget, try rotation
|
114
185
|
if not self.rate_limiter.has_budget(idx, weight):
|
186
|
+
logger.debug("key-lacks-budget", key_index=idx, weight=weight)
|
115
187
|
for _ in range(len(self._keys)):
|
116
188
|
if self.rate_limiter.has_budget(idx, weight):
|
117
189
|
break
|
@@ -119,18 +191,25 @@ class HTTPClient:
|
|
119
191
|
idx = self.key_index
|
120
192
|
if not rotated:
|
121
193
|
break
|
194
|
+
|
195
|
+
# If still no budget after trying all keys, handle exhaustion smartly
|
122
196
|
if not self.rate_limiter.has_budget(idx, weight):
|
123
|
-
|
197
|
+
logger.warning("no-budget-after-rotation", weight=weight)
|
198
|
+
self._handle_rate_limit_exhaustion(weight)
|
199
|
+
idx = self.key_index # Update idx after potential key change
|
124
200
|
|
125
201
|
url = f"{self.settings.rest_url}{endpoint}"
|
126
202
|
headers = self._create_auth_headers(method, endpoint, body)
|
127
203
|
|
128
204
|
# Update rate limit usage for this call
|
129
205
|
self.rate_limiter.record_call(idx, weight)
|
206
|
+
logger.debug("weight-recorded", weight=weight, key_index=idx)
|
130
207
|
|
131
208
|
try:
|
132
209
|
response = self._make_http_request(method, url, headers, body)
|
210
|
+
logger.debug("request-completed", status_code=response.status_code)
|
133
211
|
except httpx.HTTPError as exc:
|
212
|
+
logger.error("http-request-failed", error=str(exc))
|
134
213
|
return Failure(exc)
|
135
214
|
|
136
215
|
self._update_rate_limits(response, idx)
|
@@ -141,6 +220,7 @@ class HTTPClient:
|
|
141
220
|
"""Ensure the initial rate limit state is fetched from the API."""
|
142
221
|
if self._rate_limit_initialized:
|
143
222
|
return
|
223
|
+
logger.info("initializing-rate-limit-state")
|
144
224
|
self._rate_limit_initialized = True
|
145
225
|
self._initialize_rate_limit()
|
146
226
|
|
@@ -148,6 +228,7 @@ class HTTPClient:
|
|
148
228
|
"""Fetch initial rate limit and handle potential rate limit errors."""
|
149
229
|
endpoint = "/account"
|
150
230
|
url = f"{self.settings.rest_url}{endpoint}"
|
231
|
+
logger.debug("fetching-initial-rate-limit")
|
151
232
|
|
152
233
|
while True:
|
153
234
|
headers = self._create_auth_headers("GET", endpoint, None)
|
@@ -156,7 +237,8 @@ class HTTPClient:
|
|
156
237
|
|
157
238
|
try:
|
158
239
|
response = self._make_http_request("GET", url, headers, None)
|
159
|
-
except httpx.HTTPError:
|
240
|
+
except httpx.HTTPError as exc:
|
241
|
+
logger.warning("rate-limit-init-failed", error=str(exc))
|
160
242
|
return
|
161
243
|
|
162
244
|
self._update_rate_limits(response, self.key_index)
|
@@ -172,15 +254,19 @@ class HTTPClient:
|
|
172
254
|
if isinstance(err, dict):
|
173
255
|
err_code = str(err.get("code", ""))
|
174
256
|
if response.status_code == httpx.codes.TOO_MANY_REQUESTS and err_code == "101":
|
257
|
+
logger.info("rate-limited-during-init", error_code=err_code)
|
175
258
|
self.rate_limiter.sleep_until_reset(self.key_index)
|
176
259
|
self.rate_limiter.reset_key(self.key_index)
|
177
260
|
continue
|
178
261
|
|
179
262
|
if self.rate_limiter.get_remaining(self.key_index) < self.rate_limiter.buffer:
|
263
|
+
remaining = self.rate_limiter.get_remaining(self.key_index)
|
264
|
+
logger.info("low-budget-during-init", remaining=remaining)
|
180
265
|
self.rate_limiter.sleep_until_reset(self.key_index)
|
181
266
|
self.rate_limiter.reset_key(self.key_index)
|
182
267
|
continue
|
183
268
|
|
269
|
+
logger.info("rate-limit-initialization-completed")
|
184
270
|
break
|
185
271
|
|
186
272
|
def _create_auth_headers(self, method: str, endpoint: str, body: AnyDict | None) -> dict[str, str]:
|
@@ -216,6 +302,7 @@ class HTTPClient:
|
|
216
302
|
return httpx.delete(url, headers=headers, timeout=timeout)
|
217
303
|
case _:
|
218
304
|
msg = f"Unsupported HTTP method: {method}"
|
305
|
+
logger.error("unsupported-http-method", method=method)
|
219
306
|
raise ValueError(msg)
|
220
307
|
|
221
308
|
def _update_rate_limits(self, response: httpx.Response, idx: int) -> None:
|
@@ -227,10 +314,13 @@ class HTTPClient:
|
|
227
314
|
|
228
315
|
if isinstance(json_data, dict) and "error" in json_data:
|
229
316
|
if self._is_rate_limit_error(response, json_data):
|
317
|
+
logger.warning("rate-limit-error-detected", key_index=idx)
|
230
318
|
self.rate_limiter.update_from_error(idx, json_data)
|
231
319
|
else:
|
320
|
+
logger.debug("non-rate-limit-error", key_index=idx)
|
232
321
|
self.rate_limiter.update_from_headers(idx, dict(response.headers))
|
233
322
|
else:
|
323
|
+
logger.debug("successful-response", key_index=idx)
|
234
324
|
self.rate_limiter.update_from_headers(idx, dict(response.headers))
|
235
325
|
|
236
326
|
def _is_rate_limit_error(self, response: httpx.Response, json_data: dict[str, Any]) -> bool:
|
@@ -1,146 +0,0 @@
|
|
1
|
-
"""Rate limiting manager for Bitvavo API."""
|
2
|
-
|
3
|
-
from __future__ import annotations
|
4
|
-
|
5
|
-
import time
|
6
|
-
from typing import Protocol
|
7
|
-
|
8
|
-
|
9
|
-
class RateLimitStrategy(Protocol):
|
10
|
-
"""Protocol for custom rate limit handling strategies."""
|
11
|
-
|
12
|
-
def __call__(self, manager: RateLimitManager, idx: int, weight: int) -> None: ...
|
13
|
-
|
14
|
-
|
15
|
-
class DefaultRateLimitStrategy(RateLimitStrategy):
|
16
|
-
"""Default RateLimitStrategy implementation that sleeps until the key's rate limit resets."""
|
17
|
-
|
18
|
-
def __call__(self, manager: RateLimitManager, idx: int, _: int) -> None:
|
19
|
-
manager.sleep_until_reset(idx)
|
20
|
-
|
21
|
-
|
22
|
-
class RateLimitManager:
|
23
|
-
"""Manages rate limiting for multiple API keys and keyless requests.
|
24
|
-
|
25
|
-
Each API key index has its own rate limit state. Index -1 is reserved
|
26
|
-
for keyless requests.
|
27
|
-
"""
|
28
|
-
|
29
|
-
def __init__(self, default_remaining: int, buffer: int, strategy: RateLimitStrategy | None = None) -> None:
|
30
|
-
"""Initialize rate limit manager.
|
31
|
-
|
32
|
-
Args:
|
33
|
-
default_remaining: Default rate limit amount
|
34
|
-
buffer: Buffer to keep before hitting limit
|
35
|
-
strategy: Optional strategy callback when rate limit exceeded
|
36
|
-
"""
|
37
|
-
self.default_remaining: int = default_remaining
|
38
|
-
self.state: dict[int, dict[str, int]] = {-1: {"remaining": default_remaining, "resetAt": 0}}
|
39
|
-
self.buffer: int = buffer
|
40
|
-
|
41
|
-
self._strategy: RateLimitStrategy = strategy or DefaultRateLimitStrategy()
|
42
|
-
|
43
|
-
def ensure_key(self, idx: int) -> None:
|
44
|
-
"""Ensure a key index exists in the state."""
|
45
|
-
if idx not in self.state:
|
46
|
-
self.state[idx] = {"remaining": self.state[-1]["remaining"], "resetAt": 0}
|
47
|
-
|
48
|
-
def has_budget(self, idx: int, weight: int) -> bool:
|
49
|
-
"""Check if there's enough rate limit budget for a request.
|
50
|
-
|
51
|
-
Args:
|
52
|
-
idx: API key index (-1 for keyless)
|
53
|
-
weight: Weight of the request
|
54
|
-
|
55
|
-
Returns:
|
56
|
-
True if request can be made within rate limits
|
57
|
-
"""
|
58
|
-
self.ensure_key(idx)
|
59
|
-
return (self.state[idx]["remaining"] - weight) >= self.buffer
|
60
|
-
|
61
|
-
def record_call(self, idx: int, weight: int) -> None:
|
62
|
-
"""Record a request by decreasing the remaining budget.
|
63
|
-
|
64
|
-
This should be called whenever an API request is made to ensure
|
65
|
-
the local rate limit state reflects all outgoing calls, even when
|
66
|
-
the response doesn't include rate limit headers.
|
67
|
-
|
68
|
-
Args:
|
69
|
-
idx: API key index (-1 for keyless)
|
70
|
-
weight: Weight of the request
|
71
|
-
"""
|
72
|
-
self.ensure_key(idx)
|
73
|
-
self.state[idx]["remaining"] = max(0, self.state[idx]["remaining"] - weight)
|
74
|
-
|
75
|
-
def update_from_headers(self, idx: int, headers: dict[str, str]) -> None:
|
76
|
-
"""Update rate limit state from response headers.
|
77
|
-
|
78
|
-
Args:
|
79
|
-
idx: API key index
|
80
|
-
headers: HTTP response headers
|
81
|
-
"""
|
82
|
-
self.ensure_key(idx)
|
83
|
-
|
84
|
-
remaining = headers.get("bitvavo-ratelimit-remaining")
|
85
|
-
reset_at = headers.get("bitvavo-ratelimit-resetat")
|
86
|
-
|
87
|
-
if remaining is not None:
|
88
|
-
self.state[idx]["remaining"] = int(remaining)
|
89
|
-
if reset_at is not None:
|
90
|
-
self.state[idx]["resetAt"] = int(reset_at)
|
91
|
-
|
92
|
-
def update_from_error(self, idx: int, _err: dict[str, object]) -> None:
|
93
|
-
"""Update rate limit state from API error response.
|
94
|
-
|
95
|
-
Args:
|
96
|
-
idx: API key index
|
97
|
-
_err: Error response from API (unused but kept for interface compatibility)
|
98
|
-
"""
|
99
|
-
self.ensure_key(idx)
|
100
|
-
self.state[idx]["remaining"] = 0
|
101
|
-
self.state[idx]["resetAt"] = int(time.time() * 1000) + 60_000
|
102
|
-
|
103
|
-
def sleep_until_reset(self, idx: int) -> None:
|
104
|
-
"""Sleep until rate limit resets for given key index.
|
105
|
-
|
106
|
-
Args:
|
107
|
-
idx: API key index
|
108
|
-
"""
|
109
|
-
self.ensure_key(idx)
|
110
|
-
now = int(time.time() * 1000)
|
111
|
-
ms_left = max(0, self.state[idx]["resetAt"] - now)
|
112
|
-
time.sleep(ms_left / 1000 + 1)
|
113
|
-
|
114
|
-
def handle_limit(self, idx: int, weight: int) -> None:
|
115
|
-
"""Invoke the configured strategy when rate limit is exceeded."""
|
116
|
-
self._strategy(self, idx, weight)
|
117
|
-
|
118
|
-
def reset_key(self, idx: int) -> None:
|
119
|
-
"""Reset the remaining budget and reset time for a key index."""
|
120
|
-
self.ensure_key(idx)
|
121
|
-
self.state[idx]["remaining"] = self.default_remaining
|
122
|
-
self.state[idx]["resetAt"] = 0
|
123
|
-
|
124
|
-
def get_remaining(self, idx: int) -> int:
|
125
|
-
"""Get remaining rate limit for key index.
|
126
|
-
|
127
|
-
Args:
|
128
|
-
idx: API key index
|
129
|
-
|
130
|
-
Returns:
|
131
|
-
Remaining rate limit count
|
132
|
-
"""
|
133
|
-
self.ensure_key(idx)
|
134
|
-
return self.state[idx]["remaining"]
|
135
|
-
|
136
|
-
def get_reset_at(self, idx: int) -> int:
|
137
|
-
"""Get reset timestamp for key index.
|
138
|
-
|
139
|
-
Args:
|
140
|
-
idx: API key index
|
141
|
-
|
142
|
-
Returns:
|
143
|
-
Reset timestamp in milliseconds
|
144
|
-
"""
|
145
|
-
self.ensure_key(idx)
|
146
|
-
return self.state[idx]["resetAt"]
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/__init__.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/bitvavo.py
RENAMED
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/helper_funcs.py
RENAMED
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/settings.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_api_upgraded/type_aliases.py
RENAMED
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/adapters/__init__.py
RENAMED
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/auth/__init__.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/auth/signing.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/__init__.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/private_models.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/core/public_models.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/__init__.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/base.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/common.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/private.py
RENAMED
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/endpoints/public.py
RENAMED
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/schemas/__init__.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{bitvavo_api_upgraded-4.3.1 → bitvavo_api_upgraded-4.4.1}/src/bitvavo_client/transport/__init__.py
RENAMED
File without changes
|
File without changes
|