Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/upgrading/upgrading_to_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,11 @@ current concurrency 3, desired 5, overloaded: cpu 58% of the time (limit 40%)
To read the concurrency values in code, use `crawler.concurrency_system`.

The autoscaling loggers moved together with the package. `crawlee._autoscaling.autoscaled_pool`, `crawlee._autoscaling.snapshotter` and `crawlee._autoscaling.system_status` are now `crawlee.autoscaling._autoscaled_pool`, `crawlee.autoscaling._snapshotter` and `crawlee.autoscaling._system_status`. The state log, along with the messages about scaling and free capacity, now comes from the new `crawlee.autoscaling._concurrency_system` logger. To configure all of them at once, use the `crawlee.autoscaling` logger.

## Session retirement is permanent

`Session.retire()` permanently retires the session. Calling `mark_good()` on a retired session leaves its error score and usage count unchanged. Create a new session to replace a retired one. Repeated calls to `retire()` leave the error score unchanged.

The `Session.retired` property indicates whether a session has been retired. Sessions that expire or reach their usage limit retire without an additional error score penalty, so `is_blocked` continues to indicate whether the error score reached its limit.

Session state includes a `retired` flag, which is preserved when the session pool is saved and restored. Saved sessions without this field load with `retired=False` and retain their existing error scores and usage limits.
1 change: 1 addition & 0 deletions src/crawlee/sessions/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class SessionModel(BaseModel):
usage_count: int
max_usage_count: int
error_score: float
retired: bool = False
cookies: list[CookieParam]
blocked_status_codes: list[int]

