fix(httpx): honor session cookies across HTTP client request paths - #2104
fix(httpx): honor session cookies across HTTP client request paths#2104Ayush7614 wants to merge 7 commits into
Conversation
Httpx send_request/stream now send outbound session cookies like crawl. Impit respects persist_cookies_per_session, keys the client cache by jar identity, and closes cached clients on cleanup. Httpx fingerprint headers are generated from a single profile so Accept and User-Agent stay consistent.
There was a problem hiding this comment.
Pull request overview
This PR fixes inconsistent cookie handling across HTTP client request paths so that session cookies are reliably sent (and optionally persisted) whether requests go through crawler navigation (crawl) or handler-level calls (send_request/stream). It also makes Httpx’s fingerprint-derived headers internally consistent by sourcing Accept, Accept-Language, and User-Agent from a single generated fingerprint profile.
Changes:
- Httpx:
send_request/streamnow attach outbound session cookies via the shared request-building path (matchingcrawlbehavior). - Impit: implements
persist_cookies_per_sessionsemantics, caches clients by(proxy, cookie-jar identity), and adds client closing on cleanup/eviction. - Tests: adds coverage for cookie sending/persistence toggles, single-fingerprint headers, and Impit cleanup cache reset.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/unit/http_clients/test_http_clients.py | Adds unit coverage for session cookie sending in send_request/stream, persistence on/off behavior, single-fingerprint headers, and Impit cache cleanup. |
| src/crawlee/http_clients/_impit.py | Honors cookie persistence flag by using a resolved jar (shared vs copy), introduces client caching keyed by proxy + cookie jar identity, and closes cached clients on cleanup/eviction. |
| src/crawlee/http_clients/_httpx.py | Ensures handler-level requests attach session cookies and derives Accept/Accept-Language/User-Agent from one fingerprint profile. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Mantisus
left a comment
There was a problem hiding this comment.
Thank you for your contributions!
When persist_cookies_per_session is False, _resolve_cookie_jar builds a fresh jar for every request, and since the cache key includes the jar identity, that means a new AsyncClient per request. Consider building the Cookie header directly instead of passing a CookieJar to AsyncClient, then the client can stay cached and shared.
For example:
import urllib.request
from http.cookiejar import CookieJar
def get_cookie_header(jar: CookieJar, url: str, headers: HttpHeaders | None = None) -> str:
request = urllib.request.Request(url, headers=dict(headers) if headers else {})
jar.add_cookie_header(request)
return request.get_header('Cookie', '')Send cookies via Cookie header when persist_cookies_per_session is False so clients stay cached, and use LRUCache.popitem for eviction without fire-and-forget close tasks.
|
Thanks for the review @Mantisus! Addressed in
|
Mantisus
left a comment
There was a problem hiding this comment.
Thanks for the update, just a few more points
Simplify cookie header merge, drop redundant LRU popitem, and move client-specific Httpx/Impit tests into dedicated test modules.
|
Thanks @Mantisus — addressed in
|
Mantisus
left a comment
There was a problem hiding this comment.
Please update the PR description to match the current state, otherwise LGTM
|
Updated the PR description to match the current implementation. Thanks @Mantisus! |
Key httpx clients by session jar so cookies survive redirects, reuse _build_request from crawl, simplify Impit LRU keying by jar identity, deprecate unused HeaderGenerator helpers, and cover redirect plus persist=False cookie-header paths in tests.
Mocking get_specific_headers could not catch a regression that mixes Accept and User-Agent from separate fingerprint generations.
|
Thanks @vdusek Addressed in d4f5fae / 6aaadc4:
|
Per-session client caching pinned jars and grew without bound. Re-apply cookies in the transport on each hop (including redirects) instead so one AsyncClient per proxy stays reusable across sessions.
`async with HttpxHttpClient() as client` widens to HttpClient, so ty rejected access to `_client_by_proxy_url`.
|
Type-check failures on the previous run came from Fixed in bf9a8d0 by binding the |
|
cc: @vdusek |
| headers=dict(headers) if headers else None, | ||
| content=payload, | ||
| extensions={'crawlee_session': session if self._persist_cookies_per_session else None}, | ||
| cookies=session.cookies.jar if session else None, |
There was a problem hiding this comment.
With this new approach that uses transport, cookies=session.cookies.jar doesn't make sense
Also, the session is already being passed to the transport. Use the session's jar no need to pass it separately.
| self._client_by_proxy_url = LRUCache[str | None, _ClientCacheEntry](maxsize=10) | ||
| self._client_cache = LRUCache[tuple[str | None, CookieJar | None], AsyncClient](maxsize=10) |
There was a problem hiding this comment.
Why rename it? All HTTP clients name this attribute _client_by_proxy_url, no?
| session.cookies.set(f'k{i}', f'v{i}', domain=host, path='/') | ||
| await client.send_request(str(server_url / 'cookies'), session=session) | ||
|
|
||
| assert len(client._client_by_proxy_url) == 1 |
There was a problem hiding this comment.
Note: the load-bearing new invariant is that one client per proxy is shared across sessions, so session A's cookies must never reach session B. This asserts the sharing but not the isolation. Worth adding: crawl set_cookies with session A, then request /cookies with session B and with no session, asserting {} for both.
|
|
||
|
|
||
| async def test_client_cache_is_shared_across_sessions(server_url: URL) -> None: | ||
| """Distinct sessions must reuse one AsyncClient per proxy, not one client per cookie jar.""" |
There was a problem hiding this comment.
Suggestion: _client_by_proxy_url was already a per-proxy dict on master, so this test passes there too - the docstring implies it guards this change. Also the per-iteration cookies (k{i}=v{i}) are set but never asserted.
| if cookie_header: | ||
| request.headers['cookie'] = cookie_header | ||
| else: | ||
| request.headers.pop('cookie', None) |
There was a problem hiding this comment.
Nit: the else branch is unreachable. When a Cookie header is present, add_cookie_header short-circuits on has_header("Cookie") and get_header returns it unchanged, so the if always wins; otherwise pop is a no-op.
| if cookie_header: | |
| request.headers['cookie'] = cookie_header | |
| else: | |
| request.headers.pop('cookie', None) | |
| if cookie_header: | |
| request.headers['cookie'] = cookie_header |
| # Avoid HeaderGenerator.__init__ loading browserforge; only exercise get_specific_headers. | ||
| header_generator = HeaderGenerator.__new__(HeaderGenerator) | ||
| header_generator._generator = Mock() | ||
| header_generator._generator.generate = Mock(return_value=fingerprint) |
There was a problem hiding this comment.
The comment is not accurate - _httpx.py:124 (_DEFAULT_HEADER_GENERATOR = HeaderGenerator()) evaluates at class-body time, so importing HttpxHttpClient in this module already loads browserforge. A plain instance works and drops the __new__ bypass.
| # Avoid HeaderGenerator.__init__ loading browserforge; only exercise get_specific_headers. | |
| header_generator = HeaderGenerator.__new__(HeaderGenerator) | |
| header_generator._generator = Mock() | |
| header_generator._generator.generate = Mock(return_value=fingerprint) | |
| header_generator = HeaderGenerator() | |
| header_generator._generator = Mock(generate=Mock(return_value=fingerprint)) |
| """Retrieve or create an HTTP client for the given proxy URL and cookie jar. | ||
|
|
||
| If a client for the specified proxy URL does not exist, create and store a new one. | ||
| Clients are cached by `(proxy_url, cookie_jar)` — CookieJar hashes by identity so sessions with different |
There was a problem hiding this comment.
Not em dashes please - src/ is ASCII-only.
| Clients are cached by `(proxy_url, cookie_jar)` — CookieJar hashes by identity so sessions with different | |
| Clients are cached by `(proxy_url, cookie_jar)`: `CookieJar` hashes by identity so sessions with different |
| assert response_headers['accept-language'] == COMMON_ACCEPT_LANGUAGE | ||
|
|
||
| # By default, HTTPX uses its own User-Agent, which should be replaced by the one from the header generator. | ||
| assert 'user-agent' in response_headers |
There was a problem hiding this comment.
Why deleting this comment? It was the only explanation of why the assertion below checks for python-httpx. Also the deletion is unrelated to the fix.
| extensions={ | ||
| # Used by the transport to re-apply cookies on every hop (httpx strips Cookie on redirect). | ||
| 'crawlee_cookie_jar': session.cookies.jar if session else None, | ||
| 'crawlee_session': session if self._persist_cookies_per_session else None, | ||
| }, |
There was a problem hiding this comment.
Note: building on Mantisus' open thread here -- the transport can't just read the jar off crawlee_session, because that key is None whenever persist_cookies_per_session is False, so it doubles as both the session and the persist flag. That conflation is the only reason crawlee_cookie_jar has to exist.
Pass persist_cookies_per_session into _HttpxTransport.__init__ (the transport is already per-client, L162), send extensions={'crawlee_session': session} unconditionally, and derive the jar inside the transport -- that drops both cookies= and crawlee_cookie_jar, leaving one channel with one meaning.
| @staticmethod | ||
| def _apply_cookie_header(request: httpx.Request, jar: CookieJar) -> None: | ||
| """Set the Cookie header from a jar for the current request URL.""" | ||
| urllib_request = UrllibRequest(str(request.url), headers=dict(request.headers)) # noqa: S310 | ||
| jar.add_cookie_header(urllib_request) | ||
| cookie_header = urllib_request.get_header('Cookie') | ||
| if cookie_header: | ||
| request.headers['cookie'] = cookie_header | ||
| else: | ||
| request.headers.pop('cookie', None) |
There was a problem hiding this comment.
Suggestion: this UrllibRequest + add_cookie_header + get_header('Cookie') idiom is now hand-rolled twice -- here and in ImpitHttpClient._get_cookie_header (_impit.py:142) -- and the two copies already diverge: this one pops the header when the jar yields nothing, impit returns '' and lets the caller skip.
add_cookie_header appeared nowhere in src/ before this PR. SessionCookies is what owns cookie state, so a get_cookie_header(url, headers) there would give both clients (and curl, the third latent site) one implementation instead of three.
| if session := cast('Session | None', request.extensions.get('crawlee_session')): | ||
| session.cookies.store_cookies(list(response.cookies.jar)) | ||
|
|
||
| if 'Set-Cookie' in response.headers: |
There was a problem hiding this comment.
Note: httpx is the only client that strips this -- impit (_impit.py:50-51) and curl (_curl_impersonate.py:101-102) pass response headers through untouched. So the public HttpResponse.headers carries set-cookie under two clients and never under the third.
Pre-existing, but this PR promotes the deletion from implementation detail to stated mechanism in the new docstring, and it interacts with the semantics just given to persist_cookies_per_session=False: under httpx with persistence off, a Set-Cookie is now observable nowhere -- not in the session, not in the response -- while impit and curl users can still read it. The new cross-client cookie tests are the natural place to pin that divergence, or one line on HttpResponse.headers to document it.
Summary
send_request/stream/crawlshare_build_requestfor outbound session cookies. Clients stay shared per proxy (not per session); cookies are re-applied in_HttpxTransporton every hop so they survive redirects without pinning a client to each jar.persist_cookies_per_session. When persistence is enabled, the session jar is attached to the client; when disabled, existing cookies are sent via aCookieheader so the shared client stays cached. Clients are cached by(proxy, CookieJar)identity.Accept/Accept-Language/User-Agentcome from a single fingerprint profile.HeaderGenerator.get_common_headers/get_random_user_agent_headerare deprecated in favor ofget_specific_headers.Why
context.send_request()silently dropped session cookies under Httpx, breaking auth that worked for navigation (including after redirects). Impit's documentedpersist_cookies_per_session=Falsenever took effect because the session jar was always attached and mutated in place.Test plan
tests/unit/http_clients/test_http_clients.py— shared cookie send/persist/redirect coverage across clientstests/unit/http_clients/test_httpx.py— single-generate()fingerprint headers; proxy-only client cache across sessionstests/unit/http_clients/test_impit.py— Cookie-header path when persist is disabled