Skip to content

Commit 2a901d1

Browse files
DeanChensjcopybara-github
authored andcommitted
chore: Raise AlreadyExistsError when trying to create a resource with same ID
Move the dedupe logic into session service so that the internal error can be surfaced to client PiperOrigin-RevId: 822294430
1 parent c850da3 commit 2a901d1

6 files changed

Lines changed: 127 additions & 124 deletions

File tree

src/google/adk/cli/adk_web_server.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from ..apps.app import App
6464
from ..artifacts.base_artifact_service import BaseArtifactService
6565
from ..auth.credential_service.base_credential_service import BaseCredentialService
66+
from ..errors.already_exists_error import AlreadyExistsError
6667
from ..errors.not_found_error import NotFoundError
6768
from ..evaluation.base_eval_service import InferenceConfig
6869
from ..evaluation.base_eval_service import InferenceRequest
@@ -583,6 +584,33 @@ def _setup_runtime_config(self, web_assets_dir: str):
583584
"Failed to write runtime config file %s: %s", runtime_config_path, e
584585
)
585586

587+
async def _create_session(
588+
self,
589+
*,
590+
app_name: str,
591+
user_id: str,
592+
session_id: Optional[str] = None,
593+
state: Optional[dict[str, Any]] = None,
594+
) -> Session:
595+
try:
596+
session = await self.session_service.create_session(
597+
app_name=app_name,
598+
user_id=user_id,
599+
state=state,
600+
session_id=session_id,
601+
)
602+
logger.info("New session created: %s", session.id)
603+
return session
604+
except AlreadyExistsError as e:
605+
raise HTTPException(
606+
status_code=409, detail=f"Session already exists: {session_id}"
607+
) from e
608+
except Exception as e:
609+
logger.error(
610+
"Internal server error during session creation: %s", e, exc_info=True
611+
)
612+
raise HTTPException(status_code=500, detail=str(e)) from e
613+
586614
def get_fast_api_app(
587615
self,
588616
lifespan: Optional[Lifespan[FastAPI]] = None,
@@ -740,20 +768,12 @@ async def create_session_with_id(
740768
session_id: str,
741769
state: Optional[dict[str, Any]] = None,
742770
) -> Session:
743-
if (
744-
await self.session_service.get_session(
745-
app_name=app_name, user_id=user_id, session_id=session_id
746-
)
747-
is not None
748-
):
749-
raise HTTPException(
750-
status_code=409, detail=f"Session already exists: {session_id}"
751-
)
752-
session = await self.session_service.create_session(
753-
app_name=app_name, user_id=user_id, state=state, session_id=session_id
771+
return await self._create_session(
772+
app_name=app_name,
773+
user_id=user_id,
774+
state=state,
775+
session_id=session_id,
754776
)
755-
logger.info("New session created: %s", session_id)
756-
return session
757777

758778
@app.post(
759779
"/apps/{app_name}/users/{user_id}/sessions",
@@ -765,18 +785,9 @@ async def create_session(
765785
req: Optional[CreateSessionRequest] = None,
766786
) -> Session:
767787
if not req:
768-
return await self.session_service.create_session(
769-
app_name=app_name, user_id=user_id
770-
)
788+
return await self._create_session(app_name=app_name, user_id=user_id)
771789

772-
if req.session_id and await self.session_service.get_session(
773-
app_name=app_name, user_id=user_id, session_id=req.session_id
774-
):
775-
raise HTTPException(
776-
status_code=409, detail=f"Session already exists: {req.session_id}"
777-
)
778-
779-
session = await self.session_service.create_session(
790+
session = await self._create_session(
780791
app_name=app_name,
781792
user_id=user_id,
782793
state=req.state,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
18+
class AlreadyExistsError(Exception):
19+
"""Represents an error that occurs when an entity already exists."""
20+
21+
def __init__(self, message="The resource already exists."):
22+
"""Initializes the AlreadyExistsError exception.
23+
24+
Args:
25+
message (str): An optional custom message to describe the error.
26+
"""
27+
self.message = message
28+
super().__init__(self.message)

src/google/adk/sessions/database_session_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from tzlocal import get_localzone
5454

5555
from . import _session_util
56+
from ..errors.already_exists_error import AlreadyExistsError
5657
from ..events.event import Event
5758
from .base_session_service import BaseSessionService
5859
from .base_session_service import GetSessionConfig
@@ -465,6 +466,12 @@ async def create_session(
465466
# 5. Return the session
466467

467468
with self.database_session_factory() as sql_session:
469+
if session_id and sql_session.get(
470+
StorageSession, (app_name, user_id, session_id)
471+
):
472+
raise AlreadyExistsError(
473+
f"Session with id {session_id} already exists."
474+
)
468475
# Fetch app and user states from storage
469476
storage_app_state = sql_session.get(StorageAppState, (app_name))
470477
if not storage_app_state:

src/google/adk/sessions/in_memory_session_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing_extensions import override
2424

2525
from . import _session_util
26+
from ..errors.already_exists_error import AlreadyExistsError
2627
from ..events.event import Event
2728
from .base_session_service import BaseSessionService
2829
from .base_session_service import GetSessionConfig
@@ -89,6 +90,10 @@ def _create_session_impl(
8990
state: Optional[dict[str, Any]] = None,
9091
session_id: Optional[str] = None,
9192
) -> Session:
93+
if session_id and self._get_session_impl(
94+
app_name=app_name, user_id=user_id, session_id=session_id
95+
):
96+
raise AlreadyExistsError(f'Session with id {session_id} already exists.')
9297
state_deltas = _session_util.extract_state_delta(state)
9398
app_state_delta = state_deltas['app']
9499
user_state_delta = state_deltas['user']

tests/unittests/cli/test_fast_api.py

Lines changed: 25 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@
3939
from google.adk.events.event import Event
4040
from google.adk.events.event_actions import EventActions
4141
from google.adk.runners import Runner
42-
from google.adk.sessions.base_session_service import ListSessionsResponse
42+
from google.adk.sessions.in_memory_session_service import InMemorySessionService
4343
from google.adk.sessions.session import Session
44+
from google.adk.sessions.state import State
4445
from google.genai import types
4546
from pydantic import BaseModel
4647
import pytest
@@ -194,98 +195,8 @@ def list_agents(self):
194195

195196
@pytest.fixture
196197
def mock_session_service():
197-
"""Create a mock session service that uses an in-memory dictionary."""
198-
199-
# In-memory database to store sessions during testing
200-
session_data = {
201-
"test_app": {
202-
"test_user": {
203-
"test_session": {
204-
"id": "test_session",
205-
"app_name": "test_app",
206-
"user_id": "test_user",
207-
"events": [],
208-
"state": {},
209-
"created_at": time.time(),
210-
}
211-
}
212-
}
213-
}
214-
215-
# Mock session service class that operates on the in-memory database
216-
class MockSessionService:
217-
218-
async def get_session(self, app_name, user_id, session_id):
219-
"""Retrieve a session by ID."""
220-
if (
221-
app_name in session_data
222-
and user_id in session_data[app_name]
223-
and session_id in session_data[app_name][user_id]
224-
):
225-
return session_data[app_name][user_id][session_id]
226-
return None
227-
228-
async def create_session(
229-
self, app_name, user_id, state=None, session_id=None
230-
):
231-
"""Create a new session."""
232-
if session_id is None:
233-
session_id = f"session_{int(time.time())}"
234-
235-
# Initialize app_name and user_id if they don't exist
236-
if app_name not in session_data:
237-
session_data[app_name] = {}
238-
if user_id not in session_data[app_name]:
239-
session_data[app_name][user_id] = {}
240-
241-
# Create the session
242-
session = {
243-
"id": session_id,
244-
"app_name": app_name,
245-
"user_id": user_id,
246-
"events": [],
247-
"state": state or {},
248-
}
249-
250-
session_data[app_name][user_id][session_id] = session
251-
return session
252-
253-
async def list_sessions(self, app_name, user_id):
254-
"""List all sessions for a user."""
255-
if app_name not in session_data or user_id not in session_data[app_name]:
256-
return {"sessions": []}
257-
258-
return ListSessionsResponse(
259-
sessions=list(session_data[app_name][user_id].values())
260-
)
261-
262-
async def delete_session(self, app_name, user_id, session_id):
263-
"""Delete a session."""
264-
if (
265-
app_name in session_data
266-
and user_id in session_data[app_name]
267-
and session_id in session_data[app_name][user_id]
268-
):
269-
del session_data[app_name][user_id][session_id]
270-
271-
async def append_event(self, session, event):
272-
"""Append an event to a session."""
273-
# Update session state if event has state_delta
274-
if event.actions and event.actions.state_delta:
275-
session["state"].update(event.actions.state_delta)
276-
277-
# Add event to session events
278-
session["events"].append(event.model_dump())
279-
280-
# Update the session in storage
281-
session_data[session["app_name"]][session["user_id"]][
282-
session["id"]
283-
] = session
284-
285-
return event
286-
287-
# Return an instance of our mock service
288-
return MockSessionService()
198+
"""Create an in-memory session service instance for testing."""
199+
return InMemorySessionService()
289200

290201

291202
@pytest.fixture
@@ -465,7 +376,7 @@ async def create_test_session(
465376
state={},
466377
)
467378

468-
logger.info(f"Created test session: {session['id']}")
379+
logger.info(f"Created test session: {session.id}")
469380
return test_session_info
470381

471382

@@ -654,6 +565,22 @@ def test_create_session_with_id(test_app, test_session_info):
654565
logger.info(f"Created session with ID: {data['id']}")
655566

656567

568+
def test_create_session_with_id_already_exists(test_app, test_session_info):
569+
"""Test creating a session with an ID that already exists."""
570+
session_id = "existing_session_id"
571+
url = f"/apps/{test_session_info['app_name']}/users/{test_session_info['user_id']}/sessions/{session_id}"
572+
573+
# Create the session for the first time
574+
response = test_app.post(url, json={"state": {}})
575+
assert response.status_code == 200
576+
577+
# Attempt to create it again
578+
response = test_app.post(url, json={"state": {}})
579+
assert response.status_code == 409
580+
assert "Session already exists" in response.json()["detail"]
581+
logger.info("Verified 409 on duplicate session creation.")
582+
583+
657584
def test_create_session_without_id(test_app, test_session_info):
658585
"""Test creating a session with a generated ID."""
659586
url = f"/apps/{test_session_info['app_name']}/users/{test_session_info['user_id']}/sessions"
@@ -753,9 +680,7 @@ def test_update_session(test_app, create_test_session):
753680
state_patch_events = [
754681
event
755682
for event in events
756-
if (
757-
event.get("invocationId") or event.get("invocation_id", "")
758-
).startswith("p-")
683+
if event.get("invocationId", "").startswith("p-")
759684
]
760685

761686
assert len(state_patch_events) == 1, (
@@ -766,9 +691,9 @@ def test_update_session(test_app, create_test_session):
766691
assert state_patch_event["author"] == "user"
767692

768693
# Check for actions in both camelCase and snake_case
769-
actions = state_patch_event.get("actions") or state_patch_event.get("actions")
694+
actions = state_patch_event.get("actions")
770695
assert actions is not None, f"No actions found in event: {state_patch_event}"
771-
state_delta_in_event = actions.get("state_delta") or actions.get("stateDelta")
696+
state_delta_in_event = actions.get("stateDelta")
772697
assert state_delta_in_event == state_delta
773698

774699
logger.info("Session state patched successfully")
@@ -818,7 +743,7 @@ def test_agent_run(test_app, create_test_session):
818743
)
819744

820745
# Third event should have interrupted flag
821-
assert data[2]["interrupted"] == True
746+
assert data[2]["interrupted"] is True
822747

823748
logger.info("Agent run test completed successfully")
824749

tests/unittests/sessions/test_session_service.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from datetime import timezone
1717
import enum
1818

19+
from google.adk.errors.already_exists_error import AlreadyExistsError
1920
from google.adk.events.event import Event
2021
from google.adk.events.event_actions import EventActions
2122
from google.adk.sessions.base_session_service import GetSessionConfig
@@ -336,6 +337,32 @@ async def test_get_session_respects_user_id(service_type):
336337
assert len(session2_got.events) == 0
337338

338339

340+
@pytest.mark.asyncio
341+
@pytest.mark.parametrize(
342+
'service_type', [SessionServiceType.IN_MEMORY, SessionServiceType.DATABASE]
343+
)
344+
async def test_create_session_with_existing_id_raises_error(service_type):
345+
session_service = get_session_service(service_type)
346+
app_name = 'my_app'
347+
user_id = 'test_user'
348+
session_id = 'existing_session'
349+
350+
# Create the first session
351+
await session_service.create_session(
352+
app_name=app_name,
353+
user_id=user_id,
354+
session_id=session_id,
355+
)
356+
357+
# Attempt to create a session with the same ID
358+
with pytest.raises(AlreadyExistsError):
359+
await session_service.create_session(
360+
app_name=app_name,
361+
user_id=user_id,
362+
session_id=session_id,
363+
)
364+
365+
339366
@pytest.mark.asyncio
340367
@pytest.mark.parametrize(
341368
'service_type', [SessionServiceType.IN_MEMORY, SessionServiceType.DATABASE]

0 commit comments

Comments
 (0)