rpc_client.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import asyncio
  2. import json
  3. from dataclasses import dataclass
  4. from typing import Any
  5. from contextlib import asynccontextmanager
  6. class JsonRpcError(Exception):
  7. def __init__(self, code: int, message: str, data: Any = None):
  8. self.code = code
  9. self.message = message
  10. self.data = data
  11. super().__init__(f"RPC Error {code}: {message}")
  12. class JsonRpcConnection:
  13. """Single JSON-RPC connection over TCP"""
  14. def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
  15. self.reader = reader
  16. self.writer = writer
  17. self.request_id = 0
  18. self._lock = asyncio.Lock()
  19. self._closed = False
  20. @property
  21. def is_closed(self) -> bool:
  22. return self._closed or self.writer.is_closing()
  23. async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
  24. if self.is_closed:
  25. raise ConnectionError("Connection is closed")
  26. async with self._lock:
  27. self.request_id += 1
  28. request = {
  29. "jsonrpc": "2.0",
  30. "method": method,
  31. "id": self.request_id,
  32. "params": params,
  33. }
  34. message = json.dumps(request) + "\n"
  35. try:
  36. self.writer.write(message.encode("utf-8"))
  37. await self.writer.drain()
  38. response_line = await asyncio.wait_for(
  39. self.reader.readline(),
  40. timeout=timeout
  41. )
  42. except asyncio.TimeoutError:
  43. self._closed = True
  44. raise TimeoutError(f"RPC call '{method}' timed out after {timeout}s")
  45. except (ConnectionError, OSError) as e:
  46. self._closed = True
  47. raise ConnectionError(f"Connection lost: {e}")
  48. if not response_line:
  49. self._closed = True
  50. raise ConnectionError("Connection closed by server")
  51. response = json.loads(response_line.decode("utf-8"))
  52. if "error" in response and response["error"]:
  53. err = response["error"]
  54. raise JsonRpcError(
  55. err.get("code", -1),
  56. err.get("message", "Unknown error"),
  57. err.get("data")
  58. )
  59. return response.get("result")
  60. async def close(self):
  61. if not self._closed:
  62. self._closed = True
  63. self.writer.close()
  64. try:
  65. await self.writer.wait_closed()
  66. except Exception:
  67. pass
  68. class JsonRpcPool:
  69. """Connection pool with automatic reconnection"""
  70. def __init__(
  71. self,
  72. host: str,
  73. port: int,
  74. min_connections: int = 5,
  75. max_connections: int = 20,
  76. ):
  77. self.host = host
  78. self.port = port
  79. self.min_connections = min_connections
  80. self.max_connections = max_connections
  81. self._pool: asyncio.Queue[JsonRpcConnection] = None
  82. self._semaphore: asyncio.Semaphore = None
  83. self._connection_count = 0
  84. self._lock = asyncio.Lock()
  85. self._closed = False
  86. async def start(self):
  87. """Initialize the pool with minimum connections"""
  88. self._pool = asyncio.Queue()
  89. self._semaphore = asyncio.Semaphore(self.max_connections)
  90. self._connection_count = 0
  91. for _ in range(self.min_connections):
  92. try:
  93. conn = await self._create_connection()
  94. await self._pool.put(conn)
  95. except Exception as e:
  96. print(f"Warning: Failed to create initial connection: {e}")
  97. async def _create_connection(self) -> JsonRpcConnection:
  98. reader, writer = await asyncio.open_connection(self.host, self.port)
  99. async with self._lock:
  100. self._connection_count += 1
  101. return JsonRpcConnection(reader, writer)
  102. async def _destroy_connection(self, conn: JsonRpcConnection):
  103. await conn.close()
  104. async with self._lock:
  105. self._connection_count -= 1
  106. @asynccontextmanager
  107. async def connection(self):
  108. """Acquire a connection from the pool"""
  109. if self._closed:
  110. raise RuntimeError("Pool's closed")
  111. conn = None
  112. async with self._semaphore:
  113. # Try to get an existing connection
  114. while not self._pool.empty():
  115. conn = await self._pool.get()
  116. if not conn.is_closed:
  117. break
  118. await self._destroy_connection(conn)
  119. conn = None
  120. # Create new if needed
  121. if conn is None:
  122. conn = await self._create_connection()
  123. try:
  124. yield conn
  125. except (ConnectionError, TimeoutError):
  126. # Connection is bad, don't return to pool
  127. await self._destroy_connection(conn)
  128. raise
  129. else:
  130. # Return healthy connection to pool
  131. if not conn.is_closed:
  132. await self._pool.put(conn)
  133. else:
  134. await self._destroy_connection(conn)
  135. async def call(self, method: str, params: Any = None, timeout: float = 30.0) -> Any:
  136. """Make an RPC call using a pooled connection"""
  137. async with self.connection() as conn:
  138. return await conn.call(method, params, timeout)
  139. async def close(self):
  140. """Close all connections"""
  141. self._closed = True
  142. while not self._pool.empty():
  143. conn = await self._pool.get()
  144. await self._destroy_connection(conn)