Skip to content

fix: complete SessionError lifecycle and honor reclaim forefront - #2105

Open
Ayush7614 wants to merge 3 commits into
apify:masterfrom
Ayush7614:fix/session-error-lifecycle-and-reclaim-forefront
Open

fix: complete SessionError lifecycle and honor reclaim forefront#2105
Ayush7614 wants to merge 3 commits into
apify:masterfrom
Ayush7614:fix/session-error-lifecycle-and-reclaim-forefront

Conversation

@Ayush7614

@Ayush7614 Ayush7614 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

  • BasicCrawler SessionError path: honor error_handler replacement requests only while session rotations remain; wrap handler exceptions in UserDefinedErrorHandlerError; set RequestState.ERROR when rotations are exhausted.
  • Retries: reclaim_request now passes forefront=request.forefront so tiered-proxy priority retries stay at the front of the queue.
  • session.retire() stays on the retry/rotation path only (preserves long-lived sessions when max_session_rotations=0).

Why

error_handler could replace a request on normal failures, but its return value was discarded for SessionError. Tiered proxies set request.forefront = True on retry, but reclaim always used the default forefront=False.

Test plan

  • test_session_error_handler_can_replace_request
  • test_session_error_handler_replacement_ignored_when_rotations_exhausted
  • test_reclaim_uses_request_forefront_flag
  • Existing session rotation / error_handler tests still pass

Honor error_handler replacement requests for SessionError, retire blocked
sessions when rotations are exhausted, propagate AdaptivePlaywright static
SessionError for rotation instead of browser fallback, and reclaim retries
with request.forefront for tiered-proxy priority.

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.

🟡 Not ready to approve

The SessionError path currently leaves requests in an inconsistent lifecycle state and misses retry/error tracking in one replacement branch, which can lead to incorrect persisted request metadata and statistics.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR completes the SessionError lifecycle in BasicCrawler/AdaptivePlaywrightCrawler and ensures retry reclaiming respects request queue priority (forefront) so tiered-proxy retries stay at the front of the queue.

Changes:

  • Honor error_handler return values for SessionError, wrap handler exceptions consistently, and retire sessions when rotations are exhausted.
  • Re-raise SessionError from the adaptive crawler’s static path to trigger session rotation instead of falling through to the browser.
  • Pass forefront=request.forefront into reclaim_request so priority retries preserve queue ordering.
File summaries
File Description
src/crawlee/crawlers/_basic/_basic_crawler.py Updates retry reclaim behavior to honor forefront, and refines SessionError handling (rotation/retire + error_handler honoring).
src/crawlee/crawlers/_adaptive_playwright/_adaptive_playwright_crawler.py Ensures static-path SessionError propagates to enable session rotation rather than browser fallback with the same session.
tests/unit/crawlers/_basic/test_basic_crawler.py Adds unit tests covering SessionError error_handler replacement, session retirement on exhausted rotations, and forefront reclaim behavior.
tests/unit/crawlers/_adaptive_playwright/test_adaptive_playwright_crawler.py Adds a unit test asserting static SessionError propagation triggers session rotation and prevents browser fallback.
Review details

Suppressed comments (1)

src/crawlee/crawlers/_basic/_basic_crawler.py:1504

  • When session rotations are exhausted, the request is marked as handled without setting its final state to ERROR. This leaves failed requests in REQUEST_HANDLER state in storage, which diverges from the normal error path (where the request is set to RequestState.ERROR before marking handled).
            else:
                # Exhausted rotations: retire the blocked session so it is not reused from the pool.
                session.retire()
                await self._mark_request_as_handled(request)
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines 1474 to +1477
if not session:
raise RuntimeError('SessionError raised in a crawling context without a session') from session_error

new_request = None
Comment on lines +1484 to +1488
if new_request is not None and new_request != request:
await request_manager.add_request(new_request)
await self._mark_request_as_handled(request)
session.retire()
return

@Mantisus Mantisus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey, @Ayush7614. Thank you for your contribution!

Comment on lines +402 to +403
if isinstance(static_run.exception, SessionError):
raise static_run.exception

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The old behavior is correct here. A site can block the HTTP client even when the same session still works in a browser, for example, by requiring JavaScript execution before granting access.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reverted — static SessionError again falls through to the browser path as before.

Comment on lines +1487 to +1488
session.retire()
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Replacement with new_request should only apply in the retry branch. Please drop the early return here because it also triggers when session rotations are exhausted, so the request gets replaced instead of failed and failed_request_handler never runs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — replacement is only applied inside the _should_retry_request branch.

