Skip to content

Commit 343a6e1

Browse files
authored
fix: Handle EntityCreationError as conflict (#541)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a0e754b commit 343a6e1

5 files changed

Lines changed: 77 additions & 13 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from contextlib import asynccontextmanager
44

5-
from fastapi import FastAPI, HTTPException
5+
from fastapi import FastAPI, HTTPException, Request
66
from fastapi.exception_handlers import http_exception_handler
77
from fastapi.routing import APIRouter
88
from loguru import logger
@@ -21,6 +21,7 @@
2121
)
2222
from basic_memory.api.v2.routers.project_router import list_projects
2323
from basic_memory.config import init_api_logging
24+
from basic_memory.services.exceptions import EntityAlreadyExistsError
2425
from basic_memory.services.initialization import initialize_app
2526

2627

@@ -93,6 +94,31 @@ async def lifespan(app: FastAPI): # pragma: no cover
9394
# V2 routers are the only public API surface
9495

9596

97+
@app.exception_handler(EntityAlreadyExistsError)
98+
async def entity_already_exists_error_handler(
99+
request: Request, exc: EntityAlreadyExistsError
100+
):
101+
"""Handle entity creation conflicts (e.g., file already exists).
102+
103+
This is expected behavior when users try to create notes that exist,
104+
so log at INFO level instead of ERROR.
105+
"""
106+
logger.info(
107+
"Entity already exists",
108+
url=str(request.url),
109+
method=request.method,
110+
path=request.url.path,
111+
error=str(exc),
112+
)
113+
return await http_exception_handler(
114+
request,
115+
HTTPException(
116+
status_code=409,
117+
detail="Note already exists. Use edit_note to modify it, or delete it first.",
118+
),
119+
)
120+
121+
96122
@app.exception_handler(Exception)
97123
async def exception_handler(request, exc): # pragma: no cover
98124
logger.exception(

src/basic_memory/services/entity_service.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
DirectoryDeleteError,
3535
)
3636
from basic_memory.services import BaseService, FileService
37-
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
37+
from basic_memory.services.exceptions import (
38+
EntityAlreadyExistsError,
39+
EntityCreationError,
40+
EntityNotFoundError,
41+
)
3842
from basic_memory.services.link_resolver import LinkResolver
3943
from basic_memory.services.search_service import SearchService
4044
from basic_memory.utils import generate_permalink
@@ -216,7 +220,7 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel:
216220
file_path = Path(schema.file_path)
217221

218222
if await self.file_service.exists(file_path):
219-
raise EntityCreationError(
223+
raise EntityAlreadyExistsError(
220224
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
221225
)
222226

@@ -356,7 +360,7 @@ async def fast_write_entity(
356360
file_path = Path(existing.file_path) if existing else Path(schema.file_path)
357361

358362
if not existing and await self.file_service.exists(file_path):
359-
raise EntityCreationError(
363+
raise EntityAlreadyExistsError(
360364
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
361365
)
362366

src/basic_memory/services/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ class EntityCreationError(Exception):
1616
pass
1717

1818

19+
class EntityAlreadyExistsError(EntityCreationError):
20+
"""Raised when an entity file already exists"""
21+
22+
pass
23+
24+
1925
class DirectoryOperationError(Exception):
2026
"""Raised when directory operations fail"""
2127

tests/api/v2/test_knowledge_router.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,34 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
181181
assert data["content"] in file_content
182182

183183

184+
@pytest.mark.asyncio
185+
async def test_create_entity_conflict_returns_409(client: AsyncClient, v2_project_url):
186+
"""Test creating a duplicate entity returns 409 Conflict."""
187+
data = {
188+
"title": "TestV2EntityConflict",
189+
"directory": "conflict",
190+
"entity_type": "note",
191+
"content_type": "text/markdown",
192+
"content": "Original content for conflict",
193+
}
194+
195+
response = await client.post(
196+
f"{v2_project_url}/knowledge/entities",
197+
json=data,
198+
params={"fast": False},
199+
)
200+
assert response.status_code == 200
201+
202+
response = await client.post(
203+
f"{v2_project_url}/knowledge/entities",
204+
json=data,
205+
params={"fast": False},
206+
)
207+
assert response.status_code == 409
208+
expected_detail = "Note already exists. Use edit_note to modify it, or delete it first."
209+
assert response.json()["detail"] == expected_detail
210+
211+
184212
@pytest.mark.asyncio
185213
async def test_create_entity_returns_content(client: AsyncClient, file_service, v2_project_url):
186214
"""Test creating an entity always returns file content with frontmatter."""

tests/services/test_search_service.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,9 @@ async def test_boolean_not_search(search_service, test_graph):
345345

346346
# Should find "Root Entity" but not "Connected Entity"
347347
for result in results:
348-
assert (
349-
"connected" not in result.permalink.lower()
350-
), "Boolean NOT search returned excluded term"
348+
assert "connected" not in result.permalink.lower(), (
349+
"Boolean NOT search returned excluded term"
350+
)
351351

352352

353353
@pytest.mark.asyncio
@@ -366,9 +366,9 @@ async def test_boolean_group_search(search_service, test_graph):
366366
"root" in result.title.lower() or "connected" in result.title.lower()
367367
)
368368

369-
assert (
370-
contains_entity and contains_root_or_connected
371-
), "Boolean grouped search returned incorrect results"
369+
assert contains_entity and contains_root_or_connected, (
370+
"Boolean grouped search returned incorrect results"
371+
)
372372

373373

374374
@pytest.mark.asyncio
@@ -398,9 +398,9 @@ async def test_boolean_operators_detection(search_service):
398398

399399
for query_text in non_boolean_queries:
400400
query = SearchQuery(text=query_text)
401-
assert (
402-
not query.has_boolean_operators()
403-
), f"Incorrectly detected boolean operators in: {query_text}"
401+
assert not query.has_boolean_operators(), (
402+
f"Incorrectly detected boolean operators in: {query_text}"
403+
)
404404

405405

406406
# Tests for frontmatter tag search functionality

0 commit comments

Comments
 (0)