bitvavo-api-upgraded 4.3.1__py3-none-any.whl → 4.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bitvavo-api-upgraded
3
- Version: 4.3.1
3
+ Version: 4.4.1
4
4
  Summary: A unit-tested fork of the Bitvavo API
5
5
  Author: Bitvavo BV (original code), NostraDavid
6
6
  Author-email: NostraDavid <55331731+NostraDavid@users.noreply.github.com>
@@ -9,14 +9,14 @@ bitvavo_client/__init__.py,sha256=YXTBdP6fBREV34VeTqS_gkjfzIoHv5uSYhbqSUEeAVU,20
9
9
  bitvavo_client/adapters/__init__.py,sha256=9YVjMhNiAN6K1x7N0UvAXMQwuhOFy4W6Edrba1hW8KI,64
10
10
  bitvavo_client/adapters/returns_adapter.py,sha256=3HSAPw6HB9GCS8AbKmeidURpZXnvMZqkvalOu6JhBv0,14195
11
11
  bitvavo_client/auth/__init__.py,sha256=bjWu5WCKNNnNoLcVU290tKBml9M5afmcxaU_KrkisSQ,39
12
- bitvavo_client/auth/rate_limit.py,sha256=wv1UI733CVo8y5tI89CaUvomNpXLjbjSUh5vr9vn0ls,4949
12
+ bitvavo_client/auth/rate_limit.py,sha256=iEfrzEvYvLQfhMSCy6epJQjDXyLkhcNFvH_zidW9omc,10559
13
13
  bitvavo_client/auth/signing.py,sha256=DJrI1R1SLKjl276opj9hN4RrKIgsMhxsSEDA8b7T04I,1037
14
14
  bitvavo_client/core/__init__.py,sha256=WqjaU9Ut5JdZwn4tsR1vDdrSfMjEJred3im6fvWpalc,39
15
15
  bitvavo_client/core/errors.py,sha256=jWHHQKqkkhpHS9TeKlccl7wuyuRrq0H_PGZ0bl6sbW4,460
16
16
  bitvavo_client/core/model_preferences.py,sha256=uPXjAD3B4UaBwzmhSN7k59oG71RGmV05y6-FGDKM184,1134
17
17
  bitvavo_client/core/private_models.py,sha256=lttKQJQ6sVVwgFJ7WsnXim185KZUfjjuuQzsciPuEL8,33232
18
18
  bitvavo_client/core/public_models.py,sha256=st1m1yOxutPhVQteJoRS72mzlQNeL7ynYjG1hqYg27w,37492
19
- bitvavo_client/core/settings.py,sha256=jUu_8FQERkJneFHEOW6aHNeYX8DRRcBn_PXIkIipVtQ,2146
19
+ bitvavo_client/core/settings.py,sha256=EVvjxiecb7ju57JqOk0LNG_wjMmndrudvvPYoWFErxo,2147
20
20
  bitvavo_client/core/types.py,sha256=wxqGlbBf6UFMMnRak4H8b7rNymQQuFqKFR8mkSsRig8,429
21
21
  bitvavo_client/core/validation_helpers.py,sha256=2KeviuRXFiq4pgttHjC9q8gcrYmtLosSUrlvgnygMQY,3346
22
22
  bitvavo_client/df/__init__.py,sha256=1ui3dsRhDvy0NOoOi4zj5gR48u828Au9K_qtH9S1hIo,44
@@ -26,14 +26,14 @@ bitvavo_client/endpoints/base.py,sha256=gZwUHG1XapoVvE9F7yqU8NLt2-QETwUsGSwcHkwO
26
26
  bitvavo_client/endpoints/common.py,sha256=fc4gNNZ2zGMJJwkbHexNz6qMDLjl6dalQFGDXWmBo2E,2413
27
27
  bitvavo_client/endpoints/private.py,sha256=yChMe-HL2wFm6GBYoghURjdLzSxCPTYlEIOjysCrn3E,34589
28
28
  bitvavo_client/endpoints/public.py,sha256=EY7y3vuuo3li1BiPCM2KfSNnoa91X426DPtp8BvHiS8,21944
