Skip to content

fix: stop ConditionalRouter and BranchJoiner from_dict from mutating the caller's data - #12935

Merged
julian-risch merged 4 commits into
deepset-ai:mainfrom
simpleqt:fix/from_dict-mutates-caller-data
Sep 29, 2026
Merged

julian-risch merged 4 commits into
deepset-ai:mainfrom
simpleqt:fix/from_dict-mutates-caller-data

Conversation

@simpleqt

Copy link
Copy Markdown
Contributor

Problem

ConditionalRouter.from_dict and BranchJoiner.from_dict write deserialization results back into the caller's dict:

routes = init_params.get("routes")
for route in routes:
    route["output_type"] = deserialize_type(route["output_type"])   # mutates caller's route dicts
...
init_params["custom_filters"][name] = deserialize_callable(filter_func)   # mutates caller's dict
data["init_parameters"]["type_"] = deserialize_type(data["init_parameters"]["type_"])   # BranchJoiner

The core framework already established this is harmful — haystack/core/serialization.py deliberately copies init_parameters first, with a comment: "Copy so that replacing serialized sub-objects ... does not mutate the caller's data dict in place. Without this, a second deserialization of the same dict would receive already-parsed objects." These two components bypass that protection.

Consequence — loading the same serialized dict twice (a common "load pipeline definition once, rebuild components per request" pattern) crashes:

d = router.to_dict()
ConditionalRouter.from_dict(d)
ConditionalRouter.from_dict(d)
# TypeError: 'type' object is not iterable        (ConditionalRouter)
BranchJoiner.from_dict(bd); BranchJoiner.from_dict(bd)
# TypeError: can only concatenate str (not "types.GenericAlias") to str   (BranchJoiner)

and after the first from_dict the serialized data is corrupted (d["init_parameters"]["routes"][0]["output_type"] is a type object, not the serialized string).

Fix

Apply the same copy discipline the core serializer uses, in both components: shallow-copy init_parameters (and the route dicts / custom filters map) before replacing serialized values with parsed ones, and pass the copy to default_from_dict. The caller's dict is left untouched and repeated deserialization works.

Test plan

  • TestBranchJoinerDeserialization::test_from_dict_does_not_mutate_caller_data
  • TestConditionalRouterDeserialization::test_from_dict_does_not_mutate_caller_data

Both assert the serialized dict is unchanged after from_dict and that a second from_dict succeeds. Both fail on main and pass with this change (red/green verified). All 50 pre-existing tests in the two files still pass; ruff check + format (0.16.0) clean.

Copilot AI lite review requested due to automatic review settings September 24, 2026 15:39
@simpleqt
simpleqt requested a review from a team as a code owner September 24, 2026 15:39
@simpleqt
simpleqt requested review from davidsbatista and removed request for a team September 24, 2026 15:39
@vercel

vercel Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@simpleqt is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@davidsbatista

Copy link
Copy Markdown
Contributor

@simpleqt thanks for the contribution, can you please make the CI green, afterwards we can start a review

- from_dict: only iterate routes when present (mypy union-attr)
- test: annotate routes as list[Route] (mypy arg-type)
- add releasenotes/notes entry (reno)
@simpleqt

Copy link
Copy Markdown
Contributor Author

CI is green-able now (pushed): the for route in routes: loop is only reached when routes exist (fixes the mypy union-attr), the new test's routes are typed as list[Route] (fixes the mypy arg-type), and the missing release-notes file is added. Local: 46 router + 6 joiner tests pass, mypy clean on both touched files.

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/components/joiners
  branch.py
  haystack/components/routers
  conditional_router.py
Project Total  

This report was generated by python-coverage-comment-action

- Leave custom_filters out of the init parameters when the serialized data
  has none, instead of passing an empty dict to __init__
- Build the deserialized custom_filters as a new dict rather than copying
  and then mutating it
- Shorten the copy-rationale comments to one line
- Add the reno hash suffix to the release note filename

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the type:documentation Improvements on the docs label Sep 29, 2026

@julian-risch julian-risch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @simpleqt ! Looks good to me! I made some smaller changes and the PR is ready to be merged now.
if you're curious, reno release note had no hash suffix, when the data has no custom_filters, it passed custom_filters={} to init instead of leaving the key out, and I shortened the comments in the code.

@julian-risch
julian-risch enabled auto-merge (squash) September 29, 2026 10:44
@julian-risch
julian-risch merged commit bb5b39f into deepset-ai:main Sep 29, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

topic:tests type:documentation Improvements on the docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants