fix(gateway): handle null config.configurable in _resolve_thread_id (#4301)

* fix(gateway): handle null config.configurable in _resolve_thread_id

`_resolve_thread_id` read the thread id with
`(body.config or {}).get("configurable", {}).get("thread_id")`. The
`.get("configurable", {})` fallback only applies when the key is absent.
`RunCreateRequest.config` is typed `dict[str, Any] | None`, so a client
can send `{"config": {"configurable": null}}`; the key is then present
with value `None`, and `None.get("thread_id")` raises `AttributeError` —
an unhandled HTTP 500 on `POST /api/runs/stream` and `/api/runs/wait`.

Coalesce the inner value with `or {}` so a null `configurable` yields a
freshly generated thread id, matching the docstring ("or generate a new
one") and the behaviour of `config: null` / `configurable: {}`. The
sibling `_checkpoint_configurable` in thread_runs.py already guards the
same field defensively. Behaviour is byte-for-byte identical for every
input that worked before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: ruff format the new test (lint-backend runs ruff format --check)

Collapse the multi-line assert ruff format flags; behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Also coerce null configurable in build_run_config, not just _resolve_thread_id

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Chen
2026-07-20 07:54:15 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent de024646a7
commit a9a57fb711
3 changed files with 41 additions and 3 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ router = APIRouter(prefix="/api/runs", tags=["runs"])
def _resolve_thread_id(body: RunCreateRequest) -> str:
"""Return the thread_id from the request body, or generate a new one."""
thread_id = (body.config or {}).get("configurable", {}).get("thread_id")
thread_id = ((body.config or {}).get("configurable") or {}).get("thread_id")
if thread_id:
return str(thread_id)
return str(uuid.uuid4())
+2 -2
View File
@@ -466,7 +466,7 @@ def build_run_config(
logger.warning(
"build_run_config: client sent both 'context' and 'configurable'; preferring 'context' (LangGraph >= 0.6.0). thread_id=%s, caller_configurable keys=%s",
thread_id,
list(request_config.get("configurable", {}).keys()),
list((request_config.get("configurable") or {}).keys()),
)
context_value = request_config["context"]
if context_value is None:
@@ -493,7 +493,7 @@ def build_run_config(
config["configurable"] = {"thread_id": thread_id}
else:
configurable = {"thread_id": thread_id}
configurable.update(request_config.get("configurable", {}))
configurable.update(request_config.get("configurable") or {})
config["configurable"] = configurable
for k, v in request_config.items():
if k not in ("configurable", "context"):
+38
View File
@@ -304,3 +304,41 @@ class TestRunFeedback:
with TestClient(app) as client:
response = client.get("/api/runs/run-fb-3/feedback")
assert response.status_code == 503
def test_resolve_thread_id_handles_null_configurable():
"""A client may send ``config.configurable`` as JSON ``null``.
The key is then present with value ``None``, so the old
``.get("configurable", {}).get("thread_id")`` raised ``AttributeError``
(an unhandled HTTP 500). Per the docstring it should generate a new id.
"""
import uuid
from app.gateway.routers.thread_runs import RunCreateRequest
tid = runs._resolve_thread_id(RunCreateRequest(config={"configurable": None}))
uuid.UUID(tid) # a freshly generated id, not a crash
# working inputs are unaffected
assert runs._resolve_thread_id(RunCreateRequest(config={"configurable": {"thread_id": "t1"}})) == "t1"
def test_build_run_config_handles_null_configurable():
"""A null ``configurable`` must also survive ``build_run_config``.
``_resolve_thread_id`` is not the only place that reads it: ``build_run_config``
does ``configurable.update(request_config.get("configurable", {}))`` and, in the
``context`` branch, ``request_config.get("configurable", {}).keys()``. With the
key present and ``None``, ``.get(..., {})`` returns ``None``, so both raised
(``dict.update(None)`` / ``None.keys()``) -- an unhandled HTTP 500 that the
isolated ``_resolve_thread_id`` test could not catch.
"""
from app.gateway.services import build_run_config
config = build_run_config("t1", {"configurable": None}, None)
assert config["configurable"]["thread_id"] == "t1"
# the context branch logs the caller's configurable keys; a null value must not crash
config = build_run_config("t1", {"context": {}, "configurable": None}, None)
assert config["configurable"]["thread_id"] == "t1"