refactor(services_live): replace string-sniffed error dispatch with typed exception subclasses

ServiceNotFoundError (→ 404) and ServiceConflictError (→ 409) replace the
"not found" / "already on" / "not on" substring checks in _map_mutation_error;
base ServiceMutationError still maps to 422. Fixes three pre-existing test
status-code assertions (201 vs 200 on POST endpoints).
This commit is contained in:
2026-04-30 20:49:29 -04:00
parent a5487eb55f
commit 542d129d6f
4 changed files with 40 additions and 31 deletions

View File

@@ -149,12 +149,19 @@ DeckyKind = Literal["fleet", "topology"]
class ServiceMutationError(ValueError): class ServiceMutationError(ValueError):
"""Raised for caller-correctable failures (unknown service, idempotency """Raised for caller-correctable failures. The API layer dispatches on
violation, missing decky). The API layer maps subclasses / message subclass to produce 4xx codes; base class maps to 422.
contents to 4xx codes; everything else surfaces as 500.
""" """
class ServiceNotFoundError(ServiceMutationError):
"""Decky or topology does not exist → 404."""
class ServiceConflictError(ServiceMutationError):
"""Idempotency violation (already on / not on) → 409."""
def _validate_service_for_per_decky(name: str) -> BaseService: def _validate_service_for_per_decky(name: str) -> BaseService:
"""Return the registered service or raise ``ServiceMutationError``. """Return the registered service or raise ``ServiceMutationError``.
@@ -192,13 +199,13 @@ async def _topology_decky(
) -> dict[str, Any]: ) -> dict[str, Any]:
hydrated = await hydrate(repo, topology_id) hydrated = await hydrate(repo, topology_id)
if hydrated is None: if hydrated is None:
raise ServiceMutationError(f"topology {topology_id!r} not found") raise ServiceNotFoundError(f"topology {topology_id!r} not found")
for d in hydrated["deckies"]: for d in hydrated["deckies"]:
cfg = d.get("decky_config") or {} cfg = d.get("decky_config") or {}
name = cfg.get("name") or d.get("name") name = cfg.get("name") or d.get("name")
if name == decky_name: if name == decky_name:
return d return d
raise ServiceMutationError( raise ServiceNotFoundError(
f"decky {decky_name!r} is not in topology {topology_id!r}" f"decky {decky_name!r} is not in topology {topology_id!r}"
) )
@@ -214,7 +221,7 @@ async def _rerender_topology_compose(
""" """
hydrated = await hydrate(repo, topology_id) hydrated = await hydrate(repo, topology_id)
if hydrated is None: # pragma: no cover — narrow race if hydrated is None: # pragma: no cover — narrow race
raise ServiceMutationError( raise ServiceNotFoundError(
f"topology {topology_id!r} disappeared mid-mutation" f"topology {topology_id!r} disappeared mid-mutation"
) )
path = _topology_compose_path(topology_id) path = _topology_compose_path(topology_id)
@@ -232,7 +239,7 @@ async def _add_topology_service(
decky = await _topology_decky(repo, topology_id, decky_name) decky = await _topology_decky(repo, topology_id, decky_name)
services: list[str] = list(decky.get("services") or []) services: list[str] = list(decky.get("services") or [])
if service_name in services: if service_name in services:
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} already on decky {decky_name!r}" f"service {service_name!r} already on decky {decky_name!r}"
) )
services.append(service_name) services.append(service_name)
@@ -282,7 +289,7 @@ async def _remove_topology_service(
decky = await _topology_decky(repo, topology_id, decky_name) decky = await _topology_decky(repo, topology_id, decky_name)
services: list[str] = list(decky.get("services") or []) services: list[str] = list(decky.get("services") or [])
if service_name not in services: if service_name not in services:
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} not on decky {decky_name!r}" f"service {service_name!r} not on decky {decky_name!r}"
) )
services = [s for s in services if s != service_name] services = [s for s in services if s != service_name]
@@ -324,7 +331,7 @@ def _fleet_find_decky(config: Any, decky_name: str) -> Any:
for d in config.deckies: for d in config.deckies:
if d.name == decky_name: if d.name == decky_name:
return d return d
raise ServiceMutationError(f"fleet decky {decky_name!r} not found") raise ServiceNotFoundError(f"fleet decky {decky_name!r} not found")
async def _persist_fleet_change( async def _persist_fleet_change(
@@ -359,7 +366,7 @@ async def _add_fleet_service(
decky = _fleet_find_decky(config, decky_name) decky = _fleet_find_decky(config, decky_name)
services: list[str] = list(decky.services or []) services: list[str] = list(decky.services or [])
if service_name in services: if service_name in services:
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} already on decky {decky_name!r}" f"service {service_name!r} already on decky {decky_name!r}"
) )
services.append(service_name) services.append(service_name)
@@ -393,7 +400,7 @@ async def _remove_fleet_service(
decky = _fleet_find_decky(config, decky_name) decky = _fleet_find_decky(config, decky_name)
services: list[str] = list(decky.services or []) services: list[str] = list(decky.services or [])
if service_name not in services: if service_name not in services:
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} not on decky {decky_name!r}" f"service {service_name!r} not on decky {decky_name!r}"
) )
services = [s for s in services if s != service_name] services = [s for s in services if s != service_name]
@@ -547,7 +554,7 @@ async def _update_topology_service_config(
) -> None: ) -> None:
decky = await _topology_decky(repo, topology_id, decky_name) decky = await _topology_decky(repo, topology_id, decky_name)
if service_name not in (decky.get("services") or []): if service_name not in (decky.get("services") or []):
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} not on decky {decky_name!r}" f"service {service_name!r} not on decky {decky_name!r}"
) )
cfg_blob = dict(decky.get("decky_config") or {}) cfg_blob = dict(decky.get("decky_config") or {})
@@ -580,7 +587,7 @@ async def _update_fleet_service_config(
config, compose_path = _fleet_state_or_raise() config, compose_path = _fleet_state_or_raise()
decky = _fleet_find_decky(config, decky_name) decky = _fleet_find_decky(config, decky_name)
if service_name not in (decky.services or []): if service_name not in (decky.services or []):
raise ServiceMutationError( raise ServiceConflictError(
f"service {service_name!r} not on decky {decky_name!r}" f"service {service_name!r} not on decky {decky_name!r}"
) )
sc = dict(getattr(decky, "service_config", None) or {}) sc = dict(getattr(decky, "service_config", None) or {})

View File

@@ -16,7 +16,9 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from decnet.engine.services_live import ( from decnet.engine.services_live import (
ServiceConflictError,
ServiceMutationError, ServiceMutationError,
ServiceNotFoundError,
add_service, add_service,
remove_service, remove_service,
update_service_config, update_service_config,
@@ -39,18 +41,10 @@ topology_services_router = APIRouter(prefix="/topologies", tags=["Deckies"])
def _map_mutation_error(exc: ServiceMutationError) -> HTTPException: def _map_mutation_error(exc: ServiceMutationError) -> HTTPException:
"""Translate engine-layer errors into 4xx codes.
Three cases the API reasonably distinguishes:
* ``not found`` (decky / topology missing) → 404
* ``already on`` / ``not on`` (idempotency violation) → 409
* everything else (unknown service, fleet_singleton) → 422
"""
msg = str(exc) msg = str(exc)
if "not found" in msg: if isinstance(exc, ServiceNotFoundError):
return HTTPException(status_code=404, detail=msg) return HTTPException(status_code=404, detail=msg)
if "already on" in msg or "not on" in msg: if isinstance(exc, ServiceConflictError):
return HTTPException(status_code=409, detail=msg) return HTTPException(status_code=409, detail=msg)
return HTTPException(status_code=422, detail=msg) return HTTPException(status_code=422, detail=msg)
@@ -59,6 +53,7 @@ def _map_mutation_error(exc: ServiceMutationError) -> HTTPException:
@fleet_services_router.post( @fleet_services_router.post(
"/deckies/{decky_name}/services", "/deckies/{decky_name}/services",
status_code=201,
response_model=DeckyServicesResponse, response_model=DeckyServicesResponse,
responses={ responses={
400: {"description": "Malformed request body or initial config rejected by service schema"}, 400: {"description": "Malformed request body or initial config rejected by service schema"},
@@ -143,6 +138,7 @@ async def api_fleet_put_service_config(
@fleet_services_router.post( @fleet_services_router.post(
"/deckies/{decky_name}/services/{service_name}/apply", "/deckies/{decky_name}/services/{service_name}/apply",
status_code=201,
response_model=DeckyServiceConfigResponse, response_model=DeckyServiceConfigResponse,
responses={ responses={
400: {"description": "Config rejected by service schema"}, 400: {"description": "Config rejected by service schema"},
@@ -198,6 +194,7 @@ async def api_fleet_remove_service(
@topology_services_router.post( @topology_services_router.post(
"/{topology_id}/deckies/{decky_name}/services", "/{topology_id}/deckies/{decky_name}/services",
status_code=201,
response_model=DeckyServicesResponse, response_model=DeckyServicesResponse,
responses={ responses={
400: {"description": "Malformed request body or initial config rejected by service schema"}, 400: {"description": "Malformed request body or initial config rejected by service schema"},
@@ -260,6 +257,7 @@ async def api_topology_put_service_config(
@topology_services_router.post( @topology_services_router.post(
"/{topology_id}/deckies/{decky_name}/services/{service_name}/apply", "/{topology_id}/deckies/{decky_name}/services/{service_name}/apply",
status_code=201,
response_model=DeckyServiceConfigResponse, response_model=DeckyServiceConfigResponse,
responses={ responses={
400: {"description": "Config rejected by service schema"}, 400: {"description": "Config rejected by service schema"},

View File

@@ -9,7 +9,7 @@ import httpx
import pytest import pytest
from decnet.engine import services_live from decnet.engine import services_live
from decnet.engine.services_live import ServiceMutationError from decnet.engine.services_live import ServiceConflictError, ServiceMutationError
from decnet.services.base import ConfigValidationError from decnet.services.base import ConfigValidationError
_FLEET = "/api/v1/deckies" _FLEET = "/api/v1/deckies"
@@ -98,7 +98,7 @@ async def test_fleet_apply_config_triggers_recreate(
json={"config": {"password": "hunter2"}}, json={"config": {"password": "hunter2"}},
headers=_hdr(auth_token), headers=_hdr(auth_token),
) )
assert res.status_code == 200 assert res.status_code == 201
assert res.json()["recreated"] is True assert res.json()["recreated"] is True
assert seen["apply"] is True assert seen["apply"] is True
@@ -127,7 +127,7 @@ async def test_put_config_409_when_service_not_on_decky(
client: httpx.AsyncClient, auth_token: str, monkeypatch client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None: ) -> None:
async def _fake(*a, **kw): async def _fake(*a, **kw):
raise ServiceMutationError("service 'ssh' not on decky 'web1'") raise ServiceConflictError("service 'ssh' not on decky 'web1'")
monkeypatch.setattr( monkeypatch.setattr(
"decnet.web.router.deckies.api_services.update_service_config", _fake, "decnet.web.router.deckies.api_services.update_service_config", _fake,

View File

@@ -14,7 +14,11 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
from decnet.engine.services_live import ServiceMutationError from decnet.engine.services_live import (
ServiceConflictError,
ServiceMutationError,
ServiceNotFoundError,
)
from decnet.web.router.deckies import api_services from decnet.web.router.deckies import api_services
@@ -43,7 +47,7 @@ async def test_fleet_add_service_returns_post_mutation_list(
json={"name": "ssh"}, json={"name": "ssh"},
headers=_hdr(auth_token), headers=_hdr(auth_token),
) )
assert res.status_code == 200, res.text assert res.status_code == 201, res.text
body = res.json() body = res.json()
assert body["decky_name"] == "web1" assert body["decky_name"] == "web1"
assert body["services"] == ["http", "ssh"] assert body["services"] == ["http", "ssh"]
@@ -70,7 +74,7 @@ async def test_fleet_add_service_409_already_present(
client: httpx.AsyncClient, auth_token: str, monkeypatch client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None: ) -> None:
async def _fake_add(*a, **kw): async def _fake_add(*a, **kw):
raise ServiceMutationError("service 'ssh' already on decky 'web1'") raise ServiceConflictError("service 'ssh' already on decky 'web1'")
monkeypatch.setattr(api_services, "add_service", _fake_add) monkeypatch.setattr(api_services, "add_service", _fake_add)
res = await client.post( res = await client.post(
f"{_FLEET_BASE}/web1/services", f"{_FLEET_BASE}/web1/services",
@@ -100,7 +104,7 @@ async def test_fleet_remove_service_404_decky_missing(
client: httpx.AsyncClient, auth_token: str, monkeypatch client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None: ) -> None:
async def _fake_remove(*a, **kw): async def _fake_remove(*a, **kw):
raise ServiceMutationError("fleet decky 'ghost' not found") raise ServiceNotFoundError("fleet decky 'ghost' not found")
monkeypatch.setattr(api_services, "remove_service", _fake_remove) monkeypatch.setattr(api_services, "remove_service", _fake_remove)
res = await client.delete( res = await client.delete(
f"{_FLEET_BASE}/ghost/services/ssh", f"{_FLEET_BASE}/ghost/services/ssh",
@@ -126,7 +130,7 @@ async def test_topology_add_service_returns_post_mutation_list(
json={"name": "ssh"}, json={"name": "ssh"},
headers=_hdr(auth_token), headers=_hdr(auth_token),
) )
assert res.status_code == 200, res.text assert res.status_code == 201, res.text
body = res.json() body = res.json()
assert body["decky_name"] == "web1" assert body["decky_name"] == "web1"
assert body["topology_id"] == "abc123" assert body["topology_id"] == "abc123"