From 1bc78d9cffc9e7bc28c16847e931a3762755b75e Mon Sep 17 00:00:00 2001 From: devthedevil <41816786+devthedevil@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:22:43 -0500 Subject: [PATCH] fix: handle GraphRecursionError as a graceful answer, not a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the supervisor keeps routing to a specialist without ever picking FINISH (a genuinely reachable case — e.g. a question spanning many domains, or the LLM bouncing indecisively between specialists), langgraph raises GraphRecursionError once RECURSION_LIMIT is hit. The /api/chat handler's blanket `except Exception` turned this into an opaque 500 whose detail message leaks internal LangGraph wording ("recursion_limit config key", a docs URL) straight to the frontend's error toast. Catch GraphRecursionError specifically and return it as a normal ChatResponse (200) with a clear, actionable message instead — this is an expected, handled outcome of the hop budget, not a server fault. Verified locally that looping topology genuinely raises GraphRecursionError (not some other exception type) before writing the fix. Added test_chat_handles_recursion_limit_gracefully, which stubs the graph to raise it and asserts a 200 with the friendly message. --- agentstore/server.py | 19 +++++++++++++++++++ tests/test_server.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/agentstore/server.py b/agentstore/server.py index 7228a3d..4a82542 100644 --- a/agentstore/server.py +++ b/agentstore/server.py @@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from langchain_core.messages import AIMessage, HumanMessage +from langgraph.errors import GraphRecursionError from pydantic import BaseModel, Field from .config import get_settings @@ -62,6 +63,13 @@ def health() -> dict[str, str]: return {"status": "ok"} +RECURSION_LIMIT_MESSAGE = ( + "This question needed more back-and-forth between specialists than we allow per " + "request. Try asking a narrower question, or split it into separate questions — " + "one per topic (sales, inventory, reviews, customer)." +) + + @app.post("/api/chat", response_model=ChatResponse) def chat(req: ChatRequest) -> ChatResponse: start = time.perf_counter() @@ -70,6 +78,17 @@ def chat(req: ChatRequest) -> ChatResponse: {"messages": [HumanMessage(content=req.query)]}, config={"recursion_limit": RECURSION_LIMIT}, ) + except GraphRecursionError: + # Not a server error: the supervisor/worker loop hit its hop budget + # without reaching FINISH (e.g. a question spanning many domains, or + # the LLM bouncing indecisively between specialists). Surface it as a + # normal answer so the UI renders a helpful chat bubble instead of a + # red error toast with an internal LangGraph error message. + return ChatResponse( + answer=RECURSION_LIMIT_MESSAGE, + agent="supervisor", + latency_ms=int((time.perf_counter() - start) * 1000), + ) except Exception as exc: # noqa: BLE001 — bubble up as 500 with a short msg raise HTTPException(status_code=500, detail=f"graph error: {exc}") from exc diff --git a/tests/test_server.py b/tests/test_server.py index 629ec6f..79fd951 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,6 +9,7 @@ from fastapi.testclient import TestClient from langchain_core.messages import AIMessage +from langgraph.errors import GraphRecursionError from agentstore.server import app @@ -43,3 +44,23 @@ def test_chat_rejects_empty_query(): with TestClient(app) as client: r = client.post("/api/chat", json={"query": ""}) assert r.status_code == 422 + + +def test_chat_handles_recursion_limit_gracefully(): + """A supervisor/worker loop that never reaches FINISH within + RECURSION_LIMIT should surface as a normal answer, not an opaque 500.""" + + class _StubGraph: + def invoke(self, state, config=None): + raise GraphRecursionError( + "Recursion limit of 8 reached without hitting a stop condition." + ) + + with patch("agentstore.server.get_graph", return_value=_StubGraph()): + with TestClient(app) as client: + r = client.post("/api/chat", json={"query": "a very complex multi-domain question"}) + assert r.status_code == 200 + body = r.json() + assert body["agent"] == "supervisor" + assert "narrower" in body["answer"] + assert isinstance(body["latency_ms"], int)