Skip to content

Commit 28249ab

Browse files
authored
Merge branch 'main' into chore/bump-version
2 parents 8f0a691 + 8782014 commit 28249ab

4 files changed

Lines changed: 77 additions & 14 deletions

File tree

docs/api/plugins/aea_ledger_ethereum/rpc_rotation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,14 @@ the active RPC URL.
172172

173173
```python
174174
@endpoint_uri.setter
175-
def endpoint_uri(_value: str) -> None
175+
def endpoint_uri(value: str) -> None
176176
```
177177

178178
No-op setter retained for parent-class compatibility.
179179

180180
**Arguments**:
181181

182-
- `_value`: ignored. ``HTTPProvider.__init__`` assigns to
182+
- `value`: ignored. ``HTTPProvider.__init__`` assigns to
183183
``endpoint_uri`` once at construction; we accept the write so the
184184
parent constructor does not raise, but the active endpoint is
185185
always derived from ``self._rpc_urls[self._current_index]``.

plugins/aea-ledger-ethereum/aea_ledger_ethereum/ethereum.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,10 @@ def to_dict(
703703
# TxReceipt / TxData are TypedDicts (dict subclasses), so accept both
704704
# AttributeDict and plain dict.
705705
if not isinstance(attr_dict, (AttributeDict, dict)):
706-
raise ValueError("No AttributeDict provided.") # pragma: nocover
706+
raise ValueError( # pragma: nocover
707+
f"Expected AttributeDict or dict-like, "
708+
f"got {type(attr_dict).__name__}"
709+
)
707710
result = {
708711
cls._valid_key(key): cls._remove_hexbytes(value)
709712
for key, value in attr_dict.items()

plugins/aea-ledger-ethereum/aea_ledger_ethereum/rpc_rotation.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -258,19 +258,29 @@ def __init__(
258258
if not rpc_urls:
259259
raise ValueError("RotatingHTTPProvider requires at least one RPC URL.")
260260

261-
# Initialize the parent HTTPProvider with the first URL. The parent's
262-
# session/transport machinery is unused — every request is delegated
263-
# to one of ``self._providers`` — but ``isinstance(self, HTTPProvider)``
264-
# must hold so web3 treats us as a proper provider (verified against
265-
# web3.py 6.x; the parent does not open any connections eagerly).
261+
# Set the attributes the ``endpoint_uri`` getter reads *before*
262+
# calling ``super().__init__``. The parent constructor writes to
263+
# ``self.endpoint_uri`` (which our no-op setter absorbs); some web3
264+
# code paths read ``endpoint_uri`` from within the parent init —
265+
# hoisting these assignments keeps the getter defensible regardless
266+
# of which parent code paths are taken on a future web3 upgrade.
267+
# When upgrading web3.py: re-verify ``HTTPProvider.__init__`` still
268+
# does not read ``self.endpoint_uri`` after assignment, and that
269+
# the rotating provider is never constructed with ``session=...``
270+
# passed through (parent reads ``endpoint_uri`` in that branch).
271+
self._rpc_urls: List[str] = rpc_urls
272+
self._current_index: int = 0
273+
274+
# ``isinstance(self, HTTPProvider)`` must hold so web3 treats us
275+
# as a proper provider; the parent's session/transport machinery
276+
# is otherwise unused since every request is delegated to one of
277+
# ``self._providers``.
266278
super().__init__(endpoint_uri=rpc_urls[0], request_kwargs=request_kwargs)
267279

268-
self._rpc_urls: List[str] = rpc_urls
269280
self._providers: List[HTTPProvider] = [
270281
HTTPProvider(endpoint_uri=url, request_kwargs=request_kwargs)
271282
for url in rpc_urls
272283
]
273-
self._current_index: int = 0
274284
self._backoff_until: Dict[int, float] = {}
275285
# Reentrant so health-tracking helpers can be called both directly
276286
# (from ``_handle_error_and_rotate``) and from inside ``_rotate``,
@@ -319,14 +329,22 @@ def endpoint_uri(self) -> str: # type: ignore[override]
319329
return self._rpc_urls[self._current_index]
320330

321331
@endpoint_uri.setter
322-
def endpoint_uri(self, _value: str) -> None:
332+
def endpoint_uri(self, value: str) -> None:
323333
"""No-op setter retained for parent-class compatibility.
324334
325-
:param _value: ignored. ``HTTPProvider.__init__`` assigns to
335+
:param value: ignored. ``HTTPProvider.__init__`` assigns to
326336
``endpoint_uri`` once at construction; we accept the write so the
327337
parent constructor does not raise, but the active endpoint is
328338
always derived from ``self._rpc_urls[self._current_index]``.
329339
"""
340+
# Surface the no-op so a future caller doing
341+
# ``provider.endpoint_uri = "https://override"`` can see at DEBUG
342+
# level that the assignment did not change the active endpoint.
343+
_logger.debug(
344+
"RotatingHTTPProvider ignores endpoint_uri assignment to %r; "
345+
"active URL is derived from self._rpc_urls[self._current_index]",
346+
value,
347+
)
330348

331349
# ------------------------------------------------------------------
332350
# Per-RPC health tracking
@@ -365,7 +383,15 @@ def _rotate(self) -> bool:
365383
return False
366384

367385
now = time.monotonic()
368-
if now - self._last_rotation_time < ROTATION_COOLDOWN:
386+
# The cooldown prevents thrashing between *healthy* providers
387+
# (e.g. concurrent threads each triggering a rotation). When
388+
# the current provider is itself in backoff — which is the
389+
# case immediately after ``_handle_error_and_rotate`` marks
390+
# it unhealthy — refusing to rotate would just loop the
391+
# next attempt back to a known-bad endpoint and burn the
392+
# per-call retry budget. Skip the cooldown in that case.
393+
current_healthy = now >= self._backoff_until.get(self._current_index, 0.0)
394+
if current_healthy and now - self._last_rotation_time < ROTATION_COOLDOWN:
369395
return False
370396

371397
best: Optional[int] = None
@@ -562,4 +588,6 @@ def make_request(self, method: RPCEndpoint, params: Any) -> RPCResponse:
562588

563589
# Unreachable: ``range(max_retries + 1)`` always yields at least one
564590
# iteration which either returns successfully or re-raises above.
565-
raise AssertionError("unreachable: retry loop exited without returning")
591+
raise AssertionError( # pragma: no cover
592+
"unreachable: retry loop exited without returning"
593+
)

plugins/aea-ledger-ethereum/tests/test_rpc_rotation.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,38 @@ def test_rotation_cooldown(self) -> None:
321321
provider._rotate()
322322
assert provider._rotate() is False
323323

324+
def test_cooldown_bypassed_when_current_unhealthy(self) -> None:
325+
"""Cooldown does not block rotation when the current provider is in backoff.
326+
327+
Regression test: the cooldown is meant to prevent thrashing among
328+
healthy providers; if the active one was just marked unhealthy,
329+
refusing to rotate would loop the next attempt back to a known-bad
330+
endpoint and burn the per-call retry budget.
331+
"""
332+
provider = _make_provider(["http://a", "http://b", "http://c"])
333+
# First rotation: A -> B. Sets _last_rotation_time = now.
334+
assert provider._rotate() is True
335+
assert provider._current_index == 1
336+
337+
# Within the cooldown window, mark B (the new current) unhealthy.
338+
provider._mark_rpc_backoff(1, 100.0)
339+
340+
# Rotation must proceed despite cooldown — current is unhealthy and
341+
# a healthy peer (C) exists.
342+
assert provider._rotate() is True
343+
assert provider._current_index == 2
344+
345+
def test_cooldown_still_enforced_when_current_healthy(self) -> None:
346+
"""Cooldown still suppresses cascades when the current provider is healthy."""
347+
provider = _make_provider(["http://a", "http://b", "http://c"])
348+
# First rotation: A -> B. B remains healthy.
349+
assert provider._rotate() is True
350+
assert provider._current_index == 1
351+
# Second rotation within cooldown window must be blocked because the
352+
# current provider is healthy (so we are not stuck on a dead RPC).
353+
assert provider._rotate() is False
354+
assert provider._current_index == 1
355+
324356
def test_all_in_backoff_picks_soonest_expiry(self) -> None:
325357
"""When all RPCs are in backoff, picks the one expiring soonest."""
326358
provider = _make_provider(["http://a", "http://b", "http://c"])

0 commit comments

Comments
 (0)