From ba29d52036d6df97309f23467102ba511db131ed Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 3 Feb 2026 19:19:07 -0600 Subject: [PATCH 1/3] Handle EntityCreationError as conflict Signed-off-by: phernandez --- src/basic_memory/api/app.py | 24 ++++++++++++++++++++++++ tests/services/test_search_service.py | 18 +++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index bdc6e4b3..e8daea04 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -21,6 +21,7 @@ ) from basic_memory.api.v2.routers.project_router import list_projects from basic_memory.config import init_api_logging +from basic_memory.services.exceptions import EntityCreationError from basic_memory.services.initialization import initialize_app @@ -93,6 +94,29 @@ async def lifespan(app: FastAPI): # pragma: no cover # V2 routers are the only public API surface +@app.exception_handler(EntityCreationError) +async def entity_creation_error_handler(request, exc): # pragma: no cover + """Handle entity creation conflicts (e.g., file already exists). + + This is expected behavior when users try to create notes that exist, + so log at INFO level instead of ERROR. + """ + logger.info( + "Entity already exists", + url=str(request.url), + method=request.method, + path=request.url.path, + error=str(exc), + ) + return await http_exception_handler( + request, + HTTPException( + status_code=409, + detail="Note already exists. Use edit_note to modify it, or delete it first.", + ), + ) + + @app.exception_handler(Exception) async def exception_handler(request, exc): # pragma: no cover logger.exception( diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index d34a282a..aa5006ef 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -345,9 +345,9 @@ async def test_boolean_not_search(search_service, test_graph): # Should find "Root Entity" but not "Connected Entity" for result in results: - assert ( - "connected" not in result.permalink.lower() - ), "Boolean NOT search returned excluded term" + assert "connected" not in result.permalink.lower(), ( + "Boolean NOT search returned excluded term" + ) @pytest.mark.asyncio @@ -366,9 +366,9 @@ async def test_boolean_group_search(search_service, test_graph): "root" in result.title.lower() or "connected" in result.title.lower() ) - assert ( - contains_entity and contains_root_or_connected - ), "Boolean grouped search returned incorrect results" + assert contains_entity and contains_root_or_connected, ( + "Boolean grouped search returned incorrect results" + ) @pytest.mark.asyncio @@ -398,9 +398,9 @@ async def test_boolean_operators_detection(search_service): for query_text in non_boolean_queries: query = SearchQuery(text=query_text) - assert ( - not query.has_boolean_operators() - ), f"Incorrectly detected boolean operators in: {query_text}" + assert not query.has_boolean_operators(), ( + f"Incorrectly detected boolean operators in: {query_text}" + ) # Tests for frontmatter tag search functionality From 718ba6ccf161d9e37c2d7c3519a7abbd62656545 Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 3 Feb 2026 21:40:18 -0600 Subject: [PATCH 2/3] Add conflict test for entity creation Signed-off-by: phernandez --- src/basic_memory/api/app.py | 4 ++-- tests/api/v2/test_knowledge_router.py | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index e8daea04..bb60512d 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.exception_handlers import http_exception_handler from fastapi.routing import APIRouter from loguru import logger @@ -95,7 +95,7 @@ async def lifespan(app: FastAPI): # pragma: no cover @app.exception_handler(EntityCreationError) -async def entity_creation_error_handler(request, exc): # pragma: no cover +async def entity_creation_error_handler(request: Request, exc: EntityCreationError): """Handle entity creation conflicts (e.g., file already exists). This is expected behavior when users try to create notes that exist, diff --git a/tests/api/v2/test_knowledge_router.py b/tests/api/v2/test_knowledge_router.py index a58e018d..30d549c5 100644 --- a/tests/api/v2/test_knowledge_router.py +++ b/tests/api/v2/test_knowledge_router.py @@ -181,6 +181,34 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url): assert data["content"] in file_content +@pytest.mark.asyncio +async def test_create_entity_conflict_returns_409(client: AsyncClient, v2_project_url): + """Test creating a duplicate entity returns 409 Conflict.""" + data = { + "title": "TestV2EntityConflict", + "directory": "conflict", + "entity_type": "note", + "content_type": "text/markdown", + "content": "Original content for conflict", + } + + response = await client.post( + f"{v2_project_url}/knowledge/entities", + json=data, + params={"fast": False}, + ) + assert response.status_code == 200 + + response = await client.post( + f"{v2_project_url}/knowledge/entities", + json=data, + params={"fast": False}, + ) + assert response.status_code == 409 + expected_detail = "Note already exists. Use edit_note to modify it, or delete it first." + assert response.json()["detail"] == expected_detail + + @pytest.mark.asyncio async def test_create_entity_returns_content(client: AsyncClient, file_service, v2_project_url): """Test creating an entity always returns file content with frontmatter.""" From 8aecae31c4ab956e68ff4d3eaeabe8368b9887c5 Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 3 Feb 2026 21:56:28 -0600 Subject: [PATCH 3/3] Differentiate entity conflicts from creation failures Signed-off-by: phernandez --- src/basic_memory/api/app.py | 8 +++++--- src/basic_memory/services/entity_service.py | 10 +++++++--- src/basic_memory/services/exceptions.py | 6 ++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index bb60512d..5031af37 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -21,7 +21,7 @@ ) from basic_memory.api.v2.routers.project_router import list_projects from basic_memory.config import init_api_logging -from basic_memory.services.exceptions import EntityCreationError +from basic_memory.services.exceptions import EntityAlreadyExistsError from basic_memory.services.initialization import initialize_app @@ -94,8 +94,10 @@ async def lifespan(app: FastAPI): # pragma: no cover # V2 routers are the only public API surface -@app.exception_handler(EntityCreationError) -async def entity_creation_error_handler(request: Request, exc: EntityCreationError): +@app.exception_handler(EntityAlreadyExistsError) +async def entity_already_exists_error_handler( + request: Request, exc: EntityAlreadyExistsError +): """Handle entity creation conflicts (e.g., file already exists). This is expected behavior when users try to create notes that exist, diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 1ebe3e2b..3adfc027 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -34,7 +34,11 @@ DirectoryDeleteError, ) from basic_memory.services import BaseService, FileService -from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError +from basic_memory.services.exceptions import ( + EntityAlreadyExistsError, + EntityCreationError, + EntityNotFoundError, +) from basic_memory.services.link_resolver import LinkResolver from basic_memory.services.search_service import SearchService from basic_memory.utils import generate_permalink @@ -216,7 +220,7 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel: file_path = Path(schema.file_path) if await self.file_service.exists(file_path): - raise EntityCreationError( + raise EntityAlreadyExistsError( f"file for entity {schema.directory}/{schema.title} already exists: {file_path}" ) @@ -356,7 +360,7 @@ async def fast_write_entity( file_path = Path(existing.file_path) if existing else Path(schema.file_path) if not existing and await self.file_service.exists(file_path): - raise EntityCreationError( + raise EntityAlreadyExistsError( f"file for entity {schema.directory}/{schema.title} already exists: {file_path}" ) diff --git a/src/basic_memory/services/exceptions.py b/src/basic_memory/services/exceptions.py index ac5cc611..3082fd81 100644 --- a/src/basic_memory/services/exceptions.py +++ b/src/basic_memory/services/exceptions.py @@ -16,6 +16,12 @@ class EntityCreationError(Exception): pass +class EntityAlreadyExistsError(EntityCreationError): + """Raised when an entity file already exists""" + + pass + + class DirectoryOperationError(Exception): """Raised when directory operations fail"""