diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab260a8..263f02f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Explorer backend routes returned HTTP 200 with error/empty bodies on failure, defeating frontend error handling** (#770, #787) by @Sameer6305 and @KaifAhmad1 + - `GET /api/temporal/patterns` now raises `HTTPException(500)` on a genuine computation failure instead of silently returning an empty-but-valid `TemporalPatternResponse`; the `ImportError` fallback (optional `kg` extra not installed) is unchanged and still degrades gracefully to an empty list + - `POST /api/ontology/create` now raises `HTTPException(500)` when ontology generation fails in either the `sample_data` or `schema_text` mode, instead of silently falling back to a partial/minimal ontology with a misleading `nodes_added` count + - `GET /api/analytics` sets `response.status_code = 207` (Multi-Status) when some, but not all, of the requested metrics fail, and raises `HTTPException(500)` when every requested metric fails — a plain 2xx (including 207) reads as success to callers that only check `response.ok`, so an all-failed request now surfaces as a hard error rather than a body full of `{"error": ...}` + - Added regression tests covering all three failure paths (`test_patterns_failure_returns_500`, `test_analytics_partial_failure_returns_207`, `test_analytics_total_failure_returns_500`, and two `TestOntologyCreateFailures` cases) + ## [0.6.0] - 2026-07-21 ### Added diff --git a/semantica/explorer/routes/analytics.py b/semantica/explorer/routes/analytics.py index 357ce440..d02bd0aa 100644 --- a/semantica/explorer/routes/analytics.py +++ b/semantica/explorer/routes/analytics.py @@ -5,7 +5,7 @@ Analytics routes for graph metrics and validation. import asyncio from typing import Optional -from fastapi import APIRouter, Depends, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response from ..dependencies import get_session from ..schemas import AnalyticsResponse, ValidationIssue, ValidationReportResponse @@ -26,9 +26,11 @@ async def get_analytics( requested = set((metrics or "centrality,community,connectivity").split(",")) graph_dict = await asyncio.to_thread(session.build_graph_dict) result: dict = {} - any_failed = False + attempted = 0 + failed = 0 if "centrality" in requested and session.centrality is not None: + attempted += 1 try: result["centrality"] = await asyncio.to_thread( session.centrality.calculate_degree_centrality, @@ -36,9 +38,10 @@ async def get_analytics( ) except Exception as exc: result["centrality"] = {"error": str(exc)} - any_failed = True + failed += 1 if "community" in requested and session.community is not None: + attempted += 1 try: result["community"] = await asyncio.to_thread( session.community.detect_communities, @@ -46,9 +49,10 @@ async def get_analytics( ) except Exception as exc: result["community"] = {"error": str(exc)} - any_failed = True + failed += 1 if "connectivity" in requested and session.connectivity is not None: + attempted += 1 try: result["connectivity"] = await asyncio.to_thread( session.connectivity.analyze_connectivity, @@ -56,9 +60,15 @@ async def get_analytics( ) except Exception as exc: result["connectivity"] = {"error": str(exc)} - any_failed = True + failed += 1 - if any_failed: + if attempted and failed == attempted: + # Every requested metric raised: a plain 2xx (even 207) reads as + # success to callers that only check `response.ok`, so surface this + # as a hard failure rather than a body full of {"error": ...}. + raise HTTPException(status_code=500, detail="All requested analytics metrics failed to compute") + + if failed: response.status_code = 207 return AnalyticsResponse(**result) diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index 18597743..4f16d13e 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -474,6 +474,16 @@ class TestTemporal: assert response.status_code == 200 assert "patterns" in response.json() + def test_patterns_failure_returns_500(self, client, monkeypatch): + session = client.app.state.session + + def _boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(session, "build_graph_dict", _boom) + response = client.get("/api/temporal/patterns") + assert response.status_code == 500 + def test_bounds(self, client): response = client.get("/api/temporal/bounds") assert response.status_code == 200 @@ -488,6 +498,29 @@ class TestAnalytics: assert response.status_code == 200 assert "centrality" in response.json() + def test_analytics_partial_failure_returns_207(self, client, monkeypatch): + session = client.app.state.session + + def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(session.centrality, "calculate_degree_centrality", _boom) + response = client.get("/api/analytics?metrics=centrality,community") + assert response.status_code == 207 + payload = response.json() + assert payload["centrality"]["error"] + assert payload["community"] is not None and "error" not in payload["community"] + + def test_analytics_total_failure_returns_500(self, client, monkeypatch): + session = client.app.state.session + + def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(session.centrality, "calculate_degree_centrality", _boom) + response = client.get("/api/analytics?metrics=centrality") + assert response.status_code == 500 + def test_validation(self, client): response = client.get("/api/analytics/validation") assert response.status_code == 200 @@ -496,6 +529,44 @@ class TestAnalytics: assert "issues" in payload +class TestOntologyCreateFailures: + def test_create_from_sample_data_failure_returns_500(self, client, monkeypatch): + from semantica.ontology import OntologyEngine + + def _boom(self, *_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(OntologyEngine, "from_data", _boom) + response = client.post( + "/api/ontology/create", + json={ + "mode": "data", + "namespace": "http://example.org/create-failure-data", + "name": "Create Failure (data)", + "sample_data": "id,name\n1,Alice\n2,Bob", + }, + ) + assert response.status_code == 500 + + def test_create_from_text_failure_returns_500(self, client, monkeypatch): + from semantica.ontology import OntologyEngine + + def _boom(self, *_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(OntologyEngine, "from_text", _boom) + response = client.post( + "/api/ontology/create", + json={ + "mode": "text", + "namespace": "http://example.org/create-failure-text", + "name": "Create Failure (text)", + "schema_text": "Class: Person\nProperty: knows", + }, + ) + assert response.status_code == 500 + + class TestEnrichment: def test_reasoning(self, client): response = client.post(