Skip to content

Commit ddb1ff2

Browse files
Fix flaky localnet e2e tests around faucet races and aggregator supply. (#128)
Localnet Tests have been failing because Aptos Coin supply on recent localnets is an OptionalAggregator integer, not a table-backed aggregator. Also retry transient REST/faucet errors (including SEQUENCE_NUMBER_TOO_OLD) and make the example harness wait for the node and pick up localnet env vars. Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 0831ae0 commit ddb1ff2

7 files changed

Lines changed: 324 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All notable changes to the Aptos Python SDK will be captured in this file. This
44

55
## Unreleased
66

7+
- Make e2e / localnet examples more reliable: `aggregator_value` reads both OptionalAggregator variants (aggregator table and integer, as used on localnet), REST and faucet calls retry transient 429/5xx and faucet sequence-number races, and the integration harness waits for the node and reloads network env vars after starting a localnet.
8+
79
## 0.12.0 (2026-07-02)
810

911
### Breaking changes

aptos_sdk/async_client.py

Lines changed: 160 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json as _json
66
import logging
77
import time
8+
from collections.abc import Awaitable, Callable
89
from dataclasses import dataclass
910
from typing import Any, Dict, List, Optional
1011

@@ -29,6 +30,26 @@
2930

3031
U64_MAX = 18446744073709551615
3132

33+
# Transient REST / faucet failures that are safe to retry. SEQUENCE_NUMBER_*
34+
# shows up when a faucet minter races concurrent mint transactions.
35+
_FAUCET_RETRY_MARKERS = (
36+
"SEQUENCE_NUMBER_TOO_OLD",
37+
"SEQUENCE_NUMBER_TOO_NEW",
38+
"TRANSACTION_EXPIRED",
39+
)
40+
41+
42+
def _retryable_http_status(status: int) -> bool:
43+
return status == 429 or status >= 500
44+
45+
46+
def _retryable_faucet_error(status: int, body: str) -> bool:
47+
if _retryable_http_status(status):
48+
return True
49+
if status >= 400:
50+
return any(marker in body for marker in _FAUCET_RETRY_MARKERS)
51+
return False
52+
3253

3354
@dataclass
3455
class ClientConfig:
@@ -40,6 +61,7 @@ class ClientConfig:
4061
transaction_wait_in_seconds: int = 20
4162
http2: bool = True
4263
api_key: Optional[str] = None
64+
http_retries: int = 3
4365

4466

4567
class IndexerClient:
@@ -457,48 +479,73 @@ async def aggregator_value(
457479
resource_type: str,
458480
aggregator_path: List[str],
459481
) -> int:
482+
"""Read an ``OptionalAggregator`` value from an account resource.
483+
484+
Aptos ``0x1::optional_aggregator::OptionalAggregator`` stores either a
485+
parallelizable aggregator (table handle + key) or a plain integer. Local
486+
networks typically use the integer variant for APT supply; mainnet and
487+
devnet use the aggregator variant. This helper accepts both so callers
488+
do not need to know which representation the chain is using.
489+
490+
:param account_address: Account that holds the resource.
491+
:param resource_type: Move resource type, e.g. CoinInfo.
492+
:param aggregator_path: Field names from the resource root to the
493+
``OptionalAggregator``, e.g. ``["supply"]``. The list is not mutated.
494+
"""
460495
source = await self.account_resource(account_address, resource_type)
461496
source_data = data = source["data"]
462497

463-
while len(aggregator_path) > 0:
464-
key = aggregator_path.pop()
498+
for key in aggregator_path:
465499
if key not in data:
466500
raise ApiError(f"aggregator path not found in data: {source_data}", source_data)
467501
data = data[key]
468502

469-
if "vec" not in data:
470-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
471-
data = data["vec"]
472-
if len(data) != 1:
473-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
474-
data = data[0]
475-
if "aggregator" not in data:
476-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
477-
data = data["aggregator"]
478-
if "vec" not in data:
479-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
480-
data = data["vec"]
481-
if len(data) != 1:
503+
if "vec" not in data or len(data["vec"]) != 1:
482504
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
483-
data = data[0]
484-
if "handle" not in data:
485-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
486-
if "key" not in data:
487-
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
488-
handle = data["handle"]
489-
key = data["key"]
490-
return int(await self.get_table_item(handle, "address", "u128", key))
505+
optional = data["vec"][0]
506+
507+
aggregator = optional.get("aggregator", {}).get("vec", [])
508+
if len(aggregator) == 1 and "handle" in aggregator[0] and "key" in aggregator[0]:
509+
handle = aggregator[0]["handle"]
510+
key = aggregator[0]["key"]
511+
return int(await self.get_table_item(handle, "address", "u128", key))
512+
513+
integer = optional.get("integer", {}).get("vec", [])
514+
if len(integer) == 1 and "value" in integer[0]:
515+
return int(integer[0]["value"])
516+
517+
raise ApiError(f"aggregator not found in data: {source_data}", source_data)
491518

492519
#
493520
# Ledger accessors
494521
#
495522

496523
async def info(self) -> Dict[str, str]:
497-
response = await self.client.get(self.base_url)
524+
async def send() -> httpx.Response:
525+
return await self.client.get(self.base_url)
526+
527+
response = await self._send_with_retry(send)
498528
if response.status_code >= 400:
499529
raise ApiError(response.text, response.status_code)
500530
return response.json()
501531

532+
async def wait_until_ready(self, timeout_secs: float = 60.0) -> None:
533+
"""Poll ledger info until the node responds successfully.
534+
535+
Used by integration tests to wait out localnet startup races instead of
536+
failing on the first connection error.
537+
"""
538+
deadline = time.monotonic() + timeout_secs
539+
last_error: Optional[Exception] = None
540+
while time.monotonic() < deadline:
541+
try:
542+
await self.info()
543+
return
544+
except (ApiError, httpx.RequestError) as exc:
545+
last_error = exc
546+
await asyncio.sleep(0.5)
547+
raise TimeoutError(f"node not ready after {timeout_secs}s: {last_error}")
548+
502549
#
503550
# Transactions
504551
#
@@ -932,6 +979,32 @@ async def view_bcs_payload(
932979
raise ApiError(response.text, response.status_code)
933980
return response.json()
934981

982+
async def _send_with_retry(
983+
self, send: Callable[[], Awaitable[httpx.Response]]
984+
) -> httpx.Response:
985+
"""Retry GETs and idempotent POSTs on transport errors, 429, and 5xx.
986+
987+
Transaction submission must not use this helper: a lost 5xx response
988+
after the node accepted the transaction would double-submit.
989+
"""
990+
retries = self.client_config.http_retries
991+
last_response: Optional[httpx.Response] = None
992+
for attempt in range(retries + 1):
993+
try:
994+
response = await send()
995+
except httpx.RequestError:
996+
if attempt < retries:
997+
await asyncio.sleep(0.25 * (2**attempt))
998+
continue
999+
raise
1000+
if _retryable_http_status(response.status_code) and attempt < retries:
1001+
last_response = response
1002+
await asyncio.sleep(0.25 * (2**attempt))
1003+
continue
1004+
return response
1005+
assert last_response is not None
1006+
return last_response
1007+
9351008
async def _post(
9361009
self,
9371010
endpoint: str,
@@ -942,21 +1015,29 @@ async def _post(
9421015
# format params:
9431016
params = {} if params is None else params
9441017
params = {key: val for key, val in params.items() if val is not None}
945-
return await self.client.post(
946-
url=f"{self.base_url}/{endpoint}",
947-
params=params,
948-
headers=headers,
949-
json=data,
950-
)
1018+
1019+
async def send() -> httpx.Response:
1020+
return await self.client.post(
1021+
url=f"{self.base_url}/{endpoint}",
1022+
params=params,
1023+
headers=headers,
1024+
json=data,
1025+
)
1026+
1027+
return await self._send_with_retry(send)
9511028

9521029
async def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response:
9531030
# format params:
9541031
params = {} if params is None else params
9551032
params = {key: val for key, val in params.items() if val is not None}
956-
return await self.client.get(
957-
url=f"{self.base_url}/{endpoint}",
958-
params=params,
959-
)
1033+
1034+
async def send() -> httpx.Response:
1035+
return await self.client.get(
1036+
url=f"{self.base_url}/{endpoint}",
1037+
params=params,
1038+
)
1039+
1040+
return await self._send_with_retry(send)
9601041

9611042

9621043
class FaucetClient:
@@ -969,13 +1050,15 @@ class FaucetClient:
9691050
base_url: str
9701051
rest_client: RestClient
9711052
headers: Dict[str, str]
1053+
_fund_lock: asyncio.Lock
9721054

9731055
def __init__(self, base_url: str, rest_client: RestClient, auth_token: Optional[str] = None):
9741056
self.base_url = base_url
9751057
self.rest_client = rest_client
9761058
self.headers = {"Content-Type": "application/json"}
9771059
if auth_token:
9781060
self.headers["Authorization"] = f"Bearer {auth_token}"
1061+
self._fund_lock = asyncio.Lock()
9791062

9801063
async def close(self) -> None:
9811064
"""Close the underlying REST client connection."""
@@ -987,20 +1070,52 @@ async def fund_account(
9871070
"""This creates an account if it does not exist and mints the specified amount of
9881071
coins into that account.
9891072
1073+
Concurrent calls on the same client are serialized so a single faucet
1074+
minter does not race sequence numbers. Transient errors (429, 5xx,
1075+
SEQUENCE_NUMBER_TOO_OLD/NEW) are retried.
1076+
9901077
Note: only devnet has a publicly accessible faucet. For testnet, you must
9911078
initialize this client with an auth_token.
9921079
"""
993-
response = await self.rest_client.client.post(
994-
f"{self.base_url}/fund",
995-
headers=self.headers,
996-
json={"address": str(address), "amount": amount},
997-
)
998-
if response.status_code >= 400:
999-
raise ApiError(response.text, response.status_code)
1000-
txn_hash = response.json()["txn_hashes"][0]
1001-
if wait_for_transaction:
1002-
await self.rest_client.wait_for_transaction(txn_hash)
1003-
return txn_hash
1080+
async with self._fund_lock:
1081+
return await self._fund_account_once(address, amount, wait_for_transaction)
1082+
1083+
async def _fund_account_once(
1084+
self, address: AccountAddress, amount: int, wait_for_transaction: bool
1085+
) -> str:
1086+
retries = self.rest_client.client_config.http_retries
1087+
last_error: Optional[ApiError] = None
1088+
for attempt in range(retries + 1):
1089+
try:
1090+
response = await self.rest_client.client.post(
1091+
f"{self.base_url}/fund",
1092+
headers=self.headers,
1093+
json={"address": str(address), "amount": amount},
1094+
)
1095+
except httpx.RequestError as exc:
1096+
last_error = ApiError(str(exc), 0)
1097+
if attempt < retries:
1098+
await asyncio.sleep(0.25 * (2**attempt))
1099+
continue
1100+
raise last_error from exc
1101+
1102+
if response.status_code >= 400:
1103+
last_error = ApiError(response.text, response.status_code)
1104+
if (
1105+
_retryable_faucet_error(response.status_code, response.text)
1106+
and attempt < retries
1107+
):
1108+
await asyncio.sleep(0.25 * (2**attempt))
1109+
continue
1110+
raise last_error
1111+
1112+
txn_hash = response.json()["txn_hashes"][0]
1113+
if wait_for_transaction:
1114+
await self.rest_client.wait_for_transaction(txn_hash)
1115+
return txn_hash
1116+
1117+
assert last_error is not None
1118+
raise last_error
10041119

10051120
async def healthy(self) -> bool:
10061121
"""Return ``True`` iff the faucet's root endpoint reports ``tap:ok``."""

0 commit comments

Comments
 (0)