From c214cdd7bb24d1a0a95b51965024b891fc5ec23a Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 19:06:37 -0400 Subject: [PATCH] fix(api/topology): map duplicate-name IntegrityError to 409 POST /topologies raised a 500 with a raw SQLAlchemy IntegrityError traceback when the name collided with an existing topology. Catch the error at the router, verify it's the ix_topologies_name constraint (so unrelated integrity failures still surface as 500s with their real traceback), and return 409 with a helpful detail. Test covers the create-then-duplicate-create flow. --- .../router/topology/api_create_topology.py | 18 ++++++++++++++-- tests/api/topology/test_writes.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) 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(