async-substrate-interface 1.2.2__tar.gz → 1.3.1__tar.gz

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.
Files changed (26) hide show
  1. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/PKG-INFO +1 -1
  2. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/async_substrate.py +125 -70
  3. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/errors.py +10 -0
  4. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/substrate_addons.py +49 -10
  5. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/sync_substrate.py +25 -8
  6. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/utils/cache.py +56 -0
  7. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface.egg-info/PKG-INFO +1 -1
  8. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/pyproject.toml +1 -1
  9. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/tests/test_substrate_addons.py +31 -2
  10. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/LICENSE +0 -0
  11. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/README.md +0 -0
  12. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/__init__.py +0 -0
  13. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/const.py +0 -0
  14. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/protocols.py +0 -0
  15. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/type_registry.py +0 -0
  16. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/types.py +0 -0
  17. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/utils/__init__.py +0 -0
  18. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/utils/decoding.py +0 -0
  19. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/utils/hasher.py +0 -0
  20. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface/utils/storage.py +0 -0
  21. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface.egg-info/SOURCES.txt +0 -0
  22. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface.egg-info/dependency_links.txt +0 -0
  23. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface.egg-info/requires.txt +0 -0
  24. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/async_substrate_interface.egg-info/top_level.txt +0 -0
  25. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/setup.cfg +0 -0
  26. {async_substrate_interface-1.2.2 → async_substrate_interface-1.3.1}/tests/test_old_new.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: async-substrate-interface
3
- Version: 1.2.2
3
+ Version: 1.3.1
4
4
  Summary: Asyncio library for interacting with substrate. Mostly API-compatible with py-substrate-interface
5
5
  Author: Opentensor Foundation
6
6
  Author-email: BD Himes <b@latent.to>
@@ -22,7 +22,6 @@ from typing import (
22
22
  TYPE_CHECKING,
23
23
  )
24
24
 
25
- import asyncstdlib as a
26
25
  from bt_decode import MetadataV15, PortableRegistry, decode as decode_by_type_string
27
26
  from scalecodec.base import ScaleBytes, ScaleType, RuntimeConfigurationObject
