Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 1.2.1 /2025-05-22

## What's Changed
* Add proper mock support by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/123
* Handle Incorrect Timeouts by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/124

**Full Changelog**: https://github.com/opentensor/async-substrate-interface/compare/v1.2.1...v1.2.2

## 1.2.1 /2025-05-12

## What's Changed
Expand Down
51 changes: 39 additions & 12 deletions async_substrate_interface/async_substrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import logging
import ssl
import time
import warnings
from unittest.mock import AsyncMock
from hashlib import blake2b
from typing import (
Optional,
Expand Down Expand Up @@ -530,15 +532,31 @@ def __init__(
self._exit_task = None
self._open_subscriptions = 0
self._options = options if options else {}
self.last_received = time.time()
try:
now = asyncio.get_running_loop().time()
except RuntimeError:
warnings.warn(
"You are instantiating the AsyncSubstrateInterface Websocket outside of an event loop. "
"Verify this is intended."
)
now = asyncio.new_event_loop().time()
self.last_received = now
self.last_sent = now

async def __aenter__(self):
async with self._lock:
self._in_use += 1
await self.connect()
return self

@staticmethod
async def loop_time() -> float:
return asyncio.get_running_loop().time()

async def connect(self, force=False):
now = await self.loop_time()
self.last_received = now
self.last_sent = now
if self._exit_task:
self._exit_task.cancel()
if not self._initialized or force:
Expand Down Expand Up @@ -594,7 +612,7 @@ async def _recv(self) -> None:
try:
# TODO consider wrapping this in asyncio.wait_for and use that for the timeout logic
response = json.loads(await self.ws.recv(decode=False))
self.last_received = time.time()
self.last_received = await self.loop_time()
async with self._lock:
# note that these 'subscriptions' are all waiting sent messages which have not received
# responses, and thus are not the same as RPC 'subscriptions', which are unique
Expand Down Expand Up @@ -630,12 +648,12 @@ async def send(self, payload: dict) -> int:
Returns:
id: the internal ID of the request (incremented int)
"""
# async with self._lock:
original_id = get_next_id()
# self._open_subscriptions += 1
await self.max_subscriptions.acquire()
try:
await self.ws.send(json.dumps({**payload, **{"id": original_id}}))
self.last_sent = await self.loop_time()
return original_id
except (ConnectionClosed, ssl.SSLError, EOFError):
async with self._lock:
Expand Down Expand Up @@ -697,13 +715,16 @@ def __init__(
self.chain_endpoint = url
self.url = url
self._chain = chain_name
self.ws = Websocket(
url,
options={
"max_size": self.ws_max_size,
"write_limit": 2**16,
},
)
if not _mock:
self.ws = Websocket(
url,
options={
"max_size": self.ws_max_size,
"write_limit": 2**16,
},
)
else:
self.ws = AsyncMock(spec=Websocket)
self._lock = asyncio.Lock()
self.config = {
"use_remote_preset": use_remote_preset,
Expand All @@ -726,9 +747,11 @@ def __init__(
self._initializing = False
self.registry_type_map = {}
self.type_id_to_name = {}
self._mock = _mock

async def __aenter__(self):
await self.initialize()
if not self._mock:
await self.initialize()
return self

async def initialize(self):
Expand Down Expand Up @@ -2120,7 +2143,11 @@ async def _make_rpc_request(

if request_manager.is_complete:
break
if time.time() - self.ws.last_received >= self.retry_timeout:
if (
(current_time := await self.ws.loop_time()) - self.ws.last_received
>= self.retry_timeout
and current_time - self.ws.last_sent >= self.retry_timeout
):
if attempt >= self.max_retries:
logger.warning(
f"Timed out waiting for RPC requests {attempt} times. Exiting."
Expand Down
11 changes: 8 additions & 3 deletions async_substrate_interface/sync_substrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import socket
from hashlib import blake2b
from typing import Optional, Union, Callable, Any
from unittest.mock import MagicMock

from bt_decode import MetadataV15, PortableRegistry, decode as decode_by_type_string
from scalecodec import (
Expand All @@ -13,7 +14,7 @@
MultiAccountId,
)
from scalecodec.base import RuntimeConfigurationObject, ScaleBytes, ScaleType
from websockets.sync.client import connect
from websockets.sync.client import connect, ClientConnection
from websockets.exceptions import ConnectionClosed

from async_substrate_interface.const import SS58_FORMAT
Expand Down Expand Up @@ -522,14 +523,18 @@ def __init__(
)
self.metadata_version_hex = "0x0f000000" # v15
self.reload_type_registry()
self.ws = self.connect(init=True)
self.registry_type_map = {}
self.type_id_to_name = {}
self._mock = _mock
if not _mock:
self.ws = self.connect(init=True)
self.initialize()
else:
self.ws = MagicMock(spec=ClientConnection)

def __enter__(self):
self.initialize()
if not self._mock:
self.initialize()
return self

def __del__(self):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "async-substrate-interface"
version = "1.2.1"
version = "1.2.2"
description = "Asyncio library for interacting with substrate. Mostly API-compatible with py-substrate-interface"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
29 changes: 29 additions & 0 deletions tests/unit_tests/test_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from websockets.exceptions import InvalidURI
import pytest

from async_substrate_interface import AsyncSubstrateInterface, SubstrateInterface


@pytest.mark.asyncio
async def test_async_mock():
ssi = AsyncSubstrateInterface("notreal")
assert isinstance(ssi, AsyncSubstrateInterface)
with pytest.raises(InvalidURI):
await ssi.initialize()
async with AsyncSubstrateInterface("notreal", _mock=True) as ssi:
assert isinstance(ssi, AsyncSubstrateInterface)
ssi = AsyncSubstrateInterface("notreal", _mock=True)
async with ssi:
pass


def test_sync_mock():
with pytest.raises(InvalidURI):
SubstrateInterface("notreal")
ssi = SubstrateInterface("notreal", _mock=True)
assert isinstance(ssi, SubstrateInterface)
with pytest.raises(InvalidURI):
with SubstrateInterface("notreal") as ssi:
pass
with SubstrateInterface("notreal", _mock=True) as ssi:
assert isinstance(ssi, SubstrateInterface)
Loading