Skip to content

Commit 6bbaba3

Browse files
committed
fix: distinct error message when a CouchDB database is missing
A missing or unreachable database was reported the same way as a wrong key (e.g. 'unknown asset_id', 'work order not found'). On not-found/failure paths the iot, fmsr, wo and vibration servers now report that the database does not exist in this environment and that retrying with other arguments won't help. Signed-off-by: Shuxin Lin <linshuhsin@gmail.com>
1 parent 977156e commit 6bbaba3

9 files changed

Lines changed: 232 additions & 22 deletions

File tree

‎src/servers/fmsr/main.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,19 @@ def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult:
8585
return ErrorResult(error=message)
8686

8787

88+
def _is_missing_database(exc: Exception) -> bool:
89+
return "Database does not exist" in str(exc)
90+
91+
92+
_MISSING_DATABASE_ERROR = (
93+
f"database '{FAILURE_MODE_DBNAME}' does not exist in this environment; the "
94+
"data is unavailable, do not retry with other arguments"
95+
)
96+
97+
8898
def _is_not_found_error(exc: Exception) -> bool:
99+
if _is_missing_database(exc):
100+
return False
89101
if isinstance(exc, (KeyError, NotFoundError)):
90102
return True
91103
response = getattr(exc, "response", None)
@@ -267,6 +279,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]:
267279
try:
268280
d = fm_db.get(f"fm:{key}", check=True)
269281
except Exception as exc: # noqa: BLE001
282+
if _is_missing_database(exc):
283+
raise RuntimeError(_MISSING_DATABASE_ERROR) from exc
270284
if _is_not_found_error(exc):
271285
d = None
272286
else:
@@ -281,6 +295,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]:
281295
d = docs[0]
282296
return d
283297
except Exception as exc: # noqa: BLE001
298+
if _is_missing_database(exc):
299+
raise RuntimeError(_MISSING_DATABASE_ERROR) from exc
284300
raise RuntimeError(
285301
f"database lookup failed for asset_class '{key}': {exc}"
286302
) from exc

‎src/servers/fmsr/tests/test_tools.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,24 @@ async def test_mapping_tool_is_not_registered(self):
293293
assert "generate_failure_mode_sensor_mapping" not in {
294294
tool.name for tool in tools
295295
}
296+
297+
298+
class TestMissingDatabaseMessage:
299+
@pytest.mark.anyio
300+
async def test_missing_database_reports_unavailable(self, monkeypatch):
301+
from couchdb3.exceptions import NotFoundError
302+
303+
class MissingDatabase:
304+
def get(self, *args, **kwargs):
305+
raise NotFoundError(
306+
'{"error":"not_found","reason":"Database does not exist."}'
307+
)
308+
309+
find = get
310+
311+
monkeypatch.setattr("servers.fmsr.main.fm_db", MissingDatabase())
312+
313+
data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"})
314+
315+
assert "does not exist in this environment" in data["error"]
316+
assert "no failure_mode record" not in data["error"]

‎src/servers/iot/main.py‎

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ def known_sites() -> List[str]:
145145
return get_registry_sites() or DEFAULT_SITES
146146

147147

148+
def _missing_db_error(db: Any, name: str) -> Optional[ErrorResult]:
149+
"""Return an error when the database itself is absent or unreachable, so a
150+
missing database is not reported as an unknown key."""
151+
if db is not None and db.check():
152+
return None
153+
return ErrorResult(
154+
error=(
155+
f"database '{name}' does not exist or is unreachable in this "
156+
"environment; the data is unavailable, do not retry with other arguments"
157+
)
158+
)
159+
160+
148161
def _is_known_site(site_name: str) -> bool:
149162
return site_name in known_sites()
150163

@@ -224,7 +237,7 @@ def asset_ids(site_name: str) -> Union[AssetsResult, ErrorResult]:
224237
)
225238
except Exception as e:
226239
logger.error(f"asset_ids failed: {e}")
227-
return ErrorResult(error=str(e))
240+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e))
228241

229242

230243
@mcp.tool(title="Get Asset Detail")
@@ -265,7 +278,9 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul
265278
)
266279
docs = res.get("docs", [])
267280
if not docs:
268-
return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}")
281+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(
282+
error=f"unknown asset_id {asset_id} at site {site_name}"
283+
)
269284

270285
doc = docs[0]
271286
sensors = list(doc.get("sensors") or [])
@@ -295,7 +310,7 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul
295310
)
296311
except Exception as e:
297312
logger.error(f"asset_detail failed: {e}")
298-
return ErrorResult(error=str(e))
313+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e))
299314

300315

301316
@mcp.tool(title="List Measured Sensors")
@@ -322,7 +337,9 @@ def measured_sensors(
322337

323338
sensor_list = get_sensor_list(asset_id)
324339
if not sensor_list:
325-
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
340+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
341+
error=f"unknown asset_id {asset_id} or no sensors found"
342+
)
326343

327344
return SensorsResult(
328345
site_name=site_name,
@@ -366,7 +383,9 @@ def installed_sensors(
366383
)
367384
docs = res.get("docs", [])
368385
if not docs:
369-
return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}")
386+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(
387+
error=f"unknown asset_id {asset_id} at site {site_name}"
388+
)
370389
names = list(docs[0].get("sensors") or [])
371390
return SensorsResult(
372391
site_name=site_name,
@@ -377,7 +396,7 @@ def installed_sensors(
377396
)
378397
except Exception as e:
379398
logger.error(f"installed_sensors failed: {e}")
380-
return ErrorResult(error=str(e))
399+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e))
381400

382401

383402
@mcp.tool(title="List Assets")
@@ -435,7 +454,7 @@ def assets(
435454
)
436455
except Exception as e:
437456
logger.error(f"assets failed: {e}")
438-
return ErrorResult(error=str(e))
457+
return _missing_db_error(asset_db, ASSET_DBNAME) or ErrorResult(error=str(e))
439458

440459

441460
@mcp.tool(title="Find Assets By Sensors")
@@ -606,7 +625,7 @@ def stream_extent(
606625
total_records += 1
607626

608627
if total_records == 0:
609-
return ErrorResult(
628+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
610629
error=f"no records for asset_id {asset_id}"
611630
+ (f", sensor {sensor}" if sensor else "")
612631
)
@@ -638,7 +657,9 @@ def stream_extent(
638657
return ErrorResult(error=str(e))
639658
except Exception as e:
640659
logger.error(f"stream_extent failed: {e}")
641-
return ErrorResult(error="unable to inspect telemetry stream extent")
660+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
661+
error="unable to inspect telemetry stream extent"
662+
)
642663

643664

644665
@mcp.tool(title="Get Sensor History")
@@ -704,7 +725,9 @@ def history(
704725
)
705726
available_sensors = get_sensor_list(asset_id)
706727
if not available_sensors:
707-
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
728+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
729+
error=f"unknown asset_id {asset_id} or no sensors found"
730+
)
708731
unknown = [
709732
sensor for sensor in selected_sensors if sensor not in available_sensors
710733
]
@@ -763,7 +786,9 @@ def history(
763786
return ErrorResult(error=str(e))
764787
except Exception as e:
765788
logger.error(f"history failed: {e}")
766-
return ErrorResult(error="unable to retrieve telemetry history")
789+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
790+
error="unable to retrieve telemetry history"
791+
)
767792

768793
next_cursor = None
769794
if has_more:
@@ -828,7 +853,9 @@ def latest_reading(
828853
if sensor is not None:
829854
available_sensors = get_sensor_list(asset_id)
830855
if not available_sensors:
831-
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
856+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
857+
error=f"unknown asset_id {asset_id} or no sensors found"
858+
)
832859
if sensor not in available_sensors:
833860
return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}")
834861

@@ -856,10 +883,12 @@ def latest_reading(
856883
return ErrorResult(error=str(e))
857884
except Exception as e:
858885
logger.error(f"latest_reading failed: {e}")
859-
return ErrorResult(error="unable to retrieve latest telemetry reading")
886+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
887+
error="unable to retrieve latest telemetry reading"
888+
)
860889

861890
if latest_doc is None or latest_timestamp is None or latest_datetime is None:
862-
return ErrorResult(
891+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
863892
error=f"no records for asset_id {asset_id}"
864893
+ (f", sensor {sensor}" if sensor else "")
865894
)
@@ -930,10 +959,14 @@ def sensor_coverage(
930959
return ErrorResult(error=str(e))
931960
except Exception as e:
932961
logger.error(f"sensor_coverage failed: {e}")
933-
return ErrorResult(error="unable to calculate sensor coverage")
962+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
963+
error="unable to calculate sensor coverage"
964+
)
934965

935966
if docs_scanned == 0:
936-
return ErrorResult(error=f"unknown asset_id {asset_id} or no records found")
967+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
968+
error=f"unknown asset_id {asset_id} or no records found"
969+
)
937970

938971
sensors = [coverage[field].result(field) for field in sorted(coverage)]
939972
message = (
@@ -996,7 +1029,9 @@ def sensor_stats(
9961029

9971030
available_sensors = get_sensor_list(asset_id)
9981031
if not available_sensors:
999-
return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found")
1032+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
1033+
error=f"unknown asset_id {asset_id} or no sensors found"
1034+
)
10001035
if sensor is not None and sensor not in available_sensors:
10011036
return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}")
10021037

@@ -1035,10 +1070,12 @@ def sensor_stats(
10351070
return ErrorResult(error=str(e))
10361071
except Exception as e:
10371072
logger.error(f"sensor_stats failed: {e}")
1038-
return ErrorResult(error="unable to calculate sensor statistics")
1073+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
1074+
error="unable to calculate sensor statistics"
1075+
)
10391076

10401077
if records_in_window == 0:
1041-
return ErrorResult(
1078+
return _missing_db_error(iot_db, IOT_DBNAME) or ErrorResult(
10421079
error=f"no records for asset_id {asset_id}"
10431080
+ (f", sensor {sensor}" if sensor else "")
10441081
)

‎src/servers/iot/tests/test_tools.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,3 +1795,32 @@ async def test_discovery_integration(self):
17951795
assert "assets" in data
17961796
assert any(asset["asset_id"] == "Chiller 6" for asset in data["assets"])
17971797
assert data["total_assets"] > 0
1798+
1799+
1800+
class TestMissingDatabaseMessage:
1801+
@pytest.mark.anyio
1802+
async def test_wrong_asset_id_reports_unknown_key(self, mock_asset_db, mock_iot_db):
1803+
mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]}
1804+
mock_iot_db.find.return_value = {"docs": []}
1805+
mock_iot_db.check.return_value = True
1806+
1807+
data = await call_tool(
1808+
mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Pump-X"}
1809+
)
1810+
1811+
assert data["error"] == "unknown asset_id Pump-X or no sensors found"
1812+
1813+
@pytest.mark.anyio
1814+
async def test_missing_database_reports_unavailable(
1815+
self, mock_asset_db, mock_iot_db
1816+
):
1817+
mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]}
1818+
mock_iot_db.find.side_effect = RuntimeError("Database does not exist.")
1819+
mock_iot_db.check.return_value = False
1820+
1821+
data = await call_tool(
1822+
mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Chiller 6"}
1823+
)
1824+
1825+
assert "does not exist or is unreachable" in data["error"]
1826+
assert "do not retry" in data["error"]

‎src/servers/vibration/couchdb_client.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ def _get_db() -> Optional[couchdb3.Database]:
4747
return None
4848

4949

50+
def database_available() -> bool:
51+
"""True when the vibration database exists and CouchDB is reachable."""
52+
db = _get_db()
53+
return bool(db and db.check())
54+
55+
5056
def fetch_vibration_timeseries(
5157
asset_id: str,
5258
sensor_name: str,

‎src/servers/vibration/main.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from mcp.server.fastmcp import FastMCP
2020
from pydantic import BaseModel
2121

22-
from .couchdb_client import fetch_vibration_timeseries, list_sensor_fields
22+
from .couchdb_client import (
23+
VIBRATION_DBNAME,
24+
database_available,
25+
fetch_vibration_timeseries,
26+
list_sensor_fields,
27+
)
2328
from .data_store import store
2429
from .dsp.bearing_freqs import (
2530
COMMON_BEARINGS,
@@ -58,6 +63,20 @@ class ErrorResult(BaseModel):
5863
error: str
5964

6065

66+
def _missing_db_error() -> Optional[ErrorResult]:
67+
"""Return an error when the database itself is absent or unreachable, so a
68+
missing database is not reported as missing asset/sensor data."""
69+
if database_available():
70+
return None
71+
return ErrorResult(
72+
error=(
73+
f"database '{VIBRATION_DBNAME}' does not exist or is unreachable in "
74+
"this environment; the data is unavailable, do not retry with other "
75+
"arguments"
76+
)
77+
)
78+
79+
6180
# ---------------------------------------------------------------------------
6281
# Helpers
6382
# ---------------------------------------------------------------------------
@@ -155,7 +174,7 @@ def get_vibration_data(
155174
"""
156175
result = fetch_vibration_timeseries(asset_id, sensor_name, start, final)
157176
if result is None:
158-
return ErrorResult(
177+
return _missing_db_error() or ErrorResult(
159178
error=f"No vibration data found for asset '{asset_id}', "
160179
f"sensor '{sensor_name}' in time range starting {start}."
161180
)
@@ -190,7 +209,7 @@ def list_vibration_sensors(
190209
"""
191210
sensors = list_sensor_fields(asset_id)
192211
if not sensors:
193-
return ErrorResult(
212+
return _missing_db_error() or ErrorResult(
194213
error=f"No sensors found for asset '{asset_id}' at site '{site_name}'."
195214
)
196215
return {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""A wrong asset and a missing database produce different error messages."""
2+
3+
import pytest
4+
5+
from servers.vibration.main import mcp
6+
7+
from .conftest import call_tool
8+
9+
_ARGS = {"site_name": "MAIN", "asset_id": "Motor-X"}
10+
11+
12+
@pytest.mark.anyio
13+
async def test_wrong_asset_reports_no_sensors(mock_db):
14+
mock_db.return_value.find.return_value = {"docs": []}
15+
mock_db.return_value.check.return_value = True
16+
17+
data = await call_tool(mcp, "list_vibration_sensors", _ARGS)
18+
19+
assert data["error"] == "No sensors found for asset 'Motor-X' at site 'MAIN'."
20+
21+
22+
@pytest.mark.anyio
23+
async def test_missing_database_reports_unavailable(mock_db):
24+
mock_db.return_value.find.side_effect = RuntimeError("Database does not exist.")
25+
mock_db.return_value.check.return_value = False
26+
27+
data = await call_tool(mcp, "list_vibration_sensors", _ARGS)
28+
29+
assert "does not exist or is unreachable" in data["error"]

0 commit comments

Comments
 (0)