Skip to content

Commit 60d07e7

Browse files
authored
feat(http): add automatic key rotation (#7)
1 parent f789dff commit 60d07e7

File tree

8 files changed

+198
-348
lines changed

8 files changed

+198
-348
lines changed

src/bitvavo_api_upgraded/bitvavo.py

Lines changed: 30 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,14 @@ def __init__(self, options: dict[str, str | int | list[dict[str, str]]] | None =
233233
else:
234234
self.api_keys = []
235235

236+
if not self.api_keys:
237+
msg = "API keys are required"
238+
raise ValueError(msg)
239+
236240
# Current API key index - options take precedence
237241
self.current_api_key_index: int = 0
238242

239-
# Rate limiting per API key (keyless has index -1)
243+
# Rate limiting per API key
240244
self.rate_limits: dict[int, dict[str, int | ms]] = {}
241245
# Get default rate limit from options or settings
242246
default_rate_limit_option = _options.get("DEFAULT_RATE_LIMIT", bitvavo_upgraded_settings.DEFAULT_RATE_LIMIT)
@@ -246,7 +250,6 @@ def __init__(self, options: dict[str, str | int | list[dict[str, str]]] | None =
246250
else bitvavo_upgraded_settings.DEFAULT_RATE_LIMIT
247251
)
248252

249-
self.rate_limits[-1] = {"remaining": default_rate_limit, "resetAt": ms(0)} # keyless
250253
for i in range(len(self.api_keys)):
251254
self.rate_limits[i] = {"remaining": default_rate_limit, "resetAt": ms(0)}
252255

@@ -262,36 +265,21 @@ def __init__(self, options: dict[str, str | int | list[dict[str, str]]] | None =
262265
self.debugging: bool = bool(_options.get("DEBUGGING", bitvavo_settings.DEBUGGING))
263266

264267
def get_best_api_key_config(self, rateLimitingWeight: int = 1) -> tuple[str, str, int]:
265-
"""
266-
Get the best API key configuration to use for a request.
267-
268-
Returns:
269-
tuple: (api_key, api_secret, key_index) where key_index is -1 for keyless
270-
"""
271-
# If keyless has enough rate limit, use keyless
272-
if self._has_rate_limit_available(-1, rateLimitingWeight):
273-
return "", "", -1
268+
"""Get the best API key configuration to use for a request."""
274269

275-
# Try to find an API key with enough rate limit
276270
for i in range(len(self.api_keys)):
277271
if self._has_rate_limit_available(i, rateLimitingWeight):
278272
return self.api_keys[i]["key"], self.api_keys[i]["secret"], i
279273

280-
# If keyless is available, use it as fallback
281-
if self._has_rate_limit_available(-1, rateLimitingWeight):
282-
return "", "", -1
283-
284-
# No keys available, use current key and let rate limiting handle the wait
285-
if self.api_keys:
286-
return (
287-
self.api_keys[self.current_api_key_index]["key"],
288-
self.api_keys[self.current_api_key_index]["secret"],
289-
self.current_api_key_index,
290-
)
291-
return "", "", -1
274+
# No keys have available budget, use current key and let rate limiting handle the wait
275+
return (
276+
self.api_keys[self.current_api_key_index]["key"],
277+
self.api_keys[self.current_api_key_index]["secret"],
278+
self.current_api_key_index,
279+
)
292280

293281
def _has_rate_limit_available(self, key_index: int, weight: int) -> bool:
294-
"""Check if a specific API key (or keyless) has enough rate limit."""
282+
"""Check if a specific API key has enough rate limit."""
295283
if key_index not in self.rate_limits:
296284
return False
297285
remaining = self.rate_limits[key_index]["remaining"]
@@ -317,7 +305,7 @@ def _update_rate_limit_for_key(self, key_index: int, response: anydict | errordi
317305
self.rate_limits[key_index]["resetAt"] = ms(time_ms() + 60000)
318306

319307
timeToWait = time_to_wait(ms(self.rate_limits[key_index]["resetAt"]))
320-
key_name = f"API_KEY_{key_index}" if key_index >= 0 else "KEYLESS"
308+
key_name = f"API_KEY_{key_index}"
321309
logger.warning(
322310
"api-key-banned",
323311
info={
@@ -527,46 +515,33 @@ def public_request(
527515
list[list[str]]
528516
```
529517
"""
530-
# Get the best API key configuration (keyless preferred, then available keys)
518+
# Get the best API key configuration
531519
api_key, api_secret, key_index = self.get_best_api_key_config(rateLimitingWeight)
532520

533521
# Check if we need to wait for rate limit
534522
if not self._has_rate_limit_available(key_index, rateLimitingWeight):
535523
self._sleep_for_key(key_index)
536524

537525
# Update current API key for legacy compatibility
538-
if api_key:
539-
self._current_api_key = api_key
540-
self._current_api_secret = api_secret
541-
self.current_api_key_index = key_index
542-
else:
543-
# Using keyless
544-
self._current_api_key = ""
545-
self._current_api_secret = ""
526+
self._current_api_key = api_key
527+
self._current_api_secret = api_secret
528+
self.current_api_key_index = key_index
546529

547530
if self.debugging:
548531
logger.debug(
549532
"api-request",
550-
info={
551-
"url": url,
552-
"with_api_key": bool(api_key != ""),
553-
"public_or_private": "public",
554-
"key_index": key_index,
555-
},
533+
info={"url": url, "key_index": key_index},
556534
)
557535

558-
if api_key:
559-
now = time_ms() + bitvavo_upgraded_settings.LAG
560-
sig = create_signature(now, "GET", url.replace(self.base, ""), None, api_secret)
561-
headers = {
562-
"bitvavo-access-key": api_key,
563-
"bitvavo-access-signature": sig,
564-
"bitvavo-access-timestamp": str(now),
565-
"bitvavo-access-window": str(self.ACCESSWINDOW),
566-
}
567-
r = get(url, headers=headers, timeout=(self.ACCESSWINDOW / 1000))
568-
else:
569-
r = get(url, timeout=(self.ACCESSWINDOW / 1000))
536+
now = time_ms() + bitvavo_upgraded_settings.LAG
537+
sig = create_signature(now, "GET", url.replace(self.base, ""), None, api_secret)
538+
headers = {
539+
"bitvavo-access-key": api_key,
540+
"bitvavo-access-signature": sig,
541+
"bitvavo-access-timestamp": str(now),
542+
"bitvavo-access-window": str(self.ACCESSWINDOW),
543+
}
544+
r = get(url, headers=headers, timeout=(self.ACCESSWINDOW / 1000))
570545

571546
# Update rate limit for the specific key used
572547
if "error" in r.json():
@@ -2368,7 +2343,7 @@ def remove_api_key(self, api_key: str) -> bool:
23682343
# Update rate limit tracking indices (shift them down)
23692344
new_rate_limits = {}
23702345
for key_idx, limits in self.rate_limits.items():
2371-
if key_idx == -1 or key_idx < i: # keyless
2346+
if key_idx < i:
23722347
new_rate_limits[key_idx] = limits
23732348
elif key_idx > i:
23742349
new_rate_limits[key_idx - 1] = limits
@@ -2386,19 +2361,10 @@ def get_api_key_status(self) -> dict[str, dict[str, int | str | bool]]:
23862361
"""Get the current status of all API keys including rate limits.
23872362
23882363
Returns:
2389-
dict: Status information for keyless and all API keys
2364+
dict: Status information for all API keys
23902365
"""
23912366
status = {}
23922367

2393-
# Keyless status
2394-
keyless_limits = self.rate_limits.get(-1, {"remaining": 0, "resetAt": ms(0)})
2395-
status["keyless"] = {
2396-
"remaining": int(keyless_limits["remaining"]),
2397-
"resetAt": int(keyless_limits["resetAt"]),
2398-
"available": self._has_rate_limit_available(-1, 1),
2399-
}
2400-
2401-
# API key status
24022368
for i, key_data in enumerate(self.api_keys):
24032369
key_limits = self.rate_limits.get(i, {"remaining": 0, "resetAt": ms(0)})
24042370
KEY_LENGTH = 12

src/bitvavo_client/facade.py

Lines changed: 11 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
from __future__ import annotations
44

5-
import time
6-
from typing import TYPE_CHECKING, TypeVar
5+
from typing import TYPE_CHECKING
76

87
from bitvavo_client.auth.rate_limit import RateLimitManager
98
from bitvavo_client.core.settings import BitvavoSettings
@@ -14,8 +13,6 @@
1413
if TYPE_CHECKING: # pragma: no cover
1514
from bitvavo_client.core.model_preferences import ModelPreference
1615

17-
T = TypeVar("T")
18-
1916

2017
class BitvavoClient:
2118
"""
@@ -47,53 +44,13 @@ def __init__(
4744
self.http = HTTPClient(self.settings, self.rate_limiter)
4845

4946
# Initialize API endpoint handlers with preferred model settings
50-
self.public = PublicAPI(self.http, preferred_model=preferred_model, default_schema=default_schema)
51-
self.private = PrivateAPI(self.http, preferred_model=preferred_model, default_schema=default_schema)
52-
53-
# Configure API keys if available
54-
self._api_keys: list[tuple[str, str]] = []
55-
self._current_key: int = -1
56-
self._configure_api_keys()
57-
58-
def _configure_api_keys(self) -> None:
59-
"""Configure API keys for authentication."""
60-
# Collect keys from settings
61-
if self.settings.api_key and self.settings.api_secret:
62-
self._api_keys.append((self.settings.api_key, self.settings.api_secret))
63-
if self.settings.api_keys:
64-
self._api_keys.extend((item["key"], item["secret"]) for item in self.settings.api_keys)
65-
66-
if not self._api_keys:
67-
return
68-
69-
for idx, (_key, _secret) in enumerate(self._api_keys):
70-
self.rate_limiter.ensure_key(idx)
71-
72-
self.http.set_key_rotation_callback(self.rotate_key)
73-
key, secret = self._api_keys[0]
74-
self.http.configure_key(key, secret, 0)
75-
self._current_key = 0
76-
77-
def rotate_key(self) -> bool:
78-
"""Rotate to the next configured API key if available."""
79-
if not self._api_keys:
80-
return False
81-
82-
next_idx = (self._current_key + 1) % len(self._api_keys)
83-
now = int(time.time() * 1000)
84-
if now < self.rate_limiter.get_reset_at(next_idx):
85-
self.rate_limiter.sleep_until_reset(next_idx)
86-
self.rate_limiter.reset_key(next_idx)
87-
self._current_key = next_idx
88-
key, secret = self._api_keys[next_idx]
89-
self.http.configure_key(key, secret, next_idx)
90-
return True
91-
92-
def select_key(self, index: int) -> None:
93-
"""Select a specific API key by index."""
94-
if not (0 <= index < len(self._api_keys)):
95-
msg = "API key index out of range"
96-
raise IndexError(msg)
97-
self._current_key = index
98-
key, secret = self._api_keys[index]
99-
self.http.configure_key(key, secret, index)
47+
self.public = PublicAPI(
48+
self.http,
49+
preferred_model=preferred_model,
50+
default_schema=default_schema,
51+
)
52+
self.private = PrivateAPI(
53+
self.http,
54+
preferred_model=preferred_model,
55+
default_schema=default_schema,
56+
)

0 commit comments

Comments
 (0)