fix: harden RQ Score response parsing

This commit is contained in:
-Puter
2026-07-14 18:20:06 +05:30
parent 81a845d244
commit f3c0775e59
2 changed files with 54 additions and 7 deletions

View File

@@ -6,6 +6,8 @@ a missing KPI is omitted by the UI, never faked, and never crashes the dashboard
from __future__ import annotations
import logging
import math
from collections.abc import Mapping
import httpx
@@ -14,6 +16,17 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
def _score_value(value: object) -> float | None:
"""Parse one canonical RQ Score value; malformed values are absent."""
if isinstance(value, bool) or value is None:
return None
try:
score = float(value)
except (TypeError, ValueError):
return None
return score if math.isfinite(score) else None
async def fetch_qx(user_uuid: str | None) -> dict | None:
"""RQ Score for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service)."""
if not user_uuid:
@@ -25,10 +38,15 @@ async def fetch_qx(user_uuid: str | None) -> dict | None:
if r.status_code == 404: # no score computed yet — honest absence
return None
r.raise_for_status()
d = r.json()
qx = d.get("rq_score")
return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None
except (httpx.HTTPError, ValueError) as e:
payload = r.json()
if not isinstance(payload, Mapping):
return None
qx = _score_value(payload.get("rq_score"))
if qx is None:
return None
quotients = payload.get("quotients")
return {"qx": round(qx, 1), "quotients": quotients if isinstance(quotients, Mapping) else {}}
except (httpx.HTTPError, ValueError, TypeError) as e:
logger.warning("fetch_qx failed (non-fatal): %s", e)
return None
@@ -48,10 +66,21 @@ async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None:
)
if r.status_code == 404:
return None
r.raise_for_status()
pts = [round(float(p["rq_score"]), 1) for p in (r.json().get("points") or []) if p.get("rq_score") is not None]
payload = r.json()
if not isinstance(payload, Mapping):
return None
points = payload.get("points")
if not isinstance(points, list):
return None
pts = []
for point in points:
if not isinstance(point, Mapping):
continue
score = _score_value(point.get("rq_score"))
if score is not None:
pts.append(round(score, 1))
return pts if len(pts) >= 2 else None # need ≥2 points for a trend
except (httpx.HTTPError, ValueError, TypeError, KeyError) as e:
except (httpx.HTTPError, ValueError, TypeError) as e:
logger.warning("fetch_qx_trend failed (non-fatal): %s", e)
return None

View File

@@ -72,6 +72,24 @@ def test_fetch_qx_does_not_alias_legacy_q_score(monkeypatch):
assert asyncio.run(clients.fetch_qx("user-uuid")) is None
def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response([{"rq_score": 88}])
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) is None
def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 40.0]
def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({