diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index 111f9610da..ade649cd33 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -90,6 +90,7 @@ _artifact_archive_slots = asyncio.Semaphore(4) _MISSING_REGENERATE_BASE_DETAIL = "Could not find an addressable checkpoint before the target user message" _UNSAFE_REGENERATE_LINEAGE_DETAIL = "Could not safely resolve the checkpoint before the target user message" THREAD_MESSAGE_LEGACY_SCAN_BATCH = 201 +MAX_EVENT_TYPE_FILTERS = 100 _LEGACY_IDEMPOTENCY_REQUEST_KEY = "idempotency_request" @@ -1850,7 +1851,15 @@ async def list_run_events( await _require_run_visible_to_scope(run_id, thread_id, request) user_id = await _run_scope_user_id(request, thread_id) event_store = get_run_event_store(request) - types = event_types.split(",") if event_types else None + if event_types: + types = event_types.split(",") + if len(types) > MAX_EVENT_TYPE_FILTERS: + raise HTTPException( + status_code=422, + detail=f"event_types accepts at most {MAX_EVENT_TYPE_FILTERS} values", + ) + else: + types = None events = await event_store.list_events( thread_id, run_id, diff --git a/backend/tests/test_run_events_endpoint.py b/backend/tests/test_run_events_endpoint.py index 3afa4e32e1..416f09cedd 100644 --- a/backend/tests/test_run_events_endpoint.py +++ b/backend/tests/test_run_events_endpoint.py @@ -196,3 +196,60 @@ async def test_effective_memory_flows_from_injection_to_the_existing_debug_api() "project_context_revision": None, "project_shelf_revision": None, } + + +@pytest.mark.parametrize("count", [100, 101]) +@pytest.mark.anyio +async def test_list_run_events_bounds_event_type_filter_cardinality(count: int) -> None: + from fastapi import HTTPException + + from app.gateway.routers.thread_runs import MAX_EVENT_TYPE_FILTERS, list_run_events + + calls: dict = {} + + class FakeStore: + async def list_events( + self, + thread_id, + run_id, + *, + event_types=None, + task_id=None, + limit=500, + after_seq=None, + user_id=None, + ): + calls["event_types"] = event_types + return [] + + class FakeState: + run_event_store = FakeStore() + + class FakeApp: + state = FakeState() + + class FakeRequest: + app = FakeApp() + _deerflow_test_bypass_auth = True + cookies: dict = {} + + event_types = ",".join(f"type-{i}" for i in range(count)) + kwargs = { + "thread_id": "t1", + "run_id": "r1", + "request": FakeRequest(), + "event_types": event_types, + "task_id": None, + "limit": 500, + "after_seq": None, + } + + if count > MAX_EVENT_TYPE_FILTERS: + with pytest.raises(HTTPException) as exc_info: + await list_run_events(**kwargs) + assert exc_info.value.status_code == 422 + assert str(MAX_EVENT_TYPE_FILTERS) in exc_info.value.detail + assert calls == {} + else: + assert await list_run_events(**kwargs) == [] + assert len(calls["event_types"]) == MAX_EVENT_TYPE_FILTERS