perf(impit): optimize client and connection reuse across Sessions - #2140
perf(impit): optimize client and connection reuse across Sessions#2140Mantisus wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors ImpitHttpClient to reuse underlying impit.AsyncClient instances across sessions (per-proxy), while taking over redirect handling to process redirects hop-by-hop so per-session cookie jars can be applied and updated at each hop.
Changes:
- Moved cookie extraction / cookie header construction into
SessionCookies, and integrated it intoImpitHttpClienton each redirect hop. - Implemented WHATWG Fetch–style redirect behavior (method/body/header adjustments + cross-origin header dropping) and added a
TooManyRedirectsError. - Updated dependency constraints/lockfile and added unit tests for cookies and redirect semantics.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Bumps impit locked version to 0.13.2. |
| pyproject.toml | Raises minimum impit version to >=0.13.2. |
| src/crawlee/sessions/_cookies.py | Adds get_cookie_string() and extract_cookie_from_header() helpers on SessionCookies. |
| src/crawlee/http_clients/_impit.py | Adds hop-by-hop redirect handling, cross-origin/body-header logic, and client reuse per proxy. |
| src/crawlee/errors.py | Introduces TooManyRedirectsError for redirect limit enforcement. |
| tests/unit/sessions/test_cookies.py | Adds coverage for Set-Cookie parsing and Cookie header construction. |
| tests/unit/server.py | Adds test endpoints for redirect loops and method/body echoing. |
| tests/unit/http_clients/test_impit.py | Adds unit tests for session cookie isolation, redirect behavior, and redirect limits. |
Suppressed comments (1)
src/crawlee/http_clients/_impit.py:335
- After switching to a counted redirect loop, add an explicit guard before following the next hop so that
max_redirectstruly limits the number of redirects followed (otherwise an endless chain can still be followed one hop too far).
next_url = current_url.join(URL(location))
if next_url.scheme not in _HTTP_SCHEMES:
return response
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| The final response of the redirect chain. | ||
| """ | ||
| client = self._get_client(proxy_info.url if proxy_info else None) | ||
| current_url = URL(url) |
There was a problem hiding this comment.
Important: wrapping the URL in yarl.URL and sending str(current_url) on line 317 re-encodes it -- yarl decodes percent-escapes it considers safe, so X-Amz-Credential=AKIA%2F20240101%2F... goes on the wire as AKIA/20240101/... and presigned links start failing signature checks. This hits every request through the default client, not only redirect chains; master passed the string through verbatim.
Suggest keeping url as a str for the wire and building a yarl.URL only inside the redirect branch (for join() and _is_cross_origin).
| self._async_client_kwargs = async_client_kwargs | ||
|
|
||
| self._client_by_proxy_url = LRUCache[str | None, _ClientCacheEntry](maxsize=10) | ||
| self._client_by_proxy_url = dict[str | None, AsyncClient]() |
There was a problem hiding this comment.
Just noting, this is the same as in #2104 (comment).
I would also appreciate a short docstring here explaining its purpose and why dict is being used here (cache without an upper bound).
And we should also unify the naming of this attribute across HTTP clients.
| if session and (cookie_string := session.cookies.get_cookie_string(str(current_url))): | ||
| headers['cookie'] = cookie_string |
There was a problem hiding this comment.
Note: headers['cookie'] is assigned but never cleared, so on a hop where the jar matches nothing the previous hop's value is sent again -- a cookie stored with path=/redirect reaches /cookies after the 302. Cross-origin hops are safe (_CROSS_ORIGIN_HEADERS strips it), so this violates cookie path scoping within one origin rather than leaking across sites.
Computing per-hop headers instead of mutating the shared dict fixes it while keeping the caller-supplied Cookie header that the new tests pin.
| url=str(current_url), | ||
| content=content, | ||
| headers=headers or None, | ||
| timeout=timeout.total_seconds() if timeout else None, |
There was a problem hiding this comment.
Note: the full timeout is now applied per hop, so a chain can take up to (max_redirects + 1) x timeout. crawl() is bounded by SharedTimeout and in-handler send_request by request_handler_timeout, so the practical blast radius is small -- but it is a change from the single impit call. Worth a deliberate call on whether this should be a deadline for the whole chain.
|
|
||
|
|
||
| @docs_group('Errors') | ||
| class TooManyRedirectsError(Exception): |
There was a problem hiding this comment.
Only ImpitHttpClient raises this -- HttpxHttpClient surfaces httpx.TooManyRedirects and curl-impersonate its own -- and it appears in no public Raises: block, while crawl/send_request/stream all document ProxyError. Worth reconsideration.
| self._jar.add_cookie_header(url_request) | ||
| return url_request.get_header('Cookie', '') | ||
|
|
||
| def extract_cookie_from_header(self, url: str, set_cookie_headers: list[str]) -> None: |
There was a problem hiding this comment.
Nit: this takes a list of headers and extracts many cookies, so the plural reads more accurately. Cheap to rename now, breaking once released (the only call site is _impit.py:325).
| def extract_cookie_from_header(self, url: str, set_cookie_headers: list[str]) -> None: | |
| def extract_cookies_from_headers(self, url: str, set_cookie_headers: list[str]) -> None: |
| await send_json_response(send, headers) | ||
|
|
||
|
|
||
| async def echo_method(scope: dict[str, Any], receive: Receive, send: Send) -> None: |
There was a problem hiding this comment.
Nit: this body-reading loop is a verbatim copy of post_echo's, and both spin forever if receive() yields http.disconnect instead of http.request. A small shared read_body(receive) helper would cover both.
| assert exc_info.value.max_redirects == 2 | ||
|
|
||
|
|
||
| async def test_stream_follows_redirects(http_client: ImpitHttpClient, server_url: URL) -> None: |
There was a problem hiding this comment.
Note: the suite exercises send_request and stream, but nothing covers crawl() with a redirect chain plus session cookies -- the path the crawler actually takes. A test pinning the on-the-wire target for a URL with %2F/%3A in the query would also lock down the encoding regression flagged in _impit.py.
| assert (await read_json(response))['cookies'] == {'manual': 'value'} | ||
|
|
||
|
|
||
| async def test_session_cookies_replace_explicit_cookie_header(http_client: ImpitHttpClient, server_url: URL) -> None: |
There was a problem hiding this comment.
Question: this inverts the precedence master had -- an explicit Cookie header used to beat the impit cookie jar. Sessions are on by default, so a header hand-set on a Request is now silently dropped as soon as the jar holds anything for that domain. The new behavior looks like the right call; could it be spelled out in the PR description so it reaches the changelog?
| "cachetools>=5.5.0", | ||
| "colorama>=0.4.0", | ||
| "impit>=0.8.0", | ||
| "impit>=0.13.2", |
There was a problem hiding this comment.
Do we really need to bump the constraint to exactly this version?
Description
impittoImpitHttpClient. Clients are now reused per proxy instead of being recreated for everySession, so a single client keeps its connection pool alive across all of them. The result is higher performance than that ofHttpxHttpClient.Testing