Skip to content

Commit e971279

Browse files
authored
fix(crawlers): Keep Request mutations across handler runs and retries (#2076)
### Description - This PR ensures that mutations of `user_data` and `headers` in a `Request` persist across request handler runs and retries. The behavior matches Crawlee for JS. - In `AdaptivePlaywrightCrawler`, both the static and the browser runs affect the final request state. ### Issues - Closes: #2061 ### Testing - The tests have been updated to check that all mutations have been saved.
1 parent c941ea1 commit e971279

6 files changed

Lines changed: 80 additions & 78 deletions

File tree

src/crawlee/_types.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import dataclasses
44
from collections.abc import Callable, Iterator, Mapping
5-
from copy import deepcopy
65
from dataclasses import dataclass
76
from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypedDict, TypeVar, cast, overload
87

@@ -266,20 +265,12 @@ def __init__(
266265
self,
267266
*,
268267
key_value_store_getter: GetKeyValueStoreFunction,
269-
request: Request,
270268
) -> None:
271269
self._key_value_store_getter = key_value_store_getter
272270
self.add_requests_calls = list[AddRequestsKwargs]()
273271
self.push_data_calls = list[PushDataFunctionCall]()
274272
self.key_value_store_changes = dict[tuple[str | None, str | None, str | None], KeyValueStoreChangeRecords]()
275273

276-
# Isolated copies for handler execution
277-
self._request = deepcopy(request)
278-
279-
@property
280-
def request(self) -> Request:
281-
return self._request
282-
283274
async def add_requests(
284275
self,
285276
requests: Sequence[str | Request],
@@ -329,14 +320,6 @@ async def get_key_value_store(
329320

330321
return self.key_value_store_changes[id, name, alias]
331322

332-
def apply_request_changes(self, target: Request) -> None:
333-
"""Apply tracked changes from handler copy to original request."""
334-
if self.request.user_data != target.user_data:
335-
target.user_data = self.request.user_data
336-
337-
if self.request.headers != target.headers:
338-
target.headers = self.request.headers
339-
340323

341324
@docs_group('Functions')
342325
class AddRequestsFunction(Protocol):

src/crawlee/crawlers/_adaptive_playwright/_adaptive_playwright_crawler.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ async def _crawl_one(
293293
294294
`SubCrawlerRun` contains either result of the crawl or the exception that was thrown during the crawl.
295295
Sub crawler pipeline call is dynamically created based on the `rendering_type`.
296-
New copy-like context is created from passed `context` and `state` and is passed to sub crawler pipeline.
296+
A new context is created from passed `context` and `state` and is passed to sub crawler pipeline. The original
297+
`request` is shared, so its mutations persist. Only `result` and `use_state` are isolated per sub crawler.
297298
"""
298299
if state is not None:
299300

@@ -306,13 +307,13 @@ async def get_input_state(
306307
else:
307308
use_state_function = context.use_state
308309

309-
# New result is created and injected to newly created context. This is done to ensure isolation of sub crawlers.
310+
# A fresh result is injected into the new context to isolate `add_requests`/`push_data`/`kvs` calls between
311+
# sub crawlers. The `request` itself is shared, so mutations to it persist.
310312
result = RequestHandlerRunResult(
311313
key_value_store_getter=self.get_key_value_store,
312-
request=context.request,
313314
)
314315
context_linked_to_result = BasicCrawlingContext(
315-
request=result.request,
316+
request=context.request,
316317
session=context.session,
317318
proxy_info=context.proxy_info,
318319
send_request=context.send_request,

src/crawlee/crawlers/_basic/_basic_crawler.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@
7272
from crawlee.storages import Dataset, KeyValueStore, RequestQueue
7373

7474
from ._context_pipeline import ContextPipeline
75-
from ._context_utils import swapped_context
7675
from ._logging_utils import (
7776
get_one_line_error_summary_if_possible,
7877
reduce_asyncio_timeout_error_to_relevant_traceback_parts,
@@ -1346,8 +1345,6 @@ async def _commit_request_handler_result(self, context: BasicCrawlingContext) ->
13461345

13471346
await self._commit_key_value_store_changes(result, get_kvs=self.get_key_value_store)
13481347

1349-
result.apply_request_changes(target=context.request)
1350-
13511348
@staticmethod
13521349
async def _commit_key_value_store_changes(
13531350
result: RequestHandlerRunResult, get_kvs: GetKeyValueStoreFromRequestHandlerFunction
@@ -1413,12 +1410,12 @@ async def __run_task_function(self) -> None:
14131410
else:
14141411
session = await self._get_session()
14151412
proxy_info = await self._get_proxy_info(request, session)
1416-
result = RequestHandlerRunResult(key_value_store_getter=self.get_key_value_store, request=request)
1413+
result = RequestHandlerRunResult(key_value_store_getter=self.get_key_value_store)
14171414

14181415
deferred_cleanup: list[Callable[[], Awaitable[None]]] = []
14191416

14201417
context = BasicCrawlingContext(
1421-
request=result.request,
1418+
request=request,
14221419
session=session,
14231420
proxy_info=proxy_info,
14241421
send_request=self._prepare_send_request_function(session, proxy_info),
@@ -1437,9 +1434,8 @@ async def __run_task_function(self) -> None:
14371434
request.state = RequestState.REQUEST_HANDLER
14381435

14391436
try:
1440-
with swapped_context(context, request):
1441-
self._check_request_collision(request, session)
1442-
await self._run_request_handler(context=context)
1437+
self._check_request_collision(request, session)
1438+
await self._run_request_handler(context=context)
14431439
except asyncio.TimeoutError as e:
14441440
raise RequestHandlerError(e, context) from e
14451441

src/crawlee/crawlers/_basic/_context_utils.py

Lines changed: 0 additions & 24 deletions
This file was deleted.

tests/unit/crawlers/_adaptive_playwright/test_adaptive_playwright_crawler.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,9 @@ async def pre_nav_hook(context: AdaptivePlaywrightPreNavCrawlingContext) -> None
267267
context.request.user_data['data'] = 'bs'
268268

269269
await crawler.run(test_urls[:1])
270-
# Check that repeated pre nav hook invocations do not influence each other while probing
271-
assert user_data_in_pre_nav_hook == [None, None]
270+
# First probe starts from the original user data. The second probe runs on the same request, so it
271+
# sees the mutation from the first one.
272+
assert user_data_in_pre_nav_hook == [None, 'pw']
272273
# Check that the request handler sees changes to user data done by pre nav hooks
273274
assert user_data_in_handler == ['pw', 'bs']
274275

@@ -331,8 +332,9 @@ async def post_nav_hook(context: AdaptivePlaywrightPostNavCrawlingContext) -> No
331332
context.request.user_data['data'] = 'bs'
332333

333334
await crawler.run(test_urls[:1])
334-
# Check that repeated post nav hook invocations do not influence each other while probing
335-
assert user_data_in_post_nav_hook == [None, None]
335+
# First probe starts from the original user data. The second probe runs on the same request, so it
336+
# sees the mutation from the first one.
337+
assert user_data_in_post_nav_hook == [None, 'pw']
336338
# Check that the request handler sees changes to user data done by post nav hooks
337339
assert user_data_in_handler == ['pw', 'bs']
338340

@@ -816,7 +818,7 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
816818

817819

818820
@pytest.mark.parametrize(
819-
'test_input',
821+
('test_input', 'expected_request_state'),
820822
[
821823
pytest.param(
822824
TestInput(
@@ -825,6 +827,7 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
825827
rendering_types=cycle(['static']),
826828
detection_probability_recommendation=cycle([0]),
827829
),
830+
['initial', 'static'],
828831
id='Static only',
829832
),
830833
pytest.param(
@@ -834,6 +837,7 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
834837
rendering_types=cycle(['client only']),
835838
detection_probability_recommendation=cycle([0]),
836839
),
840+
['initial', 'browser'],
837841
id='Client only',
838842
),
839843
pytest.param(
@@ -843,11 +847,16 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
843847
rendering_types=cycle(['static', 'client only']),
844848
detection_probability_recommendation=cycle([1]),
845849
),
850+
# Both sub crawlers run on the same request: browser first, then the static detection probe.
851+
# Mutations from both handlers persist.
852+
['initial', 'browser', 'static'],
846853
id='Enforced rendering type detection',
847854
),
848855
],
849856
)
850-
async def test_change_context_state_after_handling(test_input: TestInput, server_url: URL) -> None:
857+
async def test_change_context_state_after_handling(
858+
test_input: TestInput, expected_request_state: list[str], server_url: URL
859+
) -> None:
851860
"""Test that context state is saved after handling the request."""
852861
predictor = _SimpleRenderingTypePredictor(
853862
rendering_types=test_input.rendering_types,
@@ -872,8 +881,15 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
872881
used_session_id = context.session.id
873882
context.session.user_data['session_state'] = True
874883

884+
try:
885+
# `page` is only available in the browser sub crawler, static crawling raises here.
886+
_ = context.page
887+
handler_type = 'browser'
888+
except AdaptiveContextError:
889+
handler_type = 'static'
890+
875891
if isinstance(context.request.user_data['request_state'], list):
876-
context.request.user_data['request_state'].append('handler')
892+
context.request.user_data['request_state'].append(handler_type)
877893

878894
request = Request.from_url(str(server_url), user_data={'request_state': ['initial']})
879895

@@ -888,8 +904,8 @@ async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
888904
assert check_request is not None
889905

890906
assert session.user_data.get('session_state') is True
891-
# Check that request user data was updated in the handler and only onse.
892-
assert check_request.user_data.get('request_state') == ['initial', 'handler']
907+
908+
assert check_request.user_data.get('request_state') == expected_request_state
893909

894910
await request_queue.drop()
895911

tests/unit/crawlers/_basic/test_basic_crawler.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,27 +2111,57 @@ async def handler(_: BasicCrawlingContext) -> None:
21112111
await crawler_task
21122112

21132113

2114-
async def test_protect_request_in_run_handlers() -> None:
2115-
"""Test that request in crawling context are protected in run handlers."""
2114+
async def test_request_and_session_mutations_persist() -> None:
2115+
"""Test that request and session mutated in the request handler and error handler, and mutations are persisted."""
21162116
request_queue = await RequestQueue.open(name='state-test')
21172117

2118-
request = Request.from_url('https://test.url/', user_data={'request_state': ['initial']})
2119-
2120-
crawler = BasicCrawler(request_manager=request_queue, max_request_retries=0)
2121-
2122-
@crawler.router.default_handler
2123-
async def handler(context: BasicCrawlingContext) -> None:
2124-
if isinstance(context.request.user_data['request_state'], list):
2125-
context.request.user_data['request_state'].append('modified')
2126-
raise ValueError('Simulated error after modifying request')
2118+
async with SessionPool(max_pool_size=1) as session_pool:
2119+
session = await session_pool.get_session()
2120+
session.user_data['session_state'] = ['initial']
2121+
session.cookies['initial'] = 'yes'
2122+
request = Request.from_url('https://test.url/', user_data={'request_state': ['initial']}, session_id=session.id)
2123+
2124+
crawler = BasicCrawler(
2125+
request_manager=request_queue,
2126+
max_request_retries=1,
2127+
concurrency_settings=ConcurrencySettings(max_concurrency=1, desired_concurrency=1),
2128+
session_pool=session_pool,
2129+
)
21272130

2128-
await crawler.run([request])
2131+
@crawler.error_handler
2132+
async def error_handler(context: BasicCrawlingContext, error: Exception) -> Request | None:
2133+
if isinstance(context.request.user_data['request_state'], list):
2134+
context.request.user_data['request_state'].append('error')
21292135

2130-
check_request = await request_queue.get_request(request.unique_key)
2131-
assert check_request is not None
2132-
assert check_request.user_data['request_state'] == ['initial']
2136+
if context.session and isinstance(context.session.user_data['session_state'], list):
2137+
context.session.user_data['session_state'].append('error')
2138+
context.session.cookies['error'] = 'yes'
21332139

2134-
await request_queue.drop()
2140+
@crawler.router.default_handler
2141+
async def handler(context: BasicCrawlingContext) -> None:
2142+
if isinstance(context.request.user_data['request_state'], list):
2143+
context.request.user_data['request_state'].append(f'modified_{context.request.retry_count}')
2144+
if context.session and isinstance(context.session.user_data['session_state'], list):
2145+
context.session.user_data['session_state'].append(f'modified_{context.request.retry_count}')
2146+
context.session.cookies[f'modified_{context.request.retry_count}'] = 'yes'
2147+
2148+
if context.request.retry_count == 0:
2149+
raise ValueError('Simulated error after modifying request')
2150+
2151+
await crawler.run([request])
2152+
2153+
check_request = await request_queue.get_request(request.unique_key)
2154+
assert check_request is not None
2155+
assert check_request.user_data['request_state'] == ['initial', 'modified_0', 'error', 'modified_1']
2156+
session = await session_pool.get_session_by_id(session.id)
2157+
assert session is not None
2158+
assert session.user_data['session_state'] == ['initial', 'modified_0', 'error', 'modified_1']
2159+
assert session.cookies['initial'] == 'yes'
2160+
assert session.cookies['error'] == 'yes'
2161+
assert session.cookies['modified_0'] == 'yes'
2162+
assert session.cookies['modified_1'] == 'yes'
2163+
2164+
await request_queue.drop()
21352165

21362166

21372167
async def test_new_request_error_handler() -> None:

0 commit comments

Comments
 (0)