29
- bitvavo_client/facade.py,sha256=KZ8_yw9Ygtb8hK4_VIIwlrXGXIp43OJ4c-XncoYoaxE,1969
29
+ bitvavo_client/facade.py,sha256=nVAhmAtO53FfE_lODIjV3IjnMvkU3xGHuxUH5t-CLJk,1994
30
30
  bitvavo_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
31
  bitvavo_client/schemas/__init__.py,sha256=udqMyAFElrcBbNJIhoarQuTI-CF425JvPFqgLVjILU8,1126
32
32
  bitvavo_client/schemas/private_schemas.py,sha256=cG-cV5HKO8ZvWp3hjUPBFMT6Th0UBumMViI8gAZTyik,6143
33
33
  bitvavo_client/schemas/public_schemas.py,sha256=zfV6C_PQvNLLYEWS72ZD77Nm3XtRrEghKRhaFpgWHnI,4746
34
34
  bitvavo_client/transport/__init__.py,sha256=H7txnyuz6v84_GzdBiqpsehVQitEymgUTA5AJPeUEvg,44
35
- bitvavo_client/transport/http.py,sha256=hGablbfalBKAaJW9oQvz69oXpietBk7oGtCDZp1sQdc,9113
35
+ bitvavo_client/transport/http.py,sha256=4ch-_fC6UATzQAUfh4VAQJwjcnuNnPmFGaV7swotkkc,13741
36
36
  bitvavo_client/ws/__init__.py,sha256=Q4SVEq3EihXLVUKpguMdxrhfNAoU8cncpMFbU6kIX_0,44
37
- bitvavo_api_upgraded-4.3.1.dist-info/WHEEL,sha256=F3mArEuDT3LDFEqo9fCiUx6ISLN64aIhcGSiIwtu4r8,79
38
- bitvavo_api_upgraded-4.3.1.dist-info/METADATA,sha256=TRpZGN-Wim27RSrTubvBcirUDBd0WvfB31ehdn1Yirw,35640
39
- bitvavo_api_upgraded-4.3.1.dist-info/RECORD,,
37
+ bitvavo_api_upgraded-4.4.1.dist-info/WHEEL,sha256=Pi5uDq5Fdo_Rr-HD5h9BiPn9Et29Y9Sh8NhcJNnFU1c,79
38
+ bitvavo_api_upgraded-4.4.1.dist-info/METADATA,sha256=GStFfK9ebWo5CEuTrc4N139mM8iWnfKfwo-wGGOsLFE,35640
39
+ bitvavo_api_upgraded-4.4.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.16
2
+ Generator: uv 0.8.17
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -5,6 +5,10 @@ from __future__ import annotations
5
5
  import time
6
6
  from typing import Protocol
7
7
 
8
+ from structlog.stdlib import get_logger
9
+
10
+ logger = get_logger()
11
+
8
12
 
9
13
  class RateLimitStrategy(Protocol):
10
14
  """Protocol for custom rate limit handling strategies."""
@@ -20,10 +24,9 @@ class DefaultRateLimitStrategy(RateLimitStrategy):
20
24
 
21
25
 
22
26
  class RateLimitManager:
23
- """Manages rate limiting for multiple API keys and keyless requests.
27
+ """Manages rate limiting for multiple API keys.
24
28
 
25
- Each API key index has its own rate limit state. Index -1 is reserved
26
- for keyless requests.
29
+ Each API key index has its own rate limit state.
27
30
  """
28
31
 
29
32
  def __init__(self, default_remaining: int, buffer: int, strategy: RateLimitStrategy | None = None) -> None:
@@ -35,28 +38,51 @@ class RateLimitManager:
35
38
  strategy: Optional strategy callback when rate limit exceeded
36
39
  """
37
40
  self.default_remaining: int = default_remaining
38
- self.state: dict[int, dict[str, int]] = {-1: {"remaining": default_remaining, "resetAt": 0}}
41
+ self.state: dict[int, dict[str, int]] = {}
39
42
  self.buffer: int = buffer
40
43
 
41
44
  self._strategy: RateLimitStrategy = strategy or DefaultRateLimitStrategy()
42
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
+
43
53
  def ensure_key(self, idx: int) -> None:
44
54
  """Ensure a key index exists in the state."""
45
55
  if idx not in self.state:
