From a9a57fb711df9981d64d2e22ac404a5c76e99205 Mon Sep 17 00:00:00 2001 From: Andrew Chen <48723787+chuenchen309@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:54:15 +0800 Subject: [PATCH] fix(gateway): handle null config.configurable in _resolve_thread_id (#4301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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 * Also coerce null configurable in build_run_config, not just _resolve_thread_id --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/app/gateway/routers/runs.py | 2 +- backend/app/gateway/services.py | 4 +-- backend/tests/test_runs_api_endpoints.py | 38 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/backend/app/gateway/routers/runs.py b/backend/app/gateway/routers/runs.py index 2f09ac8ac..9cf4e701b 100644 --- a/backend/app/gateway/routers/runs.py +++ b/backend/app/gateway/routers/runs.py @@ -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()) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 48d7eb45c..ef49a424c 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -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"): diff --git a/backend/tests/test_runs_api_endpoints.py b/backend/tests/test_runs_api_endpoints.py index fbb7bdce3..12ab7f1dd 100644 --- a/backend/tests/test_runs_api_endpoints.py +++ b/backend/tests/test_runs_api_endpoints.py @@ -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"