Address review: harden analytics status codes, add failure-path tests

207 alone is indistinguishable from 200 to callers that only check
response.ok, so /api/analytics now raises 500 when every requested
metric fails and reserves 207 for genuine partial failure. Adds
regression tests for the temporal, analytics, and ontology-create
failure paths introduced in this PR, and logs the fix in the
changelog's Unreleased section.
This commit is contained in:
KaifAhmad1
2026-07-23 18:13:26 +05:30
parent 9b7a33031c
commit b1deed5857
3 changed files with 95 additions and 6 deletions
+8
View File
@@ -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
+16 -6
View File
@@ -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)
+71
View File
@@ -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(