fix: harden RQ Score response parsing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user