11from __future__ import annotations
22
3+ import logging
4+ import re
35import threading
46import time
57from collections .abc import Callable
8+ from dataclasses import dataclass , field
69from datetime import datetime
710
11+ import grpc
12+
813from hiero_sdk_python .client .client import Client
914from hiero_sdk_python .consensus .topic_id import TopicId
1015from hiero_sdk_python .consensus .topic_message import TopicMessage
1419from hiero_sdk_python .utils .subscription_handle import SubscriptionHandle
1520
1621
22+ logger = logging .getLogger (__name__ )
23+
24+ RST_STREAM = re .compile (r"\brst[^0-9a-zA-Z]stream\b" , re .IGNORECASE | re .DOTALL )
25+
26+
27+ @dataclass
28+ class SubscriptionState :
29+ attempt : int = 0
30+ count : int = 0
31+ last_message : mirror_proto .ConsensusTopicResponse | None = None
32+ pending_messages : dict [str , list [mirror_proto .ConsensusTopicResponse ]] = field (default_factory = dict )
33+
34+
1735class TopicMessageQuery :
1836 """
1937 A query to subscribe to messages from a specific HCS topic, via a mirror node.
@@ -31,23 +49,31 @@ def __init__(
3149 chunking_enabled : bool = False ,
3250 ) -> None :
3351 """Initializes a TopicMessageQuery."""
34- self ._topic_id : TopicId | None = self ._parse_topic_id (topic_id ) if topic_id else None
52+ self ._topic_id : basic_types_pb2 . TopicID | None = self ._parse_topic_id (topic_id ) if topic_id else None
3553 self ._start_time : timestamp_pb2 .Timestamp | None = self ._parse_timestamp (start_time ) if start_time else None
3654 self ._end_time : timestamp_pb2 .Timestamp | None = self ._parse_timestamp (end_time ) if end_time else None
3755 self ._limit : int | None = limit
3856 self ._chunking_enabled : bool = chunking_enabled
39- self ._completion_handler : Callable [[], None ] | None = None
4057
4158 self ._max_attempts : int = 10
4259 self ._max_backoff : float = 8.0
4360
61+ self ._completion_handler : Callable [[], None ] | None = self ._on_complete
62+ self ._error_handler : Callable [[], None ] | None = self ._on_error
63+
4464 def set_max_attempts (self , attempts : int ) -> TopicMessageQuery :
4565 """Sets the maximum number of attempts to reconnect on failure."""
66+ if attempts <= 0 :
67+ raise ValueError ("max_attempts must be greater than 0" )
68+
4669 self ._max_attempts = attempts
4770 return self
4871
4972 def set_max_backoff (self , backoff : float ) -> TopicMessageQuery :
5073 """Sets the maximum backoff time in seconds for reconnection attempts."""
74+ if backoff < 0.5 :
75+ raise ValueError ("max_backoff must be at least 500 ms" )
76+
5177 self ._max_backoff = backoff
5278 return self
5379
@@ -56,23 +82,10 @@ def set_completion_handler(self, handler: Callable[[], None]) -> TopicMessageQue
5682 self ._completion_handler = handler
5783 return self
5884
59- def _parse_topic_id (self , topic_id : str | TopicId ) -> basic_types_pb2 .TopicID :
60- """Parses a topic ID from a string or TopicId object into a protobuf TopicID."""
61- if isinstance (topic_id , str ):
62- parts = topic_id .strip ().split ("." )
63- if len (parts ) != 3 :
64- raise ValueError (f"Invalid topic ID string: { topic_id } " )
65- shard , realm , topic = map (int , parts )
66- return basic_types_pb2 .TopicID (shardNum = shard , realmNum = realm , topicNum = topic )
67- if isinstance (topic_id , TopicId ):
68- return topic_id ._to_proto ()
69- raise TypeError ("Invalid topic_id format. Must be a string or TopicId." )
70-
71- def _parse_timestamp (self , dt : datetime ) -> timestamp_pb2 .Timestamp :
72- """Converts a datetime object to a protobuf Timestamp."""
73- seconds = int (dt .timestamp ())
74- nanos = int ((dt .timestamp () - seconds ) * 1e9 )
75- return timestamp_pb2 .Timestamp (seconds = seconds , nanos = nanos )
85+ def set_error_handler (self , handler : Callable [[], None ]) -> TopicMessageQuery :
86+ """Sets a completion handler that is called when the subscription completes."""
87+ self ._error_handler = handler
88+ return self
7689
7790 def set_topic_id (self , topic_id : str | TopicId ) -> TopicMessageQuery :
7891 """Sets the topic ID for the query."""
@@ -99,6 +112,41 @@ def set_chunking_enabled(self, enabled: bool) -> TopicMessageQuery:
99112 self ._chunking_enabled = enabled
100113 return self
101114
115+ def _on_complete (self ) -> None :
116+ logger .info (f"Subscription to topic { self ._topic_id } complete" )
117+
118+ def _on_error (self , err : Exception ) -> None :
119+ if isinstance (err , grpc .RpcError ) and err .code () == grpc .StatusCode .CANCELLED :
120+ logger .warning (f"Call is cancelled for topic { self ._topic_id } " )
121+ else :
122+ logger .error (f"Error attempting to subscribe to topic { self ._topic_id } : { err } " )
123+
124+ def _should_retry (self , err : Exception ) -> bool :
125+ if isinstance (err , grpc .RpcError ):
126+ return err .code () in (
127+ grpc .StatusCode .NOT_FOUND ,
128+ grpc .StatusCode .UNAVAILABLE ,
129+ grpc .StatusCode .RESOURCE_EXHAUSTED ,
130+ ) or (err .code () == grpc .StatusCode .INTERNAL and bool (RST_STREAM .search (err .details ())))
131+
132+ return True
133+
134+ def _parse_topic_id (self , topic_id : str | TopicId ) -> basic_types_pb2 .TopicID :
135+ """Parses a topic ID from a string or TopicId object into a protobuf TopicID."""
136+ if isinstance (topic_id , str ):
137+ topic_id = TopicId .from_string (topic_id )
138+
139+ if isinstance (topic_id , TopicId ):
140+ return topic_id ._to_proto ()
141+
142+ raise TypeError ("Invalid topic_id format. Must be a string or TopicId." )
143+
144+ def _parse_timestamp (self , dt : datetime ) -> timestamp_pb2 .Timestamp :
145+ """Converts a datetime object to a protobuf Timestamp."""
146+ seconds = int (dt .timestamp ())
147+ nanos = int ((dt .timestamp () - seconds ) * 1e9 )
148+ return timestamp_pb2 .Timestamp (seconds = seconds , nanos = nanos )
149+
102150 def subscribe (
103151 self ,
104152 client : Client ,
@@ -111,49 +159,61 @@ def subscribe(
111159 if not client .mirror_stub :
112160 raise ValueError ("Client has no mirror_stub. Did you configure a mirror node address?" )
113161
114- request = mirror_proto .ConsensusTopicQuery (topicID = self ._topic_id )
115- if self ._start_time :
116- request .consensusStartTime .CopyFrom (self ._start_time )
117- if self ._end_time :
118- request .consensusEndTime .CopyFrom (self ._end_time )
119- if self ._limit is not None :
120- request .limit = self ._limit
121-
122162 subscription_handle = SubscriptionHandle ()
123-
124- pending_chunks : dict [str , list [mirror_proto .ConsensusTopicResponse ]] = {}
163+ state = SubscriptionState ()
125164
126165 def run_stream ():
127- attempt = 0
128- while attempt < self ._max_attempts and not subscription_handle .is_cancelled ():
166+ while state .attempt < self ._max_attempts and not subscription_handle .is_cancelled ():
167+ request = mirror_proto .ConsensusTopicQuery (topicID = self ._topic_id )
168+
169+ if self ._end_time is not None :
170+ request .consensusEndTime .CopyFrom (self ._end_time )
171+
172+ if state .last_message is not None :
173+ last_message_time = state .last_message .consensusTimestamp
174+ request .consensusStartTime .seconds = last_message_time .seconds
175+ request .consensusStartTime .nanos = last_message_time .nanos + 1
176+
177+ if self ._limit > 0 :
178+ request .limit = max (0 , self ._limit - state .count )
179+ else :
180+ if self ._start_time is not None :
181+ request .consensusStartTime .CopyFrom (self ._start_time )
182+ request .limit = self ._limit
183+
129184 try :
130185 message_stream = client .mirror_stub .subscribeTopic (request )
186+ subscription_handle ._set_call (message_stream )
131187
132188 for response in message_stream :
133189 if subscription_handle .is_cancelled ():
134190 return
135191
192+ state .count += 1
193+ state .last_message = response
194+
136195 if (
137196 not self ._chunking_enabled
138197 or not response .HasField ("chunkInfo" )
139198 or response .chunkInfo .total <= 1
140199 ):
141- msg_obj = TopicMessage .of_single (response )
142- on_message (msg_obj )
200+ message = TopicMessage .of_single (response )
201+ on_message (message )
143202 continue
144203
145204 initial_tx_id = TransactionId ._from_proto (response .chunkInfo .initialTransactionID )
146205
147- if initial_tx_id not in pending_chunks :
148- pending_chunks [initial_tx_id ] = []
206+ if initial_tx_id not in state . pending_messages :
207+ state . pending_messages [initial_tx_id ] = []
149208
150- pending_chunks [initial_tx_id ].append (response )
209+ chunks = state .pending_messages [initial_tx_id ]
210+ chunks .append (response )
151211
152- if len (pending_chunks [ initial_tx_id ] ) == response .chunkInfo .total :
153- chunk_list = pending_chunks . pop ( initial_tx_id )
212+ if len (chunks ) == response .chunkInfo .total :
213+ del state . pending_messages [ initial_tx_id ]
154214
155- msg_obj = TopicMessage .of_many (chunk_list )
156- on_message (msg_obj )
215+ message = TopicMessage .of_many (chunks )
216+ on_message (message )
157217
158218 if self ._completion_handler :
159219 self ._completion_handler ()
@@ -163,13 +223,17 @@ def run_stream():
163223 if subscription_handle .is_cancelled ():
164224 return
165225
166- attempt += 1
167- if attempt >= self ._max_attempts :
226+ if state .attempt >= self ._max_attempts or not self ._should_retry (e ):
227+ if self ._error_handler :
228+ self ._error_handler (e )
168229 if on_error :
169230 on_error (e )
170231 return
171232
172- delay = min (0.5 * (2 ** (attempt - 1 )), self ._max_backoff )
233+ delay = min (0.5 * (2 ** (state .attempt )), self ._max_backoff )
234+ logger .warning (f"Error subscribing to topic attempt { state .attempt } . Retrying in { int (delay )} s..." )
235+
236+ state .attempt += 1
173237 time .sleep (delay )
174238
175239 thread = threading .Thread (target = run_stream , daemon = True )
0 commit comments