Expand Down
26 changes: 20 additions & 6 deletions src/crawlee/sessions/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(
usage_count: int = 0,
max_usage_count: int = 50,
error_score: float = 0.0,
retired: bool = False,
cookies: SessionCookies | CookieJar | dict[str, str] | list[CookieParam] | None = None,
blocked_status_codes: list | None = None,
) -> None:
Expand All @@ -60,6 +61,7 @@ def __init__(
usage_count: Number of times the session has been used.
max_usage_count: Maximum allowable uses of the session before it is considered expired.
error_score: Current error score of the session.
retired: Whether the session has been permanently retired.
cookies: Cookies associated with the session.
blocked_status_codes: HTTP status codes that indicate a session should be blocked.
"""
Expand All @@ -72,6 +74,7 @@ def __init__(
self._usage_count = usage_count
self._max_usage_count = max_usage_count
self._error_score = error_score
self._retired = retired
Comment thread
vdusek marked this conversation as resolved.
self._cookies = SessionCookies(cookies) or SessionCookies()
self._blocked_status_codes = set(blocked_status_codes or self._DEFAULT_BLOCKED_STATUS_CODES)

Expand Down Expand Up @@ -138,14 +141,19 @@ def usage_count(self) -> float:
"""Get the current usage count."""
return self._usage_count

@property
def retired(self) -> bool:
"""Indicate whether the session has been permanently retired."""
return self._retired

@property
def expires_at(self) -> datetime:
"""Get the expiration datetime of the session."""
return self._created_at + self._max_age

@property
def is_blocked(self) -> bool:
"""Indicate whether the session is blocked based on the error score.."""
"""Indicate whether the session is blocked by its error score."""
return self._error_score >= self._max_error_score

@property
Expand All @@ -161,7 +169,7 @@ def is_max_usage_count_reached(self) -> bool:
@property
def is_usable(self) -> bool:
"""Determine if the session is usable for next requests."""
return not (self.is_blocked or self.is_expired or self.is_max_usage_count_reached)
return not (self._retired or self.is_blocked or self.is_expired or self.is_max_usage_count_reached)

@overload
def get_state(self, *, as_dict: Literal[True]) -> dict: ...
Expand All @@ -183,6 +191,7 @@ def get_state(self, *, as_dict: bool = False) -> SessionModel | dict:
usage_count=self._usage_count,
max_usage_count=self._max_usage_count,
error_score=self._error_score,
retired=self._retired,
cookies=self._cookies.get_cookies_as_dicts(),
blocked_status_codes=list(self._blocked_status_codes),
)
Expand All @@ -192,14 +201,17 @@ def get_state(self, *, as_dict: bool = False) -> SessionModel | dict:

def mark_good(self) -> None:
"""Mark the session as good. Should be called after a successful session usage."""
if self._retired:
return

self._usage_count += 1

if self._error_score > 0:
self._error_score = max(0, self._error_score - self._error_score_decrement)

# Retire the session if it is not usable anymore
if not self.is_usable:
self.retire()
self._retired = True

def mark_bad(self) -> None:
"""Mark the session as bad after an unsuccessful session usage."""
Expand All @@ -208,17 +220,19 @@ def mark_bad(self) -> None:

# Retire the session if it is not usable anymore
if not self.is_usable:
self.retire()
self._retired = True

def retire(self) -> None:
"""Retire the session by setting the error score to the maximum value.
"""Permanently retire the session and increase its error score once.

This method should be used if the session usage was unsuccessful and you are sure that it is because of
the session configuration and not any external matters. For example when server returns 403 status code.
If the session does not work due to some external factors as server error such as 5XX you probably want
to use `mark_bad` method.
"""
self._error_score += self._max_error_score
if not self._retired:
self._retired = True
self._error_score += self._max_error_score
Comment on lines +233 to +235

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.

Is there any point in doing this for retired session?

self._error_score += self._max_error_score

if not, I would suggest to do just

def retire(self)
    """..."""
    self._retired = True

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.

Good point. The session pool only needs the retired flag through is_usable, so increasing the score is not required to keep a retired session out of the pool.

I kept the explicit retire() penalty to preserve its existing error_score/is_blocked behavior. Crawlee JS also still adds maxErrorScore in explicit retire(); automatic retirement now sets only the flag in this PR, as requested in the earlier review.

Making retire() flag-only would mean a manually retired session can report is_blocked=False, even though it remains permanently unusable. That is reasonable if we want to separate those concepts fully, but it is an additional observable behavior change. @vdusek, would you prefer that policy for v2? Happy to simplify retire() and update the tests and migration notes once the intended behavior is confirmed.

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.

Yes, but that would diverge from JS, where retire() still adds maxErrorScore. If we want this, please open a PR in Crawlee JS with the same change. Both implementations must behave identically.


def is_blocked_status_code(
self,
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/sessions/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from crawlee.sessions._cookies import SessionCookies
from crawlee.sessions._models import SessionModel
from crawlee.sessions._session import Session


Expand Down Expand Up @@ -35,6 +36,7 @@ def test_session_init(session: Session) -> None:
assert not session.is_expired
assert not session.is_max_usage_count_reached
assert session.is_usable
assert not session.retired


def test_session_get_state(session: Session) -> None:
Expand Down Expand Up @@ -89,6 +91,94 @@ def test_retire_method(session: Session) -> None:
assert session.usage_count == initial_usage_count


def test_mark_good_leaves_retired_session_unchanged() -> None:
"""A successful request leaves a retired session's state unchanged."""
session = Session()
session.retire()
state = session.get_state(as_dict=True)
session.mark_good()

assert session.get_state(as_dict=True) == state
assert session.retired
assert session.is_blocked
assert not session.is_usable


def test_retirement_survives_serialization() -> None:
"""Retirement survives a JSON round trip even with a reduced error score."""
session = Session(retired=True, error_score=1.0)
state = session.get_state(as_dict=False).model_dump_json(by_alias=True)
restored = Session.from_model(SessionModel.model_validate_json(state))
restored.mark_good()

assert restored.retired
assert restored.error_score == 1.0
assert restored.usage_count == 0
assert not restored.is_blocked
assert not restored.is_usable


def test_session_without_retirement_field_can_be_restored() -> None:
"""Saved sessions without a retirement field remain loadable."""
state = Session().get_state(as_dict=True)
del state['retired']
restored = Session.from_model(SessionModel.model_validate(state))

assert restored.is_usable
assert not restored.retired


@pytest.mark.parametrize(
'mark_method',
[pytest.param('mark_good', id='successful-request'), pytest.param('mark_bad', id='failed-request')],
)
@pytest.mark.parametrize(
'expired',
[pytest.param(False, id='usage-limit'), pytest.param(True, id='expired')],
)
def test_automatic_retirement_preserves_error_score(mark_method: str, *, expired: bool) -> None:
"""Expiration and usage limits retire sessions without adding a blocking penalty."""
session = Session(
error_score=0.5,
max_usage_count=10 if expired else 1,
created_at=datetime.now(UTC) - timedelta(hours=1) if expired else None,
)
getattr(session, mark_method)()

assert session.retired
assert not session.is_usable
assert not session.is_blocked
assert session.error_score == (0.0 if mark_method == 'mark_good' else 1.5)
assert session.usage_count == 1

state = session.get_state(as_dict=True)
session.mark_good()
session.retire()
assert session.get_state(as_dict=True) == state


def test_automatic_retirement_at_error_limit_preserves_score() -> None:
"""Reaching the error limit retires a session without a second error penalty."""
session = Session(max_error_score=1.0)
session.mark_bad()

assert session.retired
assert session.is_blocked
assert not session.is_usable
assert session.error_score == 1.0
assert session.usage_count == 1


def test_retire_is_idempotent() -> None:
"""Repeated retirement leaves the error score and usage count unchanged."""
session = Session()
session.retire()
state = session.get_state(as_dict=True)
session.retire()

assert session.get_state(as_dict=True) == state


def test_mark_good_at_usage_limit_no_double_increment() -> None:
"""Test that mark_good at max usage count does not double-increment usage_count via retire."""
session = Session(max_usage_count=5, usage_count=4)
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/sessions/test_session_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ async def test_get_session(session_pool: SessionPool) -> None:
assert session.is_usable


async def test_pool_does_not_reuse_retired_session_after_success() -> None:
"""Neither pool lookup returns a retired session after a successful request."""
async with SessionPool(max_pool_size=1, persistence_enabled=False) as pool:
session = await pool.get_session()
session.retire()
session.mark_good()

assert await pool.get_session_by_id(session.id) is None
replacement = await pool.get_session()
assert replacement.id != session.id
assert replacement.is_usable


async def test_get_session_no_usable(caplog: pytest.LogCaptureFixture, session_pool: SessionPool) -> None:
"""Ensure that retrieval of a non-existent or retired session returns None and logs warning."""
session = await session_pool.get_session_by_id('non_existent')
Expand Down Expand Up @@ -170,6 +183,39 @@ async def test_session_pool_persist_and_restore(event_manager: EventManager, kvs
assert previous_state is None


async def test_retired_session_is_not_restored(event_manager: EventManager, kvs: KeyValueStore) -> None:
"""A saved retired session is excluded when the pool is reopened."""
service_locator.set_event_manager(event_manager)

async with SessionPool(
max_pool_size=1,
persistence_enabled=True,
persist_state_kvs_name=KVS_NAME,
persist_state_key=PERSIST_STATE_KEY,
) as pool:
session = await pool.get_session()
session.retire()
session.mark_good()
event_manager.emit(event=Event.PERSIST_STATE, event_data=EventPersistStateData(is_migrating=False))
await event_manager.wait_for_all_listeners_to_complete()

saved_state = await kvs.get_value(PERSIST_STATE_KEY)
assert isinstance(saved_state, dict)
saved_pool = SessionPoolModel.model_validate(saved_state)
assert not saved_pool.sessions[session.id].is_usable

async with SessionPool(
max_pool_size=1,
persistence_enabled=True,
persist_state_kvs_name=KVS_NAME,
persist_state_key=PERSIST_STATE_KEY,
) as restored_pool:
assert await restored_pool.get_session_by_id(session.id) is None
replacement = await restored_pool.get_session()
assert replacement.id != session.id
assert replacement.is_usable


async def test_methods_raise_error_when_not_active() -> None:
session = Session()
session_pool = SessionPool()
Expand Down
Loading