rpc_client.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import asyncio
  2. import json
  3. import logging
  4. from typing import Any, Optional
  5. from contextlib import asynccontextmanager
  6. logger = logging.getLogger(__name__)
  7. class JsonRpcError(Exception):
  8. """Error returned by the RPC server."""
  9. def __init__(self, code: int, message: str, data: Any = None):
  10. self.code = code
  11. self.message = message
  12. self.data = data
  13. super().__init__(f"RPC Error {code}: {message}")
  14. class RpcUnavailableError(Exception):
  15. """Raised when RPC endpoint is not reachable."""
  16. pass
  17. class JsonRpcConnection:
  18. """Single JSON-RPC connection over TCP."""
  19. def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
  20. self.reader = reader
  21. self.writer = writer
  22. self.request_id = 0
  23. self._lock = asyncio.Lock()
  24. self._closed = False
  25. @property
  26. def is_closed(self) -> bool:
  27. return self._closed or self.writer.is_closing()
  28. async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
  29. if self.is_closed:
  30. raise ConnectionError("Connection is closed")
  31. async with self._lock:
  32. self.request_id += 1
  33. request = {
  34. "jsonrpc": "2.0",
  35. "method": method,
  36. "id": self.request_id,
  37. "params": params,
  38. }
  39. message = json.dumps(request) + "\n"
  40. try:
  41. self.writer.write(message.encode("utf-8"))
  42. await self.writer.drain()
  43. response_line = await asyncio.wait_for(
  44. self.reader.readline(),
  45. timeout=timeout
  46. )
  47. except asyncio.TimeoutError:
  48. self._closed = True
  49. raise TimeoutError(f"RPC call '{method}' timed out after {timeout}s")
  50. except (ConnectionError, OSError) as e:
  51. self._closed = True
  52. raise ConnectionError(f"Connection lost: {e}")
  53. if not response_line:
  54. self._closed = True
  55. raise ConnectionError("Connection closed by server")
  56. response = json.loads(response_line.decode("utf-8"))
  57. if "error" in response and response["error"]:
  58. err = response["error"]
  59. raise JsonRpcError(
  60. err.get("code", -1),
  61. err.get("message", "Unknown error"),
  62. err.get("data")
  63. )
  64. return response.get("result")
  65. async def close(self):
  66. if not self._closed:
  67. self._closed = True
  68. self.writer.close()
  69. try:
  70. await self.writer.wait_closed()
  71. except Exception:
  72. pass
  73. class JsonRpcPool:
  74. """
  75. Connection pool with background reconnection.
  76. - If RPC is unavailable, immediately returns error (no waiting)
  77. - Background task keeps trying to reconnect every N seconds
  78. - Once connected, requests work again
  79. """
  80. def __init__(
  81. self,
  82. host: str,
  83. port: int,
  84. min_connections: int = 5,
  85. max_connections: int = 20,
  86. reconnect_interval: float = 5.0,
  87. connect_timeout: float = 5.0,
  88. ):
  89. self.host = host
  90. self.port = port
  91. self.min_connections = min_connections
  92. self.max_connections = max_connections
  93. self.reconnect_interval = reconnect_interval
  94. self.connect_timeout = connect_timeout
  95. self._pool: asyncio.Queue[JsonRpcConnection] = None
  96. self._semaphore: asyncio.Semaphore = None
  97. self._connection_count = 0
  98. self._lock = asyncio.Lock()
  99. self._closed = False
  100. self._available = False
  101. self._reconnect_task: Optional[asyncio.Task] = None
  102. async def start(self):
  103. """Initialize the pool"""
  104. self._pool = asyncio.Queue()
  105. self._semaphore = asyncio.Semaphore(self.max_connections)
  106. self._connection_count = 0
  107. self._closed = False
  108. # Try to create initial connections
  109. success_count = 0
  110. for _ in range(self.min_connections):
  111. conn = await self._create_connection()
  112. if conn:
  113. await self._pool.put(conn)
  114. success_count += 1
  115. if success_count > 0:
  116. self._available = True
  117. logger.info(f"RPC pool started with {success_count} connections")
  118. else:
  119. self._available = False
  120. logger.warning(f"RPC {self.host}:{self.port} unavailable, will retry in background")
  121. self._start_reconnect_task()
  122. def _start_reconnect_task(self):
  123. """Start background reconnection task if not already running."""
  124. if self._reconnect_task is None or self._reconnect_task.done():
  125. self._reconnect_task = asyncio.create_task(self._reconnect_loop())
  126. async def _reconnect_loop(self):
  127. """Background task that keeps trying to reconnect."""
  128. while not self._closed and not self._available:
  129. await asyncio.sleep(self.reconnect_interval)
  130. if self._closed:
  131. break
  132. conn = await self._create_connection()
  133. if conn:
  134. await self._pool.put(conn)
  135. self._available = True
  136. logger.info(f"RPC {self.host}:{self.port} reconnected")
  137. break
  138. else:
  139. logger.debug(f"RPC {self.host}:{self.port} still unavailable, retrying...")
  140. async def _create_connection(self) -> Optional[JsonRpcConnection]:
  141. """Create a new connection. Returns None if connection fails."""
  142. try:
  143. reader, writer = await asyncio.wait_for(
  144. asyncio.open_connection(self.host, self.port, limit=16*1024*1024),
  145. timeout=self.connect_timeout
  146. )
  147. async with self._lock:
  148. self._connection_count += 1
  149. return JsonRpcConnection(reader, writer)
  150. except (asyncio.TimeoutError, OSError) as e:
  151. logger.debug(f"Connection failed: {e}")
  152. return None
  153. async def _destroy_connection(self, conn: JsonRpcConnection):
  154. """Close and clean up a connection"""
  155. await conn.close()
  156. async with self._lock:
  157. self._connection_count = max(0, self._connection_count - 1)
  158. async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
  159. """Make an RPC call. Raises RpcUnavailableError immediately if not connected."""
  160. if self._closed:
  161. raise RuntimeError("Pool's closed")
  162. if not self._available:
  163. raise RpcUnavailableError(f"RPC {self.host}:{self.port} is unavailable")
  164. async with self._semaphore:
  165. # Get or create connection
  166. conn = None
  167. while not self._pool.empty():
  168. try:
  169. conn = self._pool.get_nowait()
  170. if not conn.is_closed:
  171. break
  172. await self._destroy_connection(conn)
  173. conn = None
  174. except asyncio.QueueEmpty:
  175. break
  176. if conn is None:
  177. conn = await self._create_connection()
  178. if conn is None:
  179. self._available = False
  180. self._start_reconnect_task()
  181. raise RpcUnavailableError(f"RPC {self.host}:{self.port} is unavailable")
  182. # Make the call
  183. try:
  184. result = await conn.call(method, params, timeout)
  185. await self._pool.put(conn)
  186. return result
  187. except JsonRpcError:
  188. # Server error - connection is still good
  189. await self._pool.put(conn)
  190. raise
  191. except (ConnectionError, TimeoutError) as e:
  192. # Connection failed
  193. await self._destroy_connection(conn)
  194. self._available = False
  195. self._start_reconnect_task()
  196. raise RpcUnavailableError(f"RPC {self.host}:{self.port} is unavailable: {e}")
  197. @property
  198. def is_available(self) -> bool:
  199. """Check if RPC is currently available."""
  200. return self._available
  201. async def close(self):
  202. """Close all connections"""
  203. self._closed = True
  204. if self._reconnect_task and not self._reconnect_task.done():
  205. self._reconnect_task.cancel()
  206. try:
  207. await self._reconnect_task
  208. except asyncio.CancelledError:
  209. pass
  210. while not self._pool.empty():
  211. try:
  212. conn = self._pool.get_nowait()
  213. await self._destroy_connection(conn)
  214. except asyncio.QueueEmpty:
  215. break
  216. logger.info("RPC pool closed")