28
27
  from scalecodec.types import (
@@ -33,7 +32,7 @@ from scalecodec.types import (
33
32
  MultiAccountId,
34
33
  )
35
34
  from websockets.asyncio.client import connect
36
- from websockets.exceptions import ConnectionClosed
35
+ from websockets.exceptions import ConnectionClosed, WebSocketException
37
36
 
38
37
  from async_substrate_interface.const import SS58_FORMAT
39
38
  from async_substrate_interface.errors import (
@@ -42,6 +41,7 @@ from async_substrate_interface.errors import (
42
41
  BlockNotFound,
43
42
  MaxRetriesExceeded,
44
43
  MetadataAtVersionNotFound,
44
+ StateDiscardedError,
45
45
  )
46
46
  from async_substrate_interface.protocols import Keypair
47
47
  from async_substrate_interface.types import (
@@ -58,7 +58,7 @@ from async_substrate_interface.utils import (
58
58
  get_next_id,
59
59
  rng as random,
60
60
  )
61
- from async_substrate_interface.utils.cache import async_sql_lru_cache
61
+ from async_substrate_interface.utils.cache import async_sql_lru_cache, CachedFetcher
62
62
  from async_substrate_interface.utils.decoding import (
63
63
  _determine_if_old_runtime_call,
64
64
  _bt_decode_to_dict_or_list,
@@ -75,6 +75,7 @@ if TYPE_CHECKING:
75
75
  ResultHandler = Callable[[dict, Any], Awaitable[tuple[dict, bool]]]
76
76
 
77
77
  logger = logging.getLogger("async_substrate_interface")
78
+ raw_websocket_logger = logging.getLogger("raw_websocket")
78
79
 
79
80
 
80
81
  class AsyncExtrinsicReceipt:
@@ -505,6 +506,7 @@ class Websocket:
505
506
  max_connections=100,
506
507
  shutdown_timer=5,
507
508
  options: Optional[dict] = None,
509
+ _log_raw_websockets: bool = False,
508
510
  ):
509
511
  """
510
512
  Websocket manager object. Allows for the use of a single websocket connection by multiple
@@ -532,6 +534,10 @@ class Websocket:
532
534
  self._exit_task = None
533
535
  self._open_subscriptions = 0
534
536
  self._options = options if options else {}
537
+ self._log_raw_websockets = _log_raw_websockets
538
+ self._is_connecting = False
539
+ self._is_closing = False
540
+
535
541
  try:
536
542
  now = asyncio.get_running_loop().time()
537
543
  except RuntimeError:
@@ -539,51 +545,80 @@ class Websocket:
539
545
  "You are instantiating the AsyncSubstrateInterface Websocket outside of an event loop. "
540
546
  "Verify this is intended."
541
547
  )
542
- now = asyncio.new_event_loop().time()
548
+ # default value for in case there's no running asyncio loop
549
+ # this really doesn't matter in most cases, as it's only used for comparison on the first call to
550
+ # see how long it's been since the last call
551
+ now = 0.0
543
552
  self.last_received = now
544
553
  self.last_sent = now
554
+ self._in_use_ids = set()
545
555
 
546
556
  async def __aenter__(self):
547
- async with self._lock:
548
- self._in_use += 1
549
- await self.connect()
557
+ self._in_use += 1
558
+ await self.connect()
550
559
  return self
551
560
 
552
561
  @staticmethod
553
562
  async def loop_time() -> float:
554
563
  return asyncio.get_running_loop().time()
555
564
 
556
- async def connect(self, force=False):
557
- now = await self.loop_time()
558
- self.last_received = now
559
- self.last_sent = now
560
- if self._exit_task:
561
- self._exit_task.cancel()
562
- if not self._initialized or force:
563
- self._initialized = True
564
- try:
565
- self._receiving_task.cancel()
566
- await self._receiving_task
567
- await self.ws.close()
568
- except (AttributeError, asyncio.CancelledError):
569
- pass
570
- self.ws = await asyncio.wait_for(
571
- connect(self.ws_url, **self._options), timeout=10
565
+ async def _cancel(self):
566
+ try:
567
+ self._receiving_task.cancel()
568
+ await self._receiving_task
569
+ await self.ws.close()
570
+ except (
571
+ AttributeError,
572
+ asyncio.CancelledError,
573
+ WebSocketException,
574
+ ):
575
+ pass
576
+ except Exception as e:
577
+ logger.warning(
578
+ f"{e} encountered while trying to close websocket connection."
572
579
  )
573
- self._receiving_task = asyncio.create_task(self._start_receiving())
574
580
 
575
- async def __aexit__(self, exc_type, exc_val, exc_tb):
576
- async with self._lock: # TODO is this actually what I want to happen?
577
- self._in_use -= 1
578
- if self._exit_task is not None:
581
+ async def connect(self, force=False):
582
+ self._is_connecting = True
583
+ try:
584
+ now = await self.loop_time()
585
+ self.last_received = now
586
+ self.last_sent = now
587
+ if self._exit_task:
579
588
  self._exit_task.cancel()
580
- try:
581
- await self._exit_task
582
- except asyncio.CancelledError:
583
- pass
584
- if self._in_use == 0 and self.ws is not None:
585
- self._open_subscriptions = 0
586
- self._exit_task = asyncio.create_task(self._exit_with_timer())
589
+ if not self._is_closing:
590
+ if not self._initialized or force:
591
+ try:
592
+ await asyncio.wait_for(self._cancel(), timeout=10.0)
593
+ except asyncio.TimeoutError:
594
+ pass
595
+
596
+ self.ws = await asyncio.wait_for(
597
+ connect(self.ws_url, **self._options), timeout=10.0
598
+ )
599
+ self._receiving_task = asyncio.get_running_loop().create_task(
600
+ self._start_receiving()
601
+ )
602
+ self._initialized = True
603
+ finally:
604
+ self._is_connecting = False
605
+
606
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
607
+ self._is_closing = True
608
+ try:
609
+ if not self._is_connecting:
610
+ self._in_use -= 1
611
+ if self._exit_task is not None:
612
+ self._exit_task.cancel()
613
+ try:
614
+ await self._exit_task
615
+ except asyncio.CancelledError:
616
+ pass
617
+ if self._in_use == 0 and self.ws is not None:
618
+ self._open_subscriptions = 0
619
+ self._exit_task = asyncio.create_task(self._exit_with_timer())
620
+ finally:
621
+ self._is_closing = False
587
622
 
588
623
  async def _exit_with_timer(self):
589
624
  """
@@ -597,28 +632,27 @@ class Websocket:
597
632
  pass
598
633
 
599
634
  async def shutdown(self):
600
- async with self._lock:
601
- try:
602
- self._receiving_task.cancel()
603
- await self._receiving_task
604
- await self.ws.close()
605
- except (AttributeError, asyncio.CancelledError):
606
- pass
607
- self.ws = None
608
- self._initialized = False
609
- self._receiving_task = None
635
+ self._is_closing = True
636
+ try:
637
+ await asyncio.wait_for(self._cancel(), timeout=10.0)
638
+ except asyncio.TimeoutError:
639
+ pass
640
+ self.ws = None
641
+ self._initialized = False
642
+ self._receiving_task = None
643
+ self._is_closing = False
610
644
 
611
645
  async def _recv(self) -> None:
612
646
  try:
613
647
  # TODO consider wrapping this in asyncio.wait_for and use that for the timeout logic
614
- response = json.loads(await self.ws.recv(decode=False))
648
+ recd = await self.ws.recv(decode=False)
649
+ if self._log_raw_websockets:
650
+ raw_websocket_logger.debug(f"WEBSOCKET_RECEIVE> {recd.decode()}")
651
+ response = json.loads(recd)
615
652
  self.last_received = await self.loop_time()
616
- async with self._lock:
617
- # note that these 'subscriptions' are all waiting sent messages which have not received
618
- # responses, and thus are not the same as RPC 'subscriptions', which are unique
619
- self._open_subscriptions -= 1
620
653
  if "id" in response:
621
654
  self._received[response["id"]] = response
655
+ self._in_use_ids.remove(response["id"])
622
656
  elif "params" in response:
623
657
  self._received[response["params"]["subscription"]] = response
624
658
  else:
@@ -635,8 +669,7 @@ class Websocket:
635
669
  except asyncio.CancelledError:
636
670
  pass
637
671
  except ConnectionClosed:
638
- async with self._lock:
639
- await self.connect(force=True)
672
+ await self.connect(force=True)
640
673
 
641
674
  async def send(self, payload: dict) -> int:
642
675
  """
@@ -649,15 +682,20 @@ class Websocket:
649
682
  id: the internal ID of the request (incremented int)
650
683
  """
651
684
  original_id = get_next_id()
685
+ while original_id in self._in_use_ids:
686
+ original_id = get_next_id()
687
+ self._in_use_ids.add(original_id)
652
688
  # self._open_subscriptions += 1
653
689
  await self.max_subscriptions.acquire()
654
690
  try:
655
- await self.ws.send(json.dumps({**payload, **{"id": original_id}}))
691
+ to_send = {**payload, **{"id": original_id}}
692
+ if self._log_raw_websockets:
693
+ raw_websocket_logger.debug(f"WEBSOCKET_SEND> {to_send}")
694
+ await self.ws.send(json.dumps(to_send))
656
695
  self.last_sent = await self.loop_time()
657
696
  return original_id
658
697
  except (ConnectionClosed, ssl.SSLError, EOFError):
659
- async with self._lock:
660
- await self.connect(force=True)
698
+ await self.connect(force=True)
661
699
 
662
700
  async def retrieve(self, item_id: int) -> Optional[dict]:
663
701
  """
@@ -674,7 +712,7 @@ class Websocket:
674
712
  self.max_subscriptions.release()
675
713
  return item
676
714
  except KeyError:
677
- await asyncio.sleep(0.001)
715
+ await asyncio.sleep(0.1)
678
716
  return None
679
717
 
680
718
 
@@ -691,6 +729,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
691
729
  max_retries: int = 5,
692
730
  retry_timeout: float = 60.0,
693
731
  _mock: bool = False,
732
+ _log_raw_websockets: bool = False,
733
+ ws_shutdown_timer: float = 5.0,
694
734
  ):
695
735
  """
696
736
  The asyncio-compatible version of the subtensor interface commands we use in bittensor. It is important to
@@ -708,6 +748,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
708
748
  max_retries: number of times to retry RPC requests before giving up
709
749
  retry_timeout: how to long wait since the last ping to retry the RPC request
710
750
  _mock: whether to use mock version of the subtensor interface
751
+ _log_raw_websockets: whether to log raw websocket requests during RPC requests
752
+ ws_shutdown_timer: how long after the last connection your websocket should close
711
753
 
712
754
  """
713
755
  self.max_retries = max_retries
@@ -715,16 +757,20 @@ class AsyncSubstrateInterface(SubstrateMixin):
715
757
  self.chain_endpoint = url
716
758
  self.url = url
717
759
  self._chain = chain_name
760
+ self._log_raw_websockets = _log_raw_websockets
718
761
  if not _mock:
719
762
  self.ws = Websocket(
720
763
  url,
764
+ _log_raw_websockets=_log_raw_websockets,
721
765
  options={
722
766
  "max_size": self.ws_max_size,
723
767
  "write_limit": 2**16,
724
768
  },
769
+ shutdown_timer=ws_shutdown_timer,
725
770
  )
726
771
  else:
727
772
  self.ws = AsyncMock(spec=Websocket)
773
+
728
774
  self._lock = asyncio.Lock()
729
775
  self.config = {
730
776
  "use_remote_preset": use_remote_preset,
@@ -748,6 +794,12 @@ class AsyncSubstrateInterface(SubstrateMixin):
748
794
  self.registry_type_map = {}
749
795
  self.type_id_to_name = {}
750
796
  self._mock = _mock
797
+ self._block_hash_fetcher = CachedFetcher(512, self._get_block_hash)
798
+ self._parent_hash_fetcher = CachedFetcher(512, self._get_parent_block_hash)
799
+ self._runtime_info_fetcher = CachedFetcher(16, self._get_block_runtime_info)
800
+ self._runtime_version_for_fetcher = CachedFetcher(
801
+ 512, self._get_block_runtime_version_for
802
+ )
751
803
 
752
804
  async def __aenter__(self):
753
805
  if not self._mock:
@@ -1869,9 +1921,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
1869
1921
 
1870
1922
  return runtime.metadata_v15
1871
1923
 
1872
- @a.lru_cache(maxsize=512)
1873
1924
  async def get_parent_block_hash(self, block_hash):
1874
- return await self._get_parent_block_hash(block_hash)
1925
+ return await self._parent_hash_fetcher.execute(block_hash)
1875
1926
 
1876
1927
  async def _get_parent_block_hash(self, block_hash):
1877
1928
  block_header = await self.rpc_request("chain_getHeader", [block_hash])
@@ -1916,9 +1967,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
1916
1967
  "Unknown error occurred during retrieval of events"
1917
1968
  )
1918
1969
 
1919
- @a.lru_cache(maxsize=16)
1920
1970
  async def get_block_runtime_info(self, block_hash: str) -> dict:
1921
- return await self._get_block_runtime_info(block_hash)
1971
+ return await self._runtime_info_fetcher.execute(block_hash)
1922
1972
 
1923
1973
  get_block_runtime_version = get_block_runtime_info
1924
1974
 
@@ -1929,9 +1979,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
1929
1979
  response = await self.rpc_request("state_getRuntimeVersion", [block_hash])
1930
1980
  return response.get("result")
1931
1981
 
1932
- @a.lru_cache(maxsize=512)
1933
1982
  async def get_block_runtime_version_for(self, block_hash: str):
1934
- return await self._get_block_runtime_version_for(block_hash)
1983
+ return await self._runtime_version_for_fetcher.execute(block_hash)
1935
1984
 
1936
1985
  async def _get_block_runtime_version_for(self, block_hash: str):
1937
1986
  """
@@ -2137,6 +2186,7 @@ class AsyncSubstrateInterface(SubstrateMixin):
2137
2186
  storage_item,
2138
2187
  result_handler,
2139
2188
  )
2189
+
2140
2190
  request_manager.add_response(
2141
2191
  item_id, decoded_response, complete
2142
2192
  )
@@ -2149,14 +2199,14 @@ class AsyncSubstrateInterface(SubstrateMixin):
2149
2199
  and current_time - self.ws.last_sent >= self.retry_timeout
2150
2200
  ):
2151
2201
  if attempt >= self.max_retries:
2152
- logger.warning(
2202
+ logger.error(
2153
2203
  f"Timed out waiting for RPC requests {attempt} times. Exiting."
2154
2204
  )
2155
2205
  raise MaxRetriesExceeded("Max retries reached.")
2156
2206
  else:
2157
2207
  self.ws.last_received = time.time()
2158
2208
  await self.ws.connect(force=True)
2159
- logger.error(
2209
+ logger.warning(
2160
2210
  f"Timed out waiting for RPC requests. "
2161
2211
  f"Retrying attempt {attempt + 1} of {self.max_retries}"
2162
2212
  )
@@ -2223,9 +2273,8 @@ class AsyncSubstrateInterface(SubstrateMixin):
2223
2273
  ]
2224
2274
  result = await self._make_rpc_request(payloads, result_handler=result_handler)
2225
2275
  if "error" in result[payload_id][0]:
2226
- if (
2227
- "Failed to get runtime version"
2228
- in result[payload_id][0]["error"]["message"]
2276
+ if "Failed to get runtime version" in (
2277
+ err_msg := result[payload_id][0]["error"]["message"]
2229
2278
  ):
2230
2279
  logger.warning(
2231
2280
  "Failed to get runtime. Re-fetching from chain, and retrying."
@@ -2234,15 +2283,21 @@ class AsyncSubstrateInterface(SubstrateMixin):
2234
2283
  return await self.rpc_request(
2235
2284
  method, params, result_handler, block_hash, reuse_block_hash
2236
2285
  )
2237
- raise SubstrateRequestException(result[payload_id][0]["error"]["message"])
2286
+ elif (
2287
+ "Client error: Api called for an unknown Block: State already discarded"
2288
+ in err_msg
2289
+ ):
2290
+ bh = err_msg.split("State already discarded for ")[1].strip()
2291
+ raise StateDiscardedError(bh)
2292
+ else:
2293
+ raise SubstrateRequestException(err_msg)
2238
2294
  if "result" in result[payload_id][0]:
2239
2295
  return result[payload_id][0]
2240
2296
  else:
2241
2297
  raise SubstrateRequestException(result[payload_id][0])
2242
2298
 
2243
- @a.lru_cache(maxsize=512)
2244
2299
  async def get_block_hash(self, block_id: int) -> str:
2245
- return await self._get_block_hash(block_id)
2300
+ return await self._block_hash_fetcher.execute(block_id)
2246
2301
 
2247
2302
  async def _get_block_hash(self, block_id: int) -> str:
2248
2303
  return (await self.rpc_request("chain_getBlockHash", [block_id]))["result"]
@@ -22,6 +22,16 @@ class MetadataAtVersionNotFound(SubstrateRequestException):
22
22
  super().__init__(message)
23
23
 
24
24
 
25
+ class StateDiscardedError(SubstrateRequestException):
26
+ def __init__(self, block_hash: str):
27
+ self.block_hash = block_hash
28
+ message = (
29
+ f"State discarded for {block_hash}. This indicates the block is too old, and you should instead "
30
+ f"make this request using an archive node."
31
+ )
32
+ super().__init__(message)
33
+
34
+
25
35
  class StorageFunctionNotFound(ValueError):
26
36
  pass
27
37
 
@@ -13,7 +13,7 @@ from typing import Optional
13
13
  from websockets.exceptions import ConnectionClosed
14
14
 
15
15
  from async_substrate_interface.async_substrate import AsyncSubstrateInterface, Websocket
16
- from async_substrate_interface.errors import MaxRetriesExceeded
16
+ from async_substrate_interface.errors import MaxRetriesExceeded, StateDiscardedError
17
17
  from async_substrate_interface.sync_substrate import SubstrateInterface
18
18
 
19
19
  logger = logging.getLogger("async_substrate_interface")
@@ -117,13 +117,19 @@ class RetrySyncSubstrate(SubstrateInterface):
117
117
  max_retries: int = 5,
118
118
  retry_timeout: float = 60.0,
119
119
  _mock: bool = False,
120
+ _log_raw_websockets: bool = False,
121
+ archive_nodes: Optional[list[str]] = None,
120
122
  ):
121
123
  fallback_chains = fallback_chains or []
124
+ archive_nodes = archive_nodes or []
122
125
  self.fallback_chains = (
123
126
  iter(fallback_chains)
124
127
  if not retry_forever
125
128
  else cycle(fallback_chains + [url])
126
129
  )
130
+ self.archive_nodes = (
131
+ iter(archive_nodes) if not retry_forever else cycle(archive_nodes)
132
+ )
127
133
  self.use_remote_preset = use_remote_preset
128
134
  self.chain_name = chain_name
129
135
  self._mock = _mock
@@ -146,6 +152,7 @@ class RetrySyncSubstrate(SubstrateInterface):
146
152
  _mock=_mock,
147
153
  retry_timeout=retry_timeout,
148
154
  max_retries=max_retries,
155
+ _log_raw_websockets=_log_raw_websockets,
149
156
  )
150
157
  initialized = True
151
158
  logger.info(f"Connected to {chain_url}")
@@ -174,9 +181,12 @@ class RetrySyncSubstrate(SubstrateInterface):
174
181
  EOFError,
175
182
  ConnectionClosed,
176
183
  TimeoutError,
184
+ socket.gaierror,
185
+ StateDiscardedError,
177
186
  ) as e:
187
+ use_archive = isinstance(e, StateDiscardedError)
178
188
  try:
179
- self._reinstantiate_substrate(e)
189
+ self._reinstantiate_substrate(e, use_archive=use_archive)
180
190
  return method_(*args, **kwargs)
181
191
  except StopIteration:
182
192
  logger.error(
@@ -184,10 +194,19 @@ class RetrySyncSubstrate(SubstrateInterface):
184
194
  )
185
195
  raise MaxRetriesExceeded
186
196
 
187
- def _reinstantiate_substrate(self, e: Optional[Exception] = None) -> None:
188
- next_network = next(self.fallback_chains)
197
+ def _reinstantiate_substrate(
198
+ self, e: Optional[Exception] = None, use_archive: bool = False
199
+ ) -> None:
200
+ if use_archive:
201
+ bh = getattr(e, "block_hash", "Unknown Block Hash")
202
+ logger.info(
203
+ f"Attempt made to {bh} failed for state discarded. Attempting to switch to archive node."
204
+ )
205
+ next_network = next(self.archive_nodes)
206
+ else:
207
+ next_network = next(self.fallback_chains)
189
208
  self.ws.close()
190
- if e.__class__ == MaxRetriesExceeded:
209
+ if isinstance(e, MaxRetriesExceeded):
191
210
  logger.error(
192
211
  f"Max retries exceeded with {self.url}. Retrying with {next_network}."
193
212
  )
@@ -243,13 +262,20 @@ class RetryAsyncSubstrate(AsyncSubstrateInterface):
243
262
  max_retries: int = 5,
244
263
  retry_timeout: float = 60.0,
245
264
  _mock: bool = False,
265
+ _log_raw_websockets: bool = False,
266
+ archive_nodes: Optional[list[str]] = None,
267
+ ws_shutdown_timer: float = 5.0,
246
268
  ):
