diff --git a/netx_api/ume_alarm_ws.py b/netx_api/ume_alarm_ws.py index 0004f46..7f3476a 100644 --- a/netx_api/ume_alarm_ws.py +++ b/netx_api/ume_alarm_ws.py @@ -162,7 +162,12 @@ def establish_alarm_subscription_manual(client: UMEClient, db: Session) -> dict[ if not orphan_id: raise _ws_log.warning("establish: UME reports existing subscription id=%s, deleting then retry", orphan_id) - _delete_subscription_on_ume(client, orphan_id) + try: + _delete_subscription_on_ume(client, orphan_id) + except RuntimeError as del_exc: + raise RuntimeError( + f"ume_orphan_subscription_delete_failed:id={orphan_id}:{str(del_exc)[:180]}" + ) from del_exc sub_id, uri = client.establish_alarm_subscription(topic=topic) save_subscription(db, subscription_id=sub_id, wss_uri=uri, topic=topic) db.commit() diff --git a/netx_api/ume_client.py b/netx_api/ume_client.py index c6afdc5..f526a0c 100644 --- a/netx_api/ume_client.py +++ b/netx_api/ume_client.py @@ -23,6 +23,21 @@ def _coerce_list(value: Any) -> list[Any]: return value if isinstance(value, list) else [] +def _parse_json_response(resp: httpx.Response) -> dict[str, Any]: + """Parse JSON body; UME often returns 204/empty body on successful DELETE operations.""" + raw = str(resp.text or "").strip() + if not raw: + return {} + try: + return _coerce_dict(resp.json()) + except json.JSONDecodeError as exc: + if resp.is_success: + return {} + raise RuntimeError( + f"ume_request_failed:{int(resp.status_code)}:non_json_body:{raw[:200]}" + ) from exc + + @dataclass class RequestDiagnostics: method: str @@ -422,8 +437,9 @@ class UMEClient: marker=marker, is_end_of_reply=is_end_of_reply, ) - raise RuntimeError(f"ume_request_failed:{resp.status_code}:{resp.text[:240]}") - data = _coerce_dict(resp.json()) + body_snip = raw[:240] if (raw := str(resp.text or "").strip()) else "(empty body)" + raise RuntimeError(f"ume_request_failed:{m} {path} {resp.status_code}:{body_snip}") + data = _parse_json_response(resp) diag = RequestDiagnostics( method=m, path=path, @@ -437,7 +453,7 @@ class UMEClient: except Exception as exc: if isinstance(exc, RuntimeError): raise - raise RuntimeError(f"ume_request_failed:{str(exc)[:240]}") from exc + raise RuntimeError(f"ume_request_failed:{m} {path}:{str(exc)[:200]}") from exc def request_json( self, @@ -477,8 +493,9 @@ class UMEClient: marker=marker, is_end_of_reply=is_end_of_reply, ) - raise RuntimeError(f"ume_request_failed:{resp.status_code}:{resp.text[:240]}") - data = _coerce_dict(resp.json()) + body_snip = raw[:240] if (raw := str(resp.text or "").strip()) else "(empty body)" + raise RuntimeError(f"ume_request_failed:{m} {path} {resp.status_code}:{body_snip}") + data = _parse_json_response(resp) diag = RequestDiagnostics( method=m, path=path, @@ -492,7 +509,7 @@ class UMEClient: except Exception as exc: if isinstance(exc, RuntimeError): raise - raise RuntimeError(f"ume_request_failed:{str(exc)[:240]}") from exc + raise RuntimeError(f"ume_request_failed:{m} {path}:{str(exc)[:200]}") from exc def _extract_named_list(self, payload: dict[str, Any], keys: list[str]) -> list[dict[str, Any]]: target_keys = {str(k).lower() for k in keys} diff --git a/tests/test_ume_sync.py b/tests/test_ume_sync.py index e81d194..ef14fa8 100644 --- a/tests/test_ume_sync.py +++ b/tests/test_ume_sync.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import unittest from typing import Any from unittest.mock import patch @@ -10,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from netx_api.db import Base from netx_api.main import _extract_ume_raw_group_field, _serialize_ume_alarm_raw_row, sql_ume_query, ume_alarms_fields from netx_api.models import UmeAlarmCurrent, UmeInventoryNE -from netx_api.ume_client import UMEClient +from netx_api.ume_client import UMEClient, _parse_json_response from netx_api import ume_alarm_ws from netx_api.models import UmeAlarmSubscription from netx_api.ume_alarm_subscription_store import clear_subscription, load_subscription, save_subscription @@ -175,6 +176,39 @@ class UMEClientTests(unittest.TestCase): self.assertEqual(sub_id, "3282ac78-b38a-4242-81d2-cc5b77c28ef8") self.assertIn("wss://", uri) + def test_parse_json_response_accepts_empty_success_body(self): + class _Resp: + is_success = True + status_code = 204 + text = "" + + def json(self): + raise json.JSONDecodeError("Expecting value", "", 0) + + self.assertEqual(_parse_json_response(_Resp()), {}) # type: ignore[arg-type] + + def test_delete_alarm_subscription_empty_response(self): + from time import time as _time + + client = UMEClient(base_url="https://ume.local:18014", username="u", password="p", verify_tls=False) + client._token_value = "token-1" + client._token_expires_at = _time() + 3600 + + class _Resp: + is_success = True + status_code = 204 + text = "" + headers: dict[str, str] = {} + + def json(self): + raise json.JSONDecodeError("Expecting value", "", 0) + + def _fake_request(method: str, path: str, *, params=None, body=None): + return _parse_json_response(_Resp()), None # type: ignore[arg-type] + + client.request_json = _fake_request # type: ignore[method-assign] + client.delete_alarm_subscription("sub-to-delete") + def test_delete_alarm_subscription(self): from time import time as _time