Skip to content

fix: rotate the LinkContentFetcher user agent per fetch, not per component - #12364

Merged
davidsbatista merged 3 commits into
deepset-ai:mainfrom
pcbeingused333:fix/link-content-fetcher-ua-rotation
Aug 17, 2026
Merged

fix: rotate the LinkContentFetcher user agent per fetch, not per component#12364
davidsbatista merged 3 commits into
deepset-ai:mainfrom
pcbeingused333:fix/link-content-fetcher-ua-rotation

Conversation

@pcbeingused333

Copy link
Copy Markdown
Contributor

Related Issues

Proposed Changes:

The rotation cursor lived on the component as current_user_agent_idx, but run() fetches URLs concurrently through a ThreadPoolExecutor and run_async() gathers them. Every in-flight request read and wrote that one counter, which produced two overlapping failures:

  • a retry triggered by one URL rotated the user agent for all the others, and
  • the finally: self.current_user_agent_idx = 0 in _fetch/_fetch_async reset the counter underneath the requests still running.

With several URLs retrying at once the retries mostly went out with the un-rotated user agent, so the feature silently did not do what it documents.

The fix gives each fetch its own cursor:

  • _get_response becomes a real method that builds its tenacity wrapper per call, with user_agent_idx as a local that the after callback advances via nonlocal. It used to be a closure built once in __init__ and stored on self, which is what forced the state to be shared.
  • _get_response_async keeps its own local cursor in the retry loop.
  • _get_headers now takes the user agent for the attempt instead of reading component state.
  • current_user_agent_idx and _switch_user_agent were that shared state, so they are gone. Neither is documented API, and the counter never survived serialization anyway: the component has no custom to_dict/from_dict and the default path serializes __init__ parameters, which the counter was not — a round-tripped component always came back at 0.

No behaviour change for the single-URL case, which is why this was easy to miss.

How did you test it?

test_user_agent_rotation_is_independent_per_url fetches 8 URLs with 4 user agents, fails every URL exactly once, and asserts that all 8 succeed on user_agents[1] — each URL rotated exactly once, on its own.

I checked the test is not vacuous: with haystack/components/fetchers/link_content.py reverted to main and only the test kept, it fails with every URL reporting ua-0 instead of ua-1. That is the bug itself — the retries going out un-rotated — rather than an incidental assertion mismatch.

Full file: 32 passed. hatch run test:types clean across 427 source files, hatch run fmt-check clean.

Notes for the reviewer

Two things worth a look:

  1. The tenacity wrapper is now rebuilt per call instead of once in __init__. That is deliberate — the cursor has to be per-fetch, and the wrapper closes over it — but it is the one real cost of this approach. It is a closure construction against a network round trip, so it did not seem worth optimising; happy to hoist the static parts if you would rather.

  2. _get_headers changed signature (() -> becomes (user_agent: str) -> ). It is private and has one caller per path, but flagging it in case something downstream reaches for it.

The test patches wait_exponential at module level after constructing the fetcher, which only works because the decorator is now built at call time. That is intentional coupling to the new design — under the old code there was no way to skip the backoff without patching the instance attribute.

Checklist

  • I have read the contributors guidelines and the code of conduct.
  • I have updated the related issue with new insights and changes.
  • I have added unit tests and updated the docstrings.
  • I've used one of the conventional commit types for my PR title: fix:, feat:, build:, chore:, ci:, docs:, style:, refactor:, perf:, test: and added ! in case the PR includes breaking changes.
  • I have documented my code.
  • I have added a release note file, following the contributors guidelines.
  • I have run pre-commit hooks and fixed any issue.

…onent

`run()` fetches URLs concurrently through a ThreadPoolExecutor and `run_async()`
gathers them, but the rotation cursor lived on the component as
`current_user_agent_idx`. Every in-flight request read and wrote the same
counter: a retry triggered by one URL rotated the user agent for all the others,
and each fetch that finished reset the counter to 0 underneath the requests still
running. With several URLs retrying at once, the retries mostly went out with the
un-rotated user agent — the feature silently did not do what it documents.

Give each fetch its own cursor: a local in `_get_response` that the tenacity
`after` callback advances, and a local in `_get_response_async`. `_get_headers`
now takes the user agent for the attempt instead of reading component state.

`current_user_agent_idx` and `_switch_user_agent` were that shared state and are
gone. Neither is part of the documented API, and neither survives serialization.

Fixes deepset-ai#12287

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pcbeingused333
pcbeingused333 requested a review from a team as a code owner August 16, 2026 03:19
@pcbeingused333
pcbeingused333 requested review from sjrl and removed request for a team August 16, 2026 03:19
@github-actions

Copy link
Copy Markdown
Contributor

Hi @pcbeingused333, thanks for your interest in contributing to Haystack! 🙏

⚠️ Issue #12287 is already being addressed by open pull request(s) #12289. Before opening a PR for an issue, please check whether a PR is already linked to it, and consider contributing to the existing PR instead. We may close duplicate PRs to keep the review queue manageable.

⚠️ You currently have 3 open pull requests in this repository (#12359, #12358 and this one). Our review capacity is limited, so please hold off opening more PRs until we've had a chance to review your first 2 open PRs. This helps us give each contribution the attention it deserves. Thank you!

This is an automated message to help us keep the review queue healthy.

@pcbeingused333

Copy link
Copy Markdown
Contributor Author

Thanks bot — both points are fair, and the duplicate one is on me: I should have checked the issue for a linked PR before opening this. Apologies for the noise.

On the overlap with #12289: it does address the same root cause, and @iamyuviii got there first. Two differences that may or may not matter to you:

  • This PR keeps the existing tenacity retry (the cursor becomes a local that the after callback advances) rather than replacing the sync path with a hand-rolled while loop, so the backoff behaviour is unchanged.
  • It ships a regression test and a release note. I verified the test is not vacuous — with link_content.py reverted to main it fails with every URL sending ua-0 instead of ua-1, which is the bug itself rather than an incidental assertion mismatch.

I have no attachment to whose branch lands. Happy to do whichever you prefer:

  1. Close this and open a PR against @iamyuviii's branch with just the test and the release note, which is what feat: add LinkContentFetcher component for robust URL content retriev… #12289 is missing.
  2. Leave this one as the implementation and close it the moment feat: add LinkContentFetcher component for robust URL content retriev… #12289 is unblocked.

@iamyuviii — no intention of stepping on your PR. If you'd rather take the test across yourself, please do, it's yours.

On the second warning: understood, I won't open anything further until #12358 and #12359 have been looked at.

@julian-risch
julian-risch requested review from davidsbatista and removed request for sjrl August 17, 2026 07:48
@github-actions github-actions Bot added the type:documentation Improvements on the docs label Aug 17, 2026
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
haystack-docs Ignored Ignored Preview Aug 17, 2026 8:39am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/components/fetchers
  link_content.py 499
Project Total  

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

@davidsbatista davidsbatista 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.

looks good - I've adjusted the release notes, one must use double ticks

@davidsbatista
davidsbatista merged commit d9d70c0 into deepset-ai:main Aug 17, 2026
24 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.

LinkContentFetcher's User-Agent rotation on retry is scrambled across concurrent multi-URL fetches

2 participants