46
- self.state[idx] = {"remaining": self.state[-1]["remaining"], "resetAt": 0}
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
+ )
47
62
 
48
63
  def has_budget(self, idx: int, weight: int) -> bool:
49
64
  """Check if there's enough rate limit budget for a request.
50
65
 
51
66
  Args:
52
- idx: API key index (-1 for keyless)
67
+ idx: API key index
53
68
  weight: Weight of the request
54
69
 
55
70
  Returns:
56
71
  True if request can be made within rate limits
57
72
  """
58
73
  self.ensure_key(idx)
59
- return (self.state[idx]["remaining"] - weight) >= self.buffer
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
60
86
 
61
87
  def record_call(self, idx: int, weight: int) -> None:
62
88
  """Record a request by decreasing the remaining budget.
@@ -66,12 +92,21 @@ class RateLimitManager:
66
92
  the response doesn't include rate limit headers.
67
93
 
68
94
  Args:
69
- idx: API key index (-1 for keyless)
95
+ idx: API key index
70
96
  weight: Weight of the request
71
97
  """
72
98
  self.ensure_key(idx)
99
+ old_remaining = self.state[idx]["remaining"]
73
100
  self.state[idx]["remaining"] = max(0, self.state[idx]["remaining"] - weight)
74
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
+
75
110
  def update_from_headers(self, idx: int, headers: dict[str, str]) -> None:
76
111
  """Update rate limit state from response headers.
77
112
 
@@ -84,11 +119,24 @@ class RateLimitManager:
84
119
  remaining = headers.get("bitvavo-ratelimit-remaining")
85
120
  reset_at = headers.get("bitvavo-ratelimit-resetat")
86
121
 
122
+ old_state = dict(self.state[idx])
123
+
87
124
  if remaining is not None:
88
125
  self.state[idx]["remaining"] = int(remaining)
89
126
  if reset_at is not None:
90
127
  self.state[idx]["resetAt"] = int(reset_at)
91
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
+
92
140
  def update_from_error(self, idx: int, _err: dict[str, object]) -> None:
93
141
  """Update rate limit state from API error response.
94
142
 
@@ -97,30 +145,73 @@ class RateLimitManager:
97
145
  _err: Error response from API (unused but kept for interface compatibility)
98
146
  """
99
147
  self.ensure_key(idx)
148
+ old_remaining = self.state[idx]["remaining"]
149
+ old_reset_at = self.state[idx]["resetAt"]
150
+
100
151
  self.state[idx]["remaining"] = 0
101
152
  self.state[idx]["resetAt"] = int(time.time() * 1000) + 60_000
102
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
+
103
162
  def sleep_until_reset(self, idx: int) -> None:
104
163
  """Sleep until rate limit resets for given key index.
105
164
 
106
165
  Args:
107
166
  idx: API key index
108
167
  """
168
+
109
169
  self.ensure_key(idx)
110
170
  now = int(time.time() * 1000)
111
171
  ms_left = max(0, self.state[idx]["resetAt"] - now)
112
- time.sleep(ms_left / 1000 + 1)
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
+ )
113
187
 
114
188
  def handle_limit(self, idx: int, weight: int) -> None:
115
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
+ )
116
196
  self._strategy(self, idx, weight)
117
197
 
118
198
  def reset_key(self, idx: int) -> None:
119
199
  """Reset the remaining budget and reset time for a key index."""
120
200
  self.ensure_key(idx)
201
+ old_remaining = self.state[idx]["remaining"]
202
+ old_reset_at = self.state[idx]["resetAt"]
203
+
121
204
  self.state[idx]["remaining"] = self.default_remaining
122
205
  self.state[idx]["resetAt"] = 0
123
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
+
124
215
  def get_remaining(self, idx: int) -> int:
125
216
  """Get remaining rate limit for key index.
126
217
 
@@ -144,3 +235,90 @@ class RateLimitManager:
144
235
  """
145
236
  self.ensure_key(idx)
146
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
@@ -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=0, description="Rate limit buffer to avoid hitting limits")
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
 
bitvavo_client/facade.py CHANGED
@@ -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
 
@@ -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
- self.rate_limiter.handle_limit(idx, weight)
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: