fix(gateway): split stream_existing_run into per-method routes for unique OpenAPI operationIds (#3228)

* fix(gateway): split stream_existing_run into per-method routes for unique OpenAPI operationIds

`@router.api_route("/.../stream", methods=["GET", "POST"])` registers a
single FastAPI route that holds both methods. FastAPI's auto-generated
`operationId` is computed once per route from a single method picked out
of `route.methods`, so when OpenAPI generation iterates over every method
on that route both end up sharing the same `operationId`. That triggers
`UserWarning: Duplicate Operation ID stream_existing_run_..._stream_(get|post) for function stream_existing_run`
during `app.openapi()` and produces an invalid OpenAPI spec for SDK /
codegen consumers.

Register GET and POST as two separate routes on the same handler so each
method gets a distinct auto-generated `operationId` ("..._stream_get" and
"..._stream_post"). Behavior is otherwise unchanged: same handler, same
`require_permission` decoration, same response.

Add `tests/test_openapi_operation_ids.py` to lock in the invariant:
no duplicate-operationId warnings during spec generation, globally unique
operationIds across the spec, and distinct GET / POST operationIds on the
stream endpoint specifically. Reverted the source change locally and
confirmed all three tests fail before the fix.

* test(runtime): widen CancelledError catch in _ScriptedAgent to fix cancel-race flake

`_ScriptedAgent.astream()` previously only caught `asyncio.CancelledError`
inside the inner `if self.block_after_first_chunk:` while-loop. Cancellation
arriving during any earlier `await` in the same body
(`self.model.ainvoke`, `_write_checkpoint`, the `yield`) would propagate
without setting `controller.cancelled`, so callers waiting on
`controller.cancelled.wait(5)` after `POST /cancel` returned 204 could race
and time out.

`test_cancel_interrupt_stops_running_background_run` waits only for the
`started` event (set on the first line of `astream`) before issuing cancel,
so its race window spans all three pre-loop `await`s. On a clean `main`
checkout, stress-running the test 20× reproduces the failure 6/20
(~30%). `test_cancel_rollback_restores_pre_run_checkpoint`, which waits
for the later `checkpoint_written` event, passes 20/20 — confirming the
race lives entirely in the gap between `started.set()` and the
cancellation-aware block.

Widen the try/except to cover the entire `astream` body so any
`CancelledError` sets the controller event; the non-cancel path is
unchanged (no exception means no event set). After this change the
previously flaky test passes 50/50, the rollback test still passes 30/30,
and the full backend suite remains at 3649 passed / 19 skipped.

Test-only change — `backend/tests/test_runtime_lifecycle_e2e.py` is the
only file touched; the production cancel pipeline is unaffected.
This commit is contained in:
Lucy Shen
2026-05-28 08:20:52 +08:00
committed by GitHub
parent 3cb75887c1
commit 37451500eb
3 changed files with 104 additions and 15 deletions
+19 -14
View File
@@ -96,25 +96,30 @@ class _ScriptedAgent:
del subgraphs
self.controller.started.set()
thread_id = _thread_id_from_config(config)
human_text = _last_human_text(graph_input)
human = HumanMessage(content=human_text)
ai = await self.model.ainvoke([human], config=config)
state = {"messages": [human.model_dump(), ai.model_dump()], "title": self.title}
try:
thread_id = _thread_id_from_config(config)
human_text = _last_human_text(graph_input)
human = HumanMessage(content=human_text)
ai = await self.model.ainvoke([human], config=config)
state = {"messages": [human.model_dump(), ai.model_dump()], "title": self.title}
if self.checkpointer is not None:
await _write_checkpoint(self.checkpointer, thread_id=thread_id, state=state)
self.controller.checkpoint_written.set()
if self.checkpointer is not None:
await _write_checkpoint(self.checkpointer, thread_id=thread_id, state=state)
self.controller.checkpoint_written.set()
yield _stream_item_for_mode(stream_mode, state)
yield _stream_item_for_mode(stream_mode, state)
if self.block_after_first_chunk:
try:
if self.block_after_first_chunk:
while not self.controller.release.is_set():
await asyncio.sleep(0.05)
except asyncio.CancelledError:
self.controller.cancelled.set()
raise
except asyncio.CancelledError:
# Catch cancellation arriving anywhere in the body — including the
# `await ainvoke()` / `_write_checkpoint()` / `yield` points between
# ``started.set()`` and the original inner ``try`` — so tests that
# wait for ``cancelled`` after issuing ``POST /cancel`` no longer
# race with cancellation arriving early.
self.controller.cancelled.set()
raise
def _make_agent_factory(controller: _RunController, **agent_kwargs):