Use nonces and drain interleaved stats in Hydrabase
Use UUID nonces to correlate requests/responses and robustly handle interleaved stats/heartbeat messages from the Hydrabase server. Adds _extract_stats and _extract_results helpers, records last_peer_count and timestamp, and loops on recv() with timeouts to drain non-result messages until the matching response (or a results message without a nonce) arrives. Mirrors the same nonce/send-and-drain logic in HydrabaseWorker, adds necessary imports (time, uuid), and improves logging and timeout handling to avoid returning stale or misattributed data.
This commit is contained in:
parent
7261b04950
commit
63624a4f6e
2 changed files with 96 additions and 33 deletions
|
|
@ -9,6 +9,8 @@ and iTunesClient (Track, Artist, Album).
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from typing import List, Optional, Callable, Tuple
|
from typing import List, Optional, Callable, Tuple
|
||||||
|
|
||||||
from core.itunes_client import Track, Artist, Album
|
from core.itunes_client import Track, Artist, Album
|
||||||
|
|
@ -44,8 +46,35 @@ class HydrabaseClient:
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _extract_stats(self, data):
|
||||||
|
"""Extract peer stats from any message that contains them."""
|
||||||
|
if isinstance(data, dict) and 'stats' in data:
|
||||||
|
stats = data['stats']
|
||||||
|
if isinstance(stats, dict) and 'connectedPeers' in stats:
|
||||||
|
self.last_peer_count = stats['connectedPeers']
|
||||||
|
self.last_peer_count_time = time.time()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_results(data) -> Optional[list]:
|
||||||
|
"""Extract results array from a response dict. Returns None if not a results message."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
if 'response' in data:
|
||||||
|
resp = data['response']
|
||||||
|
return resp if isinstance(resp, list) else [resp]
|
||||||
|
if 'results' in data:
|
||||||
|
return data['results']
|
||||||
|
if 'data' in data:
|
||||||
|
result = data['data']
|
||||||
|
return result if isinstance(result, list) else [result]
|
||||||
|
return None
|
||||||
|
|
||||||
def _send_and_recv(self, request_type: str, query: str) -> Optional[list]:
|
def _send_and_recv(self, request_type: str, query: str) -> Optional[list]:
|
||||||
"""Send a search request and return the response array."""
|
"""Send a search request and return the response array.
|
||||||
|
|
||||||
|
Uses a nonce for request-response correlation and loops on recv()
|
||||||
|
to drain any interleaved stats/heartbeat messages from the server.
|
||||||
|
"""
|
||||||
ws, lock = self.get_ws_and_lock()
|
ws, lock = self.get_ws_and_lock()
|
||||||
if ws is None:
|
if ws is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -55,50 +84,58 @@ class HydrabaseClient:
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
nonce = uuid.uuid4().hex
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
'request': {
|
'request': {
|
||||||
'type': request_type,
|
'type': request_type,
|
||||||
'query': query
|
'query': query
|
||||||
}
|
},
|
||||||
|
'nonce': nonce
|
||||||
})
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with lock:
|
with lock:
|
||||||
ws.settimeout(self.timeout)
|
ws.settimeout(self.timeout)
|
||||||
ws.send(payload)
|
ws.send(payload)
|
||||||
raw = ws.recv()
|
|
||||||
|
|
||||||
data = json.loads(raw)
|
deadline = time.time() + self.timeout
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
logger.warning(f"Hydrabase response timeout for ({request_type}, '{query}')")
|
||||||
|
return None
|
||||||
|
|
||||||
# Extract stats if present (can appear alongside results)
|
ws.settimeout(remaining)
|
||||||
if isinstance(data, dict) and 'stats' in data:
|
raw = ws.recv()
|
||||||
stats = data['stats']
|
data = json.loads(raw)
|
||||||
if isinstance(stats, dict) and 'connectedPeers' in stats:
|
|
||||||
import time
|
|
||||||
self.last_peer_count = stats['connectedPeers']
|
|
||||||
self.last_peer_count_time = time.time()
|
|
||||||
|
|
||||||
# Handle various response shapes
|
# Always extract stats from any message
|
||||||
if isinstance(data, list):
|
self._extract_stats(data)
|
||||||
return data
|
|
||||||
if isinstance(data, dict):
|
# Bare list — results with no envelope
|
||||||
# Hydrabase returns results under "response" key
|
if isinstance(data, list):
|
||||||
if 'response' in data:
|
return data
|
||||||
resp = data['response']
|
|
||||||
return resp if isinstance(resp, list) else [resp]
|
if not isinstance(data, dict):
|
||||||
if 'results' in data:
|
continue
|
||||||
return data['results']
|
|
||||||
if 'data' in data:
|
# Response has our nonce — definitely ours
|
||||||
result = data['data']
|
if data.get('nonce') == nonce:
|
||||||
return result if isinstance(result, list) else [result]
|
results = self._extract_results(data)
|
||||||
# Stats-only or empty response — no search results
|
return results if results is not None else []
|
||||||
if 'stats' in data:
|
|
||||||
logger.debug(f"Hydrabase stats-only response for ({request_type}, '{query}')")
|
# Response has results but no nonce (server doesn't echo nonces)
|
||||||
return []
|
if 'nonce' not in data:
|
||||||
|
results = self._extract_results(data)
|
||||||
|
if results is not None:
|
||||||
|
return results
|
||||||
|
# Stats-only message with no nonce — skip and recv again
|
||||||
|
logger.debug(f"Hydrabase draining non-result message for ({request_type}, '{query}')")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Has a nonce but not ours — stale response, skip it
|
||||||
|
logger.debug(f"Hydrabase draining stale nonce response for ({request_type}, '{query}')")
|
||||||
|
|
||||||
# Unknown shape — return empty rather than wrapping garbage
|
|
||||||
logger.debug(f"Hydrabase unexpected response shape for ({request_type}, '{query}'): {list(data.keys()) if isinstance(data, dict) else type(data).__name__}")
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Hydrabase query failed ({request_type}, '{query}'): {e}")
|
logger.error(f"Hydrabase query failed ({request_type}, '{query}'): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -125,11 +126,13 @@ class HydrabaseWorker:
|
||||||
self.stats['dropped'] += 1
|
self.stats['dropped'] += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
|
nonce = uuid.uuid4().hex
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
'request': {
|
'request': {
|
||||||
'type': item['type'],
|
'type': item['type'],
|
||||||
'query': item['query']
|
'query': item['query']
|
||||||
}
|
},
|
||||||
|
'nonce': nonce
|
||||||
})
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -137,8 +140,31 @@ class HydrabaseWorker:
|
||||||
if not ws.connected:
|
if not ws.connected:
|
||||||
self.stats['dropped'] += 1
|
self.stats['dropped'] += 1
|
||||||
return
|
return
|
||||||
|
ws.settimeout(15)
|
||||||
ws.send(payload)
|
ws.send(payload)
|
||||||
ws.recv() # Required by protocol, response discarded
|
|
||||||
|
# Loop to drain stats/heartbeat messages until we get our response
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
ws.settimeout(remaining)
|
||||||
|
raw = ws.recv()
|
||||||
|
data = json.loads(raw)
|
||||||
|
|
||||||
|
# Got our nonce back — done
|
||||||
|
if isinstance(data, dict) and data.get('nonce') == nonce:
|
||||||
|
break
|
||||||
|
# Got results without nonce — assume it's ours
|
||||||
|
if isinstance(data, dict) and 'nonce' not in data:
|
||||||
|
if 'response' in data or 'results' in data or 'data' in data:
|
||||||
|
break
|
||||||
|
# Bare list — results
|
||||||
|
if isinstance(data, list):
|
||||||
|
break
|
||||||
|
# Stats/heartbeat — drain and recv again
|
||||||
|
|
||||||
self.stats['sent'] += 1
|
self.stats['sent'] += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Hydrabase send failed: {e}")
|
logger.debug(f"Hydrabase send failed: {e}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue