diff --git a/decnet/web/router/topology/api_create_topology.py b/decnet/web/router/topology/api_create_topology.py index a60bda57..9f367508 100644 --- a/decnet/web/router/topology/api_create_topology.py +++ b/decnet/web/router/topology/api_create_topology.py @@ -2,6 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError from decnet.telemetry import traced as _traced from decnet.topology.allocator import reserved_subnets @@ -24,7 +25,7 @@ router = APIRouter() 400: {"description": "Malformed or invalid generation parameters"}, 401: {"description": "Missing or invalid credentials"}, 403: {"description": "Insufficient permissions"}, - 409: {"description": "Generator could not allocate subnets (exhausted pool)"}, + 409: {"description": "Duplicate topology name, or generator could not allocate subnets (exhausted pool)"}, }, ) @_traced("api.topology.create") @@ -58,6 +59,19 @@ async def api_create_topology( except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - topology_id = await persist(repo, plan, target_host_uuid=body.target_host_uuid) + try: + topology_id = await persist(repo, plan, target_host_uuid=body.target_host_uuid) + except IntegrityError as exc: + # Unique constraint on topologies.name is the only integrity + # error the create path can realistically hit — inspecting the + # constraint name keeps us from silently mapping unrelated + # integrity failures to 409. + msg = str(exc.orig) if exc.orig is not None else str(exc) + if "ix_topologies_name" in msg or "topologies.name" in msg: + raise HTTPException( + status_code=409, + detail=f"A topology named {body.name!r} already exists.", + ) from exc + raise row = await repo.get_topology(topology_id) return TopologySummary(**row) diff --git a/tests/api/topology/test_writes.py b/tests/api/topology/test_writes.py index 26c7ba57..ed75b59b 100644 --- a/tests/api/topology/test_writes.py +++ b/tests/api/topology/test_writes.py @@ -80,6 +80,27 @@ async def test_create_requires_auth(client): assert r.status_code == 401 +@pytest.mark.anyio +async def test_create_duplicate_name_is_409(client, auth_token): + """Re-using an existing topology name must return a clean 409, not + bubble the raw MySQL IntegrityError up to a 500.""" + payload = _generate_payload() + first = await client.post( + f"{_V1}/", + json=payload, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"{_V1}/", + json=payload, + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert second.status_code == 409, second.text + assert payload["name"] in second.json()["detail"] + + @pytest.mark.anyio async def test_create_bad_body(client, auth_token): r = await client.post(