Comment on lines +1502 to +1503
# Exhausted rotations: retire the blocked session so it is not reused from the pool.
session.retire()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please keep session.retire() only in the _should_retry_request branch. Otherwise, it breaks the long-lived session use case when max_session_rotations=0.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — session.retire() is only called on the retry/rotation path now.

Comment on lines +334 to +357
async def test_reclaim_uses_request_forefront_flag() -> None:
"""Retries must reclaim with `request.forefront` so tiered-proxy priority retries stay at the front."""
queue = await RequestQueue.open()
reclaim_calls: list[bool] = []
original_reclaim = queue.reclaim_request

async def tracking_reclaim(request: Request, *, forefront: bool = False) -> Any:
reclaim_calls.append(forefront)
return await original_reclaim(request, forefront=forefront)

queue.reclaim_request = tracking_reclaim # type: ignore[method-assign]

crawler = BasicCrawler(request_manager=queue, max_request_retries=1)

@crawler.router.default_handler
async def handler(context: BasicCrawlingContext) -> None:
context.request.forefront = True
raise RuntimeError('retry me')

await crawler.run([Request.from_url('https://a.placeholder.com')])

assert reclaim_calls == [True]

await queue.drop()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's use the mock

Suggested change
async def test_reclaim_uses_request_forefront_flag() -> None:
"""Retries must reclaim with `request.forefront` so tiered-proxy priority retries stay at the front."""
queue = await RequestQueue.open()
reclaim_calls: list[bool] = []
original_reclaim = queue.reclaim_request
async def tracking_reclaim(request: Request, *, forefront: bool = False) -> Any:
reclaim_calls.append(forefront)
return await original_reclaim(request, forefront=forefront)
queue.reclaim_request = tracking_reclaim # type: ignore[method-assign]
crawler = BasicCrawler(request_manager=queue, max_request_retries=1)
@crawler.router.default_handler
async def handler(context: BasicCrawlingContext) -> None:
context.request.forefront = True
raise RuntimeError('retry me')
await crawler.run([Request.from_url('https://a.placeholder.com')])
assert reclaim_calls == [True]
await queue.drop()
async def test_reclaim_uses_request_forefront_flag() -> None:
"""Retries must reclaim with `request.forefront` so tiered-proxy priority retries stay at the front."""
queue = await RequestQueue.open()
crawler = BasicCrawler(request_manager=queue, max_request_retries=1)
@crawler.router.default_handler
async def handler(context: BasicCrawlingContext) -> None:
context.request.forefront = True
raise RuntimeError('Arbitrary crash for testing purposes')
with patch.object(queue, 'reclaim_request', wraps=queue.reclaim_request) as reclaim_mock:
await crawler.run(['https://a.placeholder.com'])
reclaim_mock.assert_awaited_once()
(reclaimed_request,), reclaim_kwargs = reclaim_mock.await_args_list[0]
assert reclaimed_request.url == 'https://a.placeholder.com'
assert reclaim_kwargs == {'forefront': True}
await queue.drop()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated the test to use patch.object(queue, 'reclaim_request', wraps=...) as suggested.

Honor error_handler replacements only while rotations remain, keep
session.retire() on the retry path only, restore AdaptivePlaywright static
SessionError fallback to browser, and use patch.object for forefront reclaim
coverage.
@Ayush7614

Copy link
Copy Markdown
Author

Thanks for the review @Mantisus!

Addressed in 3a44e62:

  • Reverted the AdaptivePlaywright static SessionError re-raise — browser fallback with the same session is intentional when HTTP is blocked but JS may still work.
  • error_handler replacement requests are honored only inside the _should_retry_request branch, so exhausted rotations still hit failed_request_handler.
  • session.retire() is only called on the retry/rotation path (preserves max_session_rotations=0 long-lived sessions).
  • Exhausted path now sets RequestState.ERROR before marking handled.
  • Forefront reclaim test updated to use patch.object(..., wraps=...).

@Mantisus Mantisus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One nit, and please update the PR description to match the current state. Otherwise LGTM, thanks for contributing!


@crawler.error_handler
async def error_handler(context: BasicCrawlingContext, error: Exception) -> Request | None:
assert isinstance(error, SessionError)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This assert is caught by the crawler and re-raised as UserDefinedErrorHandlerError.

Assertions raised inside error_handler are wrapped as
UserDefinedErrorHandlerError, so remove the isinstance check.
@Ayush7614

Copy link
Copy Markdown
Author

Thanks @Mantisus — addressed in 20ccb2b:

  • removed the assert isinstance(error, SessionError) from the error_handler test
  • updated the PR description to match the current scope

@Mantisus
Mantisus requested a review from vdusek August 3, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants