Skip to content

Commit fab2736

Browse files
authored
fix: standardize error handling patterns across all tool modules (#521) (#678)
* fix: standardize error handling across all tool modules (#521) Replace all ad-hoc {"success": False, "error": "string"} return dicts with structured helpers from errors.py and helpers.py. All tool-level failures now raise ToolError (sets isError=true per MCP spec). Batch item failures within result arrays remain as returned structured dicts. Patterns applied across 30 tool files (~286 occurrences): - Pattern A: except blocks → exception_to_structured_error() - Pattern B: input validation → raise_tool_error(create_error_response()) - Pattern C: WS failures → raise_tool_error(create_error_response()) - Pattern D: batch item failures → create_error_response() (no raise) Also removes redundant 404 string-check in ha_get_state (already handled by exception_to_structured_error for HomeAssistantAPIError). Adds except ToolError: raise guard in all functions where raise_tool_error() is called inside a try block with a broad except Exception handler, to prevent ToolError being swallowed and re-mapped to INTERNAL_ERROR. Updates unit tests and e2e tests that previously asserted on returned error dicts to use pytest.raises(ToolError) or safe_call_tool(). * fix(tests): update e2e tests for structured error shape - suggestions now live at result["error"]["suggestions"] not top-level - use safe_call_tool() for calls expected to raise ToolError - fix test_label_full_lifecycle, test_get_nonexistent_label, test_delete_nonexistent_label, test_delete_calendar_event, test_get_blueprint_not_found, test_import_blueprint_nonexistent_url * fix(tests): use safe_call_tool for all error-path e2e calls * test: guard e2e tests against accidental real-HA usage - AGENTS.md: document that full suite must be run from tests/ dir; running individual files misses failures CI catches - conftest.py: fail fast if Docker is unavailable (clear error instead of cryptic Docker exception) - conftest.py: abort if HOMEASSISTANT_URL is pre-set in the environment to prevent accidentally targeting a real HA instance - conftest.py: fail hard if HA API is not ready after 60s instead of silently continuing with a broken container * fix(test): remove pre-set URL guard that fires on dotenv load config.py calls load_dotenv(tests/.env.test) at import time, which sets HOMEASSISTANT_URL=http://localhost:8123 into os.environ before the ha_container_with_fresh_config fixture runs. The guard was tripping on its own placeholder URL in CI. Real-HA protection is now: Docker availability check (guard 1), fail-fast if HA API not ready (guard 3), and AGENTS.md documentation. * docs: trim AGENTS.md testing note * fix(tests): fix remaining e2e error-shape mismatches - test_python_transform: result["error"].lower() → extract message from error dict - test_lifecycle (jq): call_tool+parse_mcp_result → safe_call_tool + error.message - test_bulk: safe_call_tool for empty operations test - test_entity_management: suggestions nested under error, not top-level - test_backup: available_backups no longer top-level, accept suggestion instead * fix(tests): fix final batch of e2e error-shape mismatches - test_resources.py: use safe_call_tool + dict error extraction for test_inline_empty_content_error - test_integration_management.py: replace call_tool+assert_mcp_failure with safe_call_tool + dict error extraction for both delete_config_entry tests; remove unused assert_mcp_failure import - labels/test_lifecycle.py: use safe_call_tool for update/delete nonexistent label and assign to nonexistent entity tests * fix(tests): fix remaining e2e error-shape mismatches (batch 3) - test_device_registry.py: extract error message from dict for nonexistent device get/update/remove assertions - test_system_tools.py: use safe_call_tool + dict error extraction for restart/reload error-path tests; fix suggestions lookup under error dict - test_lifecycle.py (scripts): use safe_call_tool for blueprint script tests instead of call_tool + enhanced_parse_mcp_result * fix: address code review feedback on error handling standardization - smart_search.py: replace return create_error_response() with exception_to_structured_error() so all 4 tool methods raise ToolError instead of returning error dicts (contradicted PR contract) - tools_entities.py: replace hardcoded "SERVICE_CALL_FAILED" and "ENTITY_NOT_FOUND" strings with ErrorCode enum values - tools_areas/addons/labels/groups.py: restore logger.error() calls before exception_to_structured_error() for consistent server-side logging (was removed inconsistently in original PR) - tools_services.py: change VALIDATION_INVALID_PARAMETER to INTERNAL_UNEXPECTED for unexpected HA API response format in _process_services (not a user input error) * fix: restore exception_to_structured_error(raise_error=False) in delete handlers During rebase conflict resolution the old idempotent "not found = success" pattern was reintroduced in the exception blocks of ha_config_delete_dashboard and ha_config_delete_dashboard_resource. PR #680 had explicitly removed those paths in favour of exception_to_structured_error(raise_error=False), which this commit restores.
1 parent 9e6988a commit fab2736

57 files changed

Lines changed: 2320 additions & 2100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -431,20 +431,27 @@ cp .env.example .env # Configure HA connection
431431
- Personal workflow helper (gitignored, not committed)
432432

433433
### Testing
434-
E2E tests are in `tests/src/e2e/` (not `tests/e2e/`).
434+
E2E tests are in `tests/src/e2e/` (not `tests/e2e/`). Tests use **testcontainers** to spin up
435+
an isolated Docker HA instance — Docker daemon must be running.
435436

436437
```bash
437-
# Run E2E tests (requires Docker daemon)
438-
uv run pytest tests/src/e2e/ -v --tb=short
438+
# Run FULL E2E suite (required before claiming all tests pass)
439+
cd tests && uv run pytest src/e2e/ -v --tb=short
439440

440-
# Run specific test
441-
uv run pytest tests/src/e2e/workflows/automation/test_lifecycle.py -v
441+
# Run specific file (partial coverage only — never substitute for full suite)
442+
cd tests && uv run pytest src/e2e/workflows/automation/test_lifecycle.py -v
442443

443444
# Interactive test environment
444445
uv run hamcp-test-env # Interactive mode
445446
uv run hamcp-test-env --no-interactive # For automation
446447
```
447448

449+
**CRITICAL RULES:**
450+
- Always run from the `tests/` directory so pytest picks up the correct `conftest.py`
451+
- Always run the **full suite** before declaring tests pass
452+
- `tests/.env.test` contains placeholder values only; testcontainers sets the real URL dynamically
453+
- Never set `HOMEASSISTANT_URL` manually in your shell before running tests
454+
448455
Test token centralized in `tests/test_constants.py`.
449456

450457
### Code Quality
@@ -535,45 +542,79 @@ def register_<domain>_tools(mcp, client, **kwargs):
535542

536543
**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.
537544

538-
**Domain-specific errors** (`errors.py`) — use these when the error type is known:
545+
**All tool-level failures must raise `ToolError`** (sets `isError=true` per MCP spec). Batch item failures within result arrays are the only exception — those return structured dicts without raising.
546+
547+
**Pattern A — Exception blocks** (most common): call `exception_to_structured_error` without `return` — it raises `ToolError` by default:
539548
```python
540-
from ..errors import create_entity_not_found_error, create_validation_error, create_service_error
549+
from .helpers import exception_to_structured_error, raise_tool_error
550+
from fastmcp.exceptions import ToolError
541551

542-
# Entity lookup failures (404 / not found)
543-
return create_entity_not_found_error(entity_id, details=str(e))
552+
try:
553+
# ... tool logic ...
554+
except ToolError:
555+
raise # must re-raise; prevents ToolError being swallowed by outer except
556+
except Exception as e:
557+
exception_to_structured_error(
558+
e,
559+
context={"entity_id": entity_id},
560+
suggestions=["Verify entity exists", "Check HA connection"],
561+
)
562+
```
544563

545-
# Invalid parameters
546-
return create_validation_error("Invalid format", parameter="entity_ids", details=str(e))
564+
The `except ToolError: raise` guard is required whenever `raise_tool_error()` or validation errors are called inside the same `try` block — without it, `except Exception` catches the `ToolError` and re-maps it to `INTERNAL_ERROR`.
547565

548-
# Service call failures
549-
return create_service_error(domain, service, message=f"Service call failed: {e}", details=str(e))
566+
**Pattern B — Input validation errors**:
567+
```python
568+
from ..errors import ErrorCode, create_error_response, create_validation_error
569+
570+
if not entity_id.startswith("light."):
571+
raise_tool_error(create_error_response(
572+
ErrorCode.VALIDATION_INVALID_PARAMETER,
573+
f"entity_id must start with 'light.', got: {entity_id}",
574+
suggestions=["Use ha_search_entities(domain_filter='light') to find valid IDs"],
575+
context={"entity_id": entity_id},
576+
))
550577
```
551578

552-
Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_resource_not_found_error`, and the generic `create_error_response`.
553-
554-
**Catch-all exception handler** (`helpers.py`) — use in `except Exception` blocks:
579+
**Pattern C — WebSocket / service call failures**:
555580
```python
556-
from .helpers import exception_to_structured_error
581+
if not result.get("success"):
582+
raise_tool_error(create_error_response(
583+
ErrorCode.SERVICE_CALL_FAILED,
584+
result.get("error", "Operation failed"),
585+
context={"entity_id": entity_id},
586+
))
587+
```
557588

558-
except Exception as e:
559-
return exception_to_structured_error(e, context={"entity_id": entity_id})
589+
**Pattern D — Batch item failures** (items inside a results list — do NOT raise):
590+
```python
591+
results.append(create_error_response(
592+
ErrorCode.SERVICE_CALL_FAILED,
593+
str(e),
594+
context={"entity_id": eid},
595+
))
560596
```
561597

562-
**Pattern for tools**: Use `exception_to_structured_error` as the catch-all — it already classifies 404s, auth errors, timeouts, etc. based on exception type and message. Pass `context={"entity_id": ...}` so it can produce `ENTITY_NOT_FOUND` for 404 errors automatically. No manual 404 string matching needed:
598+
**Special case** — when the error dict needs post-processing before raising (e.g., timezone metadata injection), use `raise_error=False` then `raise_tool_error()`:
563599
```python
564-
try:
565-
result = await client.get_entity_state(entity_id)
566-
return await add_timezone_metadata(client, result)
567600
except Exception as e:
568-
error_response = exception_to_structured_error(e, context={"entity_id": entity_id})
569-
return await add_timezone_metadata(client, error_response)
601+
error_response = exception_to_structured_error(
602+
e, context={"entity_id": entity_id}, raise_error=False
603+
)
604+
error_with_tz = await add_timezone_metadata(client, error_response)
605+
raise_tool_error(error_with_tz)
570606
```
571607

608+
Available `errors.py` helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_resource_not_found_error`, and the generic `create_error_response`.
609+
610+
`exception_to_structured_error` already classifies 404s, auth errors, timeouts, etc. based on exception type. Pass `context={"entity_id": ...}` so it produces `ENTITY_NOT_FOUND` for 404 errors automatically — no manual string matching needed.
611+
572612
### Return Values
573613
```python
574614
{"success": True, "data": result} # Success
575615
{"success": True, "partial": True, "warning": "..."} # Degraded
576-
{"success": False, "error": {...}} # Failure
616+
raise ToolError(json.dumps({...})) # Tool-level failure (isError=true)
617+
{"success": False, "error": {...}} # Batch item failure only (in results list)
577618
```
578619

579620
### Tool Consolidation

src/ha_mcp/tools/backup.py

Lines changed: 70 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@
99
from datetime import datetime
1010
from typing import TYPE_CHECKING, Annotated, Any
1111

12+
from fastmcp.exceptions import ToolError
1213
from pydantic import Field
1314

1415
from ..client.rest_client import HomeAssistantClient
1516
from ..client.websocket_client import HomeAssistantWebSocketClient
16-
from .helpers import get_connected_ws_client, log_tool_usage
17+
from ..errors import ErrorCode, create_error_response
18+
from .helpers import (
19+
exception_to_structured_error,
20+
get_connected_ws_client,
21+
log_tool_usage,
22+
raise_tool_error,
23+
)
1724

1825
if TYPE_CHECKING:
1926
from fastmcp import FastMCP
@@ -94,12 +101,18 @@ async def create_backup(
94101
# Connect to WebSocket
95102
ws_client, error = await get_connected_ws_client(client.base_url, client.token)
96103
if error:
97-
return error
104+
raise_tool_error(error or create_error_response(
105+
ErrorCode.CONNECTION_FAILED,
106+
"Failed to connect to Home Assistant WebSocket for backup",
107+
))
98108

99109
# Get backup password
100110
password, error = await _get_backup_password(ws_client)
101111
if error:
102-
return error
112+
raise_tool_error(create_error_response(
113+
ErrorCode.SERVICE_CALL_FAILED,
114+
error.get("error", "Failed to retrieve backup password"),
115+
))
103116

104117
# Generate backup name if not provided
105118
if not name:
@@ -120,11 +133,10 @@ async def create_backup(
120133
result = await ws_client.send_command("backup/generate", **backup_params)
121134

122135
if not result.get("success"):
123-
return {
124-
"success": False,
125-
"error": "Backup creation failed",
126-
"details": result,
127-
}
136+
raise_tool_error(create_error_response(
137+
ErrorCode.SERVICE_CALL_FAILED,
138+
result.get("error", "Backup creation failed"),
139+
))
128140

129141
backup_job_id = result.get("result", {}).get("backup_job_id")
130142
logger.info(f"Backup job started: {backup_job_id}, waiting for completion...")
@@ -185,30 +197,30 @@ async def create_backup(
185197

186198
# Check if backup failed
187199
elif event_state == "failed":
188-
return {
189-
"success": False,
190-
"error": "Backup creation failed",
191-
"backup_job_id": backup_job_id,
192-
"last_event": last_event,
193-
}
200+
raise_tool_error(create_error_response(
201+
ErrorCode.SERVICE_CALL_FAILED,
202+
"Backup creation failed",
203+
context={"backup_job_id": backup_job_id},
204+
))
194205

195206
# Timeout waiting for backup
196207
logger.warning(f"Backup did not complete within {max_wait_seconds} seconds")
197-
return {
198-
"success": False,
199-
"error": f"Backup creation timed out after {max_wait_seconds} seconds",
200-
"backup_job_id": backup_job_id,
201-
"name": name,
202-
"suggestion": "Backup may still be in progress. Check Home Assistant backup status.",
203-
}
204-
208+
raise_tool_error(create_error_response(
209+
ErrorCode.TIMEOUT_OPERATION,
210+
f"Backup creation timed out after {max_wait_seconds} seconds",
211+
context={"backup_job_id": backup_job_id, "name": name},
212+
suggestions=["Backup may still be in progress. Check Home Assistant backup status."],
213+
))
214+
215+
except ToolError:
216+
raise
205217
except Exception as e:
206218
logger.error(f"Error creating backup: {e}")
207-
return {
208-
"success": False,
209-
"error": f"Failed to create backup: {str(e)}",
210-
"suggestion": "Check Home Assistant connection and backup configuration",
211-
}
219+
exception_to_structured_error(
220+
e,
221+
context={"tool": "create_backup"},
222+
suggestions=["Check Home Assistant connection and backup configuration"],
223+
)
212224
finally:
213225
# Always disconnect WebSocket
214226
if ws_client:
@@ -240,35 +252,28 @@ async def restore_backup(
240252
# Connect to WebSocket
241253
ws_client, error = await get_connected_ws_client(client.base_url, client.token)
242254
if error:
243-
return error
255+
raise_tool_error(error or create_error_response(
256+
ErrorCode.CONNECTION_FAILED,
257+
"Failed to connect to Home Assistant WebSocket for restore",
258+
))
244259

245260
# Verify backup exists
246261
backup_info = await ws_client.send_command("backup/info")
247262
if not backup_info.get("success"):
248-
return {
249-
"success": False,
250-
"error": "Failed to retrieve backup information",
251-
"details": backup_info,
252-
}
263+
raise_tool_error(create_error_response(
264+
ErrorCode.SERVICE_CALL_FAILED,
265+
backup_info.get("error", "Failed to retrieve backup information"),
266+
))
253267

254268
backups = backup_info.get("result", {}).get("backups", [])
255269
backup_exists = any(b.get("backup_id") == backup_id for b in backups)
256270

257271
if not backup_exists:
258-
available_backups = [
259-
{
260-
"backup_id": b.get("backup_id"),
261-
"name": b.get("name"),
262-
"date": b.get("date"),
263-
}
264-
for b in backups[:5]
265-
]
266-
return {
267-
"success": False,
268-
"error": f"Backup '{backup_id}' not found",
269-
"available_backups": available_backups,
270-
"suggestion": "Use one of the available backup IDs listed above",
271-
}
272+
raise_tool_error(create_error_response(
273+
ErrorCode.RESOURCE_NOT_FOUND,
274+
f"Backup '{backup_id}' not found",
275+
suggestions=["Use ha_backup_list() to see available backups"],
276+
))
272277

273278
# Create safety backup BEFORE restoring
274279
logger.info("Creating safety backup before restore...")
@@ -293,12 +298,11 @@ async def restore_backup(
293298
)
294299

295300
if not safety_backup.get("success"):
296-
return {
297-
"success": False,
298-
"error": "Failed to create safety backup before restore",
299-
"details": safety_backup,
300-
"suggestion": "Cannot proceed with restore without safety backup",
301-
}
301+
raise_tool_error(create_error_response(
302+
ErrorCode.SERVICE_CALL_FAILED,
303+
safety_backup.get("error", "Failed to create safety backup before restore"),
304+
suggestions=["Cannot proceed with restore without safety backup"],
305+
))
302306

303307
safety_backup_id = safety_backup.get("result", {}).get("backup_job_id")
304308
logger.info(f"Safety backup created: {safety_backup_id}")
@@ -326,20 +330,21 @@ async def restore_backup(
326330
"note": "A safety backup was created before restore. You can restore from it if needed.",
327331
}
328332
else:
329-
return {
330-
"success": False,
331-
"error": "Restore operation failed",
332-
"details": result,
333-
"safety_backup_id": safety_backup_id,
334-
}
335-
333+
raise_tool_error(create_error_response(
334+
ErrorCode.SERVICE_CALL_FAILED,
335+
result.get("error", "Restore operation failed"),
336+
context={"backup_id": backup_id},
337+
))
338+
339+
except ToolError:
340+
raise
336341
except Exception as e:
337342
logger.error(f"Error restoring backup: {e}")
338-
return {
339-
"success": False,
340-
"error": f"Failed to restore backup: {str(e)}",
341-
"suggestion": "Check Home Assistant connection and backup availability",
342-
}
343+
exception_to_structured_error(
344+
e,
345+
context={"tool": "restore_backup", "backup_id": backup_id},
346+
suggestions=["Check Home Assistant connection and backup availability"],
347+
)
343348
finally:
344349
# Always disconnect WebSocket
345350
if ws_client:

0 commit comments

Comments
 (0)