247
269
  fallback_chains = fallback_chains or []
270
+ archive_nodes = archive_nodes or []
248
271
  self.fallback_chains = (
249
272
  iter(fallback_chains)
250
273
  if not retry_forever
251
274
  else cycle(fallback_chains + [url])
252
275
  )
276
+ self.archive_nodes = (
277
+ iter(archive_nodes) if not retry_forever else cycle(archive_nodes)
278
+ )
253
279
  self.use_remote_preset = use_remote_preset
254
280
  self.chain_name = chain_name
255
281
  self._mock = _mock
@@ -265,6 +291,8 @@ class RetryAsyncSubstrate(AsyncSubstrateInterface):
265
291
  _mock=_mock,
266
292
  retry_timeout=retry_timeout,
267
293
  max_retries=max_retries,
294
+ _log_raw_websockets=_log_raw_websockets,
295
+ ws_shutdown_timer=ws_shutdown_timer,
268
296
  )
269
297
  self._original_methods = {
270
298
  method: getattr(self, method) for method in RETRY_METHODS
@@ -272,9 +300,18 @@ class RetryAsyncSubstrate(AsyncSubstrateInterface):
272
300
  for method in RETRY_METHODS:
273
301
  setattr(self, method, partial(self._retry, method))
274
302
 
275
- async def _reinstantiate_substrate(self, e: Optional[Exception] = None) -> None:
276
- next_network = next(self.fallback_chains)
277
- if e.__class__ == MaxRetriesExceeded:
303
+ async def _reinstantiate_substrate(
304
+ self, e: Optional[Exception] = None, use_archive: bool = False
305
+ ) -> None:
306
+ if use_archive:
307
+ bh = getattr(e, "block_hash", "Unknown Block Hash")
308
+ logger.info(
309
+ f"Attempt made to {bh} failed for state discarded. Attempting to switch to archive node."
310
+ )
311
+ next_network = next(self.archive_nodes)
312
+ else:
313
+ next_network = next(self.fallback_chains)
314
+ if isinstance(e, MaxRetriesExceeded):
278
315
  logger.error(
279
316
  f"Max retries exceeded with {self.url}. Retrying with {next_network}."
280
317
  )
@@ -314,11 +351,13 @@ class RetryAsyncSubstrate(AsyncSubstrateInterface):
314
351
  ConnectionClosed,
315
352
  EOFError,
316
353
  socket.gaierror,
354
+ StateDiscardedError,
317
355
  ) as e:
356
+ use_archive = isinstance(e, StateDiscardedError)
318
357
  try:
319
- await self._reinstantiate_substrate(e)
358
+ await self._reinstantiate_substrate(e, use_archive=use_archive)
320
359
  return await method_(*args, **kwargs)
321
- except StopAsyncIteration:
360
+ except StopIteration:
322
361
  logger.error(
323
362
  f"Max retries exceeded with {self.url}. No more fallback chains."
324
363
  )
@@ -24,6 +24,7 @@ from async_substrate_interface.errors import (
24
24
  BlockNotFound,
25
25
  MaxRetriesExceeded,
26
26
  MetadataAtVersionNotFound,
27
+ StateDiscardedError,
27
28
  )
28
29
  from async_substrate_interface.protocols import Keypair
29
30
  from async_substrate_interface.types import (
@@ -52,6 +53,7 @@ from async_substrate_interface.type_registry import _TYPE_REGISTRY
52
53
  ResultHandler = Callable[[dict, Any], tuple[dict, bool]]
53
54
 
54
55
  logger = logging.getLogger("async_substrate_interface")
56
+ raw_websocket_logger = logging.getLogger("raw_websocket")
55
57
 
56
58
 
57
59
  class ExtrinsicReceipt:
@@ -484,6 +486,7 @@ class SubstrateInterface(SubstrateMixin):
484
486
  max_retries: int = 5,
485
487
  retry_timeout: float = 60.0,
486
488
  _mock: bool = False,
489
+ _log_raw_websockets: bool = False,
487
490
  ):
488
491
  """
489
492
  The sync compatible version of the subtensor interface commands we use in bittensor. Use this instance only
@@ -500,6 +503,7 @@ class SubstrateInterface(SubstrateMixin):
500
503
  max_retries: number of times to retry RPC requests before giving up
501
504
  retry_timeout: how to long wait since the last ping to retry the RPC request
502
505
  _mock: whether to use mock version of the subtensor interface
506
+ _log_raw_websockets: whether to log raw websocket requests during RPC requests
503
507
 
504
508
  """
505
509
  self.max_retries = max_retries
@@ -526,6 +530,7 @@ class SubstrateInterface(SubstrateMixin):
526
530
  self.registry_type_map = {}
527
531
  self.type_id_to_name = {}
528
532
  self._mock = _mock
533
+ self.log_raw_websockets = _log_raw_websockets
529
534
  if not _mock:
530
535
  self.ws = self.connect(init=True)
531
536
  self.initialize()
@@ -1830,12 +1835,18 @@ class SubstrateInterface(SubstrateMixin):
1830
1835
  ws = self.connect(init=False if attempt == 1 else True)
1831
1836
  for payload in payloads:
1832
1837
  item_id = get_next_id()
1833
- ws.send(json.dumps({**payload["payload"], **{"id": item_id}}))
1838
+ to_send = {**payload["payload"], **{"id": item_id}}
1839
+ if self.log_raw_websockets:
1840
+ raw_websocket_logger.debug(f"WEBSOCKET_SEND> {to_send}")
1841
+ ws.send(json.dumps(to_send))
1834
1842
  request_manager.add_request(item_id, payload["id"])
1835
1843
 
1836
1844
  while True:
1837
1845
  try:
1838
- response = json.loads(ws.recv(timeout=self.retry_timeout, decode=False))
1846
+ recd = ws.recv(timeout=self.retry_timeout, decode=False)
1847
+ if self.log_raw_websockets:
1848
+ raw_websocket_logger.debug(f"WEBSOCKET_RECEIVE> {recd.decode()}")
1849
+ response = json.loads(recd)
1839
1850
  except (TimeoutError, ConnectionClosed):
1840
1851
  if attempt >= self.max_retries:
1841
1852
  logger.warning(
@@ -1944,9 +1955,8 @@ class SubstrateInterface(SubstrateMixin):
1944
1955
  ]
1945
1956
  result = self._make_rpc_request(payloads, result_handler=result_handler)
1946
1957
  if "error" in result[payload_id][0]:
1947
- if (
1948
- "Failed to get runtime version"
1949
- in result[payload_id][0]["error"]["message"]
1958
+ if "Failed to get runtime version" in (
1959
+ err_msg := result[payload_id][0]["error"]["message"]
1950
1960
  ):
1951
1961
  logger.warning(
1952
1962
  "Failed to get runtime. Re-fetching from chain, and retrying."
@@ -1955,7 +1965,14 @@ class SubstrateInterface(SubstrateMixin):
1955
1965
  return self.rpc_request(
1956
1966
  method, params, result_handler, block_hash, reuse_block_hash
1957
1967
  )
1958
- raise SubstrateRequestException(result[payload_id][0]["error"]["message"])
1968
+ elif (
1969
+ "Client error: Api called for an unknown Block: State already discarded"
1970
+ in err_msg
1971
+ ):
1972
+ bh = err_msg.split("State already discarded for ")[1].strip()
1973
+ raise StateDiscardedError(bh)
1974
+ else:
1975
+ raise SubstrateRequestException(err_msg)
1959
1976
  if "result" in result[payload_id][0]:
1960
1977
  return result[payload_id][0]
1961
1978
  else:
@@ -2497,13 +2514,13 @@ class SubstrateInterface(SubstrateMixin):
2497
2514
  Returns:
2498
2515
  ScaleType from the runtime call
2499
2516
  """
2500
- self.init_runtime(block_hash=block_hash)
2517
+ runtime = self.init_runtime(block_hash=block_hash)
2501
2518
 
2502
2519
  if params is None:
2503
2520
  params = {}
2504
2521
 
2505
2522
  try:
2506
- metadata_v15_value = self.runtime.metadata_v15.value()
2523
+ metadata_v15_value = runtime.metadata_v15.value()
2507
2524
 
2508
2525
  apis = {entry["name"]: entry for entry in metadata_v15_value["apis"]}
2509
2526
  api_entry = apis[api]
@@ -1,10 +1,15 @@
1
+ import asyncio
2
+ from collections import OrderedDict
1
3
  import functools
2
4
  import os
3
5
  import pickle
4
6
  import sqlite3
5
7
  from pathlib import Path
8
+ from typing import Callable, Any
9
+
6
10
  import asyncstdlib as a
7
11
 
12
+
8
13
  USE_CACHE = True if os.getenv("NO_CACHE") != "1" else False
9
14
  CACHE_LOCATION = (
10
15
  os.path.expanduser(
@@ -139,3 +144,54 @@ def async_sql_lru_cache(maxsize=None):
139
144
  return inner
140
145
 
141
146
  return decorator
147
+
148
+
149
+ class LRUCache:
150
+ def __init__(self, max_size: int):
151
+ self.max_size = max_size
152
+ self.cache = OrderedDict()
153
+
154
+ def set(self, key, value):
155
+ if key in self.cache:
156
+ self.cache.move_to_end(key)
157
+ self.cache[key] = value
158
+ if len(self.cache) > self.max_size:
159
+ self.cache.popitem(last=False)
160
+
161
+ def get(self, key):
162
+ if key in self.cache:
163
+ # Mark as recently used
164
+ self.cache.move_to_end(key)
165
+ return self.cache[key]
166
+ return None
167
+
168
+
169
+ class CachedFetcher:
170
+ def __init__(self, max_size: int, method: Callable):
171
+ self._inflight: dict[int, asyncio.Future] = {}
172
+ self._method = method
173
+ self._cache = LRUCache(max_size=max_size)
174
+
175
+ async def execute(self, single_arg: Any) -> str:
176
+ if item := self._cache.get(single_arg):
177
+ return item
178
+
179
+ if single_arg in self._inflight:
180
+ result = await self._inflight[single_arg]
181
+ return result
182
+
183
+ loop = asyncio.get_running_loop()
184
+ future = loop.create_future()
185
+ self._inflight[single_arg] = future
186
+
187
+ try:
188
+ result = await self._method(single_arg)
189
+ self._cache.set(single_arg, result)
190
+ future.set_result(result)
191
+ return result
192
+ except Exception as e:
193
+ # Propagate errors
194
+ future.set_exception(e)
195
+ raise
196
+ finally:
197
+ self._inflight.pop(single_arg, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: async-substrate-interface
3
- Version: 1.2.2
3
+ Version: 1.3.1
4
4
  Summary: Asyncio library for interacting with substrate. Mostly API-compatible with py-substrate-interface
5
5
  Author: Opentensor Foundation
6
6
  Author-email: BD Himes <b@latent.to>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "async-substrate-interface"
3
- version = "1.2.2"
3
+ version = "1.3.1"
4
4
  description = "Asyncio library for interacting with substrate. Mostly API-compatible with py-substrate-interface"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -4,8 +4,12 @@ import subprocess
4
4
  import pytest
5
5
  import time
6
6
 
7
- from async_substrate_interface.substrate_addons import RetrySyncSubstrate
8
- from async_substrate_interface.errors import MaxRetriesExceeded
7
+ from async_substrate_interface import AsyncSubstrateInterface, SubstrateInterface
8
+ from async_substrate_interface.substrate_addons import (
9
+ RetrySyncSubstrate,
10
+ RetryAsyncSubstrate,
11
+ )
12
+ from async_substrate_interface.errors import MaxRetriesExceeded, StateDiscardedError
9
13
  from tests.conftest import start_docker_container
10
14
 
11
15
  LATENT_LITE_ENTRYPOINT = "wss://lite.sub.latent.to:443"
@@ -70,3 +74,28 @@ def test_retry_sync_substrate_offline():
70
74
  RetrySyncSubstrate(
71
75
  "ws://127.0.0.1:9945", fallback_chains=["ws://127.0.0.1:9946"]
72
76
  )
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_retry_async_subtensor_archive_node():
81
+ async with AsyncSubstrateInterface("wss://lite.sub.latent.to:443") as substrate:
82
+ current_block = await substrate.get_block_number()
83
+ old_block = current_block - 1000
84
+ with pytest.raises(StateDiscardedError):
85
+ await substrate.get_block(block_number=old_block)
86
+ async with RetryAsyncSubstrate(
87
+ "wss://lite.sub.latent.to:443", archive_nodes=["ws://178.156.172.75:9944"]
88
+ ) as substrate:
89
+ assert isinstance((await substrate.get_block(block_number=old_block)), dict)
90
+
91
+
92
+ def test_retry_sync_subtensor_archive_node():
93
+ with SubstrateInterface("wss://lite.sub.latent.to:443") as substrate:
94
+ current_block = substrate.get_block_number()
95
+ old_block = current_block - 1000
96
+ with pytest.raises(StateDiscardedError):
97
+ substrate.get_block(block_number=old_block)
98
+ with RetrySyncSubstrate(
99
+ "wss://lite.sub.latent.to:443", archive_nodes=["ws://178.156.172.75:9944"]
100
+ ) as substrate:
101
+ assert isinstance((substrate.get_block(block_number=old_block)), dict)