55import json as _json
66import logging
77import time
8+ from collections .abc import Awaitable , Callable
89from dataclasses import dataclass
910from typing import Any , Dict , List , Optional
1011
2930
3031U64_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
3455class 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
4567class 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
9621043class 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