571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""Tests for ThrottlingRequestManager - per-domain delay scheduling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from crawlee._request import Request
|
|
from crawlee._service_locator import ServiceLocator
|
|
from crawlee._utils.http import parse_retry_after_header
|
|
from crawlee.request_loaders._throttling_request_manager import ThrottlingRequestManager
|
|
from crawlee.storage_clients import MemoryStorageClient
|
|
from crawlee.storages import RequestQueue
|
|
|
|
THROTTLED_DOMAIN = 'throttled.com'
|
|
NON_THROTTLED_DOMAIN = 'free.com'
|
|
TEST_DOMAINS = [THROTTLED_DOMAIN]
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_storage_client() -> MemoryStorageClient:
|
|
"""Create a MemoryStorageClient for testing."""
|
|
return MemoryStorageClient()
|
|
|
|
|
|
@pytest.fixture
|
|
def service_locator(memory_storage_client: MemoryStorageClient) -> ServiceLocator:
|
|
"""Create a ServiceLocator with MemoryStorageClient for testing."""
|
|
return ServiceLocator(storage_client=memory_storage_client)
|
|
|
|
|
|
@pytest.fixture
|
|
async def inner_queue(memory_storage_client: MemoryStorageClient) -> RequestQueue:
|
|
"""Create a real RequestQueue with MemoryStorageClient."""
|
|
return await RequestQueue.open(name='test-inner', storage_client=memory_storage_client)
|
|
|
|
|
|
@pytest.fixture
|
|
async def manager(inner_queue: RequestQueue, service_locator: ServiceLocator) -> ThrottlingRequestManager[RequestQueue]:
|
|
"""Create a ThrottlingRequestManager wrapping a real queue with test domains."""
|
|
return ThrottlingRequestManager(
|
|
inner_queue,
|
|
domains=TEST_DOMAINS,
|
|
request_manager_opener=RequestQueue.open,
|
|
service_locator=service_locator,
|
|
)
|
|
|
|
|
|
def _make_request(url: str) -> Request:
|
|
"""Helper to create a Request object."""
|
|
return Request.from_url(url)
|
|
|
|
|
|
# ── Request Routing Tests ─────────────────────────────────
|
|
|
|
|
|
async def test_add_request_routes_listed_domain_to_sub_manager(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
inner_queue: RequestQueue,
|
|
) -> None:
|
|
"""Requests for listed domains should be routed to their sub-manager, not inner."""
|
|
request = _make_request(f'https://{THROTTLED_DOMAIN}/page1')
|
|
await manager.add_request(request)
|
|
|
|
# Inner manager should be empty — the request went to a sub-manager.
|
|
assert await inner_queue.is_empty()
|
|
assert THROTTLED_DOMAIN in manager._sub_managers
|
|
|
|
# The sub-manager should have the request.
|
|
assert await manager._sub_managers[THROTTLED_DOMAIN].get_total_count() == 1
|
|
|
|
|
|
async def test_add_request_routes_non_listed_domain_to_inner(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
inner_queue: RequestQueue,
|
|
) -> None:
|
|
"""Requests for non-listed domains should go to the inner manager."""
|
|
request = _make_request(f'https://{NON_THROTTLED_DOMAIN}/page1')
|
|
await manager.add_request(request)
|
|
|
|
assert not await inner_queue.is_empty()
|
|
assert NON_THROTTLED_DOMAIN not in manager._sub_managers
|
|
|
|
|
|
async def test_add_request_with_string_url(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""add_request should also work when given a plain URL string."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
assert THROTTLED_DOMAIN in manager._sub_managers
|
|
assert await manager._sub_managers[THROTTLED_DOMAIN].get_total_count() == 1
|
|
|
|
|
|
async def test_domain_matching_is_case_insensitive(
|
|
inner_queue: RequestQueue,
|
|
service_locator: ServiceLocator,
|
|
) -> None:
|
|
"""Domains with mixed casing should match URLs whose hostnames yarl lowercases."""
|
|
manager = ThrottlingRequestManager(
|
|
inner_queue,
|
|
domains=['Example.COM'],
|
|
request_manager_opener=RequestQueue.open,
|
|
service_locator=service_locator,
|
|
)
|
|
|
|
await manager.add_request('https://EXAMPLE.com/page1')
|
|
|
|
assert await inner_queue.is_empty()
|
|
assert 'example.com' in manager._sub_managers
|
|
assert await manager._sub_managers['example.com'].get_total_count() == 1
|
|
|
|
# 429 bookkeeping must also flow through the lowercased state.
|
|
manager.record_domain_delay('https://Example.Com/page2')
|
|
assert manager._is_domain_throttled('example.com')
|
|
|
|
|
|
async def test_add_requests_routes_mixed_domains(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
inner_queue: RequestQueue,
|
|
) -> None:
|
|
"""add_requests should split requests by domain and route them correctly."""
|
|
throttled_req = _make_request(f'https://{THROTTLED_DOMAIN}/page1')
|
|
free_req = _make_request(f'https://{NON_THROTTLED_DOMAIN}/page1')
|
|
|
|
await manager.add_requests([throttled_req, free_req])
|
|
|
|
# Inner gets the non-listed domain request.
|
|
assert not await inner_queue.is_empty()
|
|
|
|
# Sub-manager gets the listed domain request.
|
|
assert THROTTLED_DOMAIN in manager._sub_managers
|
|
assert await manager._sub_managers[THROTTLED_DOMAIN].get_total_count() == 1
|
|
|
|
|
|
# ── Core Throttling Tests ─────────────────────────────────
|
|
|
|
|
|
async def test_429_triggers_domain_delay(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""After record_domain_delay(), the domain should be throttled and the call should report success."""
|
|
assert manager.record_domain_delay(f'https://{THROTTLED_DOMAIN}/page1') is True
|
|
assert manager._is_domain_throttled(THROTTLED_DOMAIN)
|
|
|
|
|
|
async def test_record_domain_delay_returns_false_for_unconfigured_domain(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""record_domain_delay should return False (no-op) when the URL's domain is not in the configured list."""
|
|
assert manager.record_domain_delay(f'https://{NON_THROTTLED_DOMAIN}/page1') is False
|
|
assert not manager._is_domain_throttled(NON_THROTTLED_DOMAIN)
|
|
|
|
|
|
async def test_different_domains_independent(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""Throttling one domain should NOT affect other domains."""
|
|
manager.record_domain_delay(f'https://{THROTTLED_DOMAIN}/page1')
|
|
assert manager._is_domain_throttled(THROTTLED_DOMAIN)
|
|
assert not manager._is_domain_throttled(NON_THROTTLED_DOMAIN)
|
|
|
|
|
|
async def test_exponential_backoff(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""Consecutive 429s should increase delay exponentially."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
|
|
manager.record_domain_delay(url)
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
first_until = state.throttled_until
|
|
|
|
manager.record_domain_delay(url)
|
|
second_until = state.throttled_until
|
|
|
|
assert second_until > first_until
|
|
assert state.consecutive_429_count == 2
|
|
|
|
|
|
async def test_max_delay_cap(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""Backoff should cap at max_delay (60s)."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
|
|
for _ in range(20):
|
|
manager.record_domain_delay(url)
|
|
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
now = datetime.now(timezone.utc)
|
|
actual_delay = state.throttled_until - now
|
|
|
|
assert actual_delay <= manager._max_delay + timedelta(seconds=1)
|
|
|
|
|
|
async def test_retry_after_header_priority(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""Explicit Retry-After should override exponential backoff."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
|
|
manager.record_domain_delay(url, retry_after=timedelta(seconds=30))
|
|
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
now = datetime.now(timezone.utc)
|
|
actual_delay = state.throttled_until - now
|
|
|
|
assert actual_delay > timedelta(seconds=28)
|
|
assert actual_delay <= timedelta(seconds=31)
|
|
|
|
|
|
async def test_retry_after_exceeding_max_delay_logs_warning(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A Retry-After value above max_delay should still be capped, but emit a warning."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
|
|
with caplog.at_level('WARNING', logger='crawlee.request_loaders._throttling_request_manager'):
|
|
manager.record_domain_delay(url, retry_after=manager._max_delay + timedelta(seconds=240))
|
|
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
actual_delay = state.throttled_until - datetime.now(timezone.utc)
|
|
assert actual_delay <= manager._max_delay + timedelta(seconds=1)
|
|
|
|
warnings = [r for r in caplog.records if r.levelname == 'WARNING']
|
|
assert len(warnings) == 1
|
|
assert 'Retry-After header' in warnings[0].message
|
|
assert THROTTLED_DOMAIN in warnings[0].message
|
|
|
|
|
|
async def test_success_resets_backoff(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""Successful request should reset the consecutive 429 count."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
|
|
manager.record_domain_delay(url)
|
|
manager.record_domain_delay(url)
|
|
assert manager._domain_states[THROTTLED_DOMAIN].consecutive_429_count == 2
|
|
|
|
manager.record_success(url)
|
|
assert manager._domain_states[THROTTLED_DOMAIN].consecutive_429_count == 0
|
|
|
|
|
|
# ── Crawl-Delay Integration Tests ─────────────────────────
|
|
|
|
|
|
async def test_crawl_delay_integration(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""set_crawl_delay() should record the delay for the domain."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
manager.set_crawl_delay(url, 5)
|
|
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
assert state.crawl_delay == timedelta(seconds=5)
|
|
|
|
|
|
async def test_crawl_delay_throttles_after_dispatch(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""After dispatching a request, crawl-delay should throttle the next one."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
manager.set_crawl_delay(url, 5)
|
|
|
|
manager._mark_domain_dispatched(THROTTLED_DOMAIN)
|
|
|
|
assert manager._is_domain_throttled(THROTTLED_DOMAIN)
|
|
|
|
|
|
# ── Fetch Scheduling Tests ────────────────────────────
|
|
|
|
|
|
async def test_fetch_from_unthrottled_sub_manager(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""fetch_next_request should return from an unthrottled sub-manager."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
result = await manager.fetch_next_request()
|
|
|
|
assert result is not None
|
|
assert result.url == url
|
|
|
|
|
|
async def test_fetch_falls_back_to_inner(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""When sub-managers are empty, should return from inner manager."""
|
|
url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
result = await manager.fetch_next_request()
|
|
|
|
assert result is not None
|
|
assert result.url == url
|
|
|
|
|
|
async def test_fetch_skips_throttled_sub_manager(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""Should skip throttled sub-managers and fall through to inner."""
|
|
# Add a request to the throttled domain and mark it as throttled.
|
|
throttled_url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
free_url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
|
|
await manager.add_request(throttled_url)
|
|
await manager.add_request(free_url)
|
|
|
|
manager.record_domain_delay(throttled_url)
|
|
|
|
result = await manager.fetch_next_request()
|
|
|
|
assert result is not None
|
|
assert result.url == free_url
|
|
|
|
|
|
async def test_sleep_when_all_throttled(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""When all domains are throttled and inner is empty, should wait and retry."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
manager.record_domain_delay(url, retry_after=timedelta(seconds=10))
|
|
|
|
target = (
|
|
'crawlee.request_loaders._throttling_request_manager.ThrottlingRequestManager._wait_for_new_work_or_timeout'
|
|
)
|
|
with patch(target, new_callable=AsyncMock) as mock_wait:
|
|
|
|
async def wait_side_effect(*_args: Any, **_kwargs: Any) -> None:
|
|
# Set throttled_until firmly in the past so the next iteration reliably unblocks the domain regardless of
|
|
# clock resolution or scheduling jitter on slow CI runners.
|
|
manager._domain_states[THROTTLED_DOMAIN].throttled_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
|
|
mock_wait.side_effect = wait_side_effect
|
|
|
|
result = await manager.fetch_next_request()
|
|
|
|
mock_wait.assert_called()
|
|
assert result is not None
|
|
assert result.url == url
|
|
|
|
|
|
async def test_fetch_wakes_when_request_added_during_throttle_wait(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""When all sub-managers are throttled and inner is empty, fetch should wake up immediately
|
|
when a new request is added rather than blocking until the throttle expires."""
|
|
# Throttle the only configured domain for a long time so a naive sleep would block here.
|
|
throttled_url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(throttled_url)
|
|
manager.record_domain_delay(throttled_url, retry_after=timedelta(seconds=60))
|
|
|
|
free_url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
|
|
# Wrap the wait helper so we can synchronize with the moment fetch enters the wait state.
|
|
wait_entered = asyncio.Event()
|
|
original_wait = manager._wait_for_new_work_or_timeout
|
|
|
|
async def signaling_wait(timeout: float) -> None:
|
|
wait_entered.set()
|
|
await original_wait(timeout)
|
|
|
|
manager._wait_for_new_work_or_timeout = signaling_wait # ty: ignore[invalid-assignment]
|
|
|
|
fetch_task = asyncio.create_task(manager.fetch_next_request())
|
|
|
|
# Wait until fetch is suspended inside the wait, then add fresh non-throttled work.
|
|
await wait_entered.wait()
|
|
await manager.add_request(free_url)
|
|
|
|
# If the wake-up signal works, fetch returns the freshly-added request well within the 2s wait_for budget; otherwise
|
|
# it would still be blocked on the 60s throttle.
|
|
result = await asyncio.wait_for(fetch_task, timeout=2.0)
|
|
|
|
assert result is not None
|
|
assert result.url == free_url
|
|
|
|
|
|
# ── Delegation Tests ────────────────────────────────────
|
|
|
|
|
|
async def test_reclaim_request_routes_to_sub_manager(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""reclaim_request should route to sub-manager for listed domains."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
# Fetch it first, then reclaim.
|
|
request = await manager.fetch_next_request()
|
|
assert request is not None
|
|
|
|
await manager.reclaim_request(request)
|
|
|
|
# Should be back in the sub-manager.
|
|
assert not await manager._sub_managers[THROTTLED_DOMAIN].is_empty()
|
|
|
|
|
|
async def test_reclaim_request_routes_to_inner(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
inner_queue: RequestQueue,
|
|
) -> None:
|
|
"""reclaim_request should route to inner for non-listed domains."""
|
|
url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
request = await manager.fetch_next_request()
|
|
assert request is not None
|
|
|
|
await manager.reclaim_request(request)
|
|
|
|
# Should be back in inner manager.
|
|
assert not await inner_queue.is_empty()
|
|
|
|
|
|
async def test_mark_request_as_handled_routes_to_sub_manager(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""mark_request_as_handled should route to sub-manager for listed domains."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
request = await manager.fetch_next_request()
|
|
assert request is not None
|
|
|
|
await manager.mark_request_as_handled(request)
|
|
|
|
assert await manager._sub_managers[THROTTLED_DOMAIN].get_handled_count() == 1
|
|
|
|
|
|
async def test_mark_request_as_handled_routes_to_inner(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
inner_queue: RequestQueue,
|
|
) -> None:
|
|
"""mark_request_as_handled should route to inner for non-listed domains."""
|
|
url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
|
|
request = await manager.fetch_next_request()
|
|
assert request is not None
|
|
|
|
await manager.mark_request_as_handled(request)
|
|
|
|
assert await inner_queue.get_handled_count() == 1
|
|
|
|
|
|
async def test_get_handled_count_aggregates(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""get_handled_count should sum inner and all sub-managers."""
|
|
throttled_url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
free_url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
|
|
await manager.add_request(throttled_url)
|
|
await manager.add_request(free_url)
|
|
|
|
# Fetch and handle both.
|
|
req1 = await manager.fetch_next_request()
|
|
assert req1 is not None
|
|
await manager.mark_request_as_handled(req1)
|
|
|
|
req2 = await manager.fetch_next_request()
|
|
assert req2 is not None
|
|
await manager.mark_request_as_handled(req2)
|
|
|
|
assert await manager.get_handled_count() == 2
|
|
|
|
|
|
async def test_get_total_count_aggregates(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""get_total_count should sum inner and all sub-managers."""
|
|
throttled_url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
free_url = f'https://{NON_THROTTLED_DOMAIN}/page1'
|
|
|
|
await manager.add_request(throttled_url)
|
|
await manager.add_request(free_url)
|
|
|
|
assert await manager.get_total_count() == 2
|
|
|
|
|
|
async def test_is_empty_aggregates(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""is_empty should return False if any manager has requests."""
|
|
assert await manager.is_empty() is True
|
|
|
|
await manager.add_request(f'https://{THROTTLED_DOMAIN}/page1')
|
|
assert await manager.is_empty() is False
|
|
|
|
|
|
async def test_is_finished_aggregates(manager: ThrottlingRequestManager[RequestQueue]) -> None:
|
|
"""is_finished should return True only when all managers are finished."""
|
|
assert await manager.is_finished() is True
|
|
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
assert await manager.is_finished() is False
|
|
|
|
request = await manager.fetch_next_request()
|
|
assert request is not None
|
|
await manager.mark_request_as_handled(request)
|
|
|
|
assert await manager.is_finished() is True
|
|
|
|
|
|
async def test_drop_clears_all(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""drop should clear inner and all sub-managers."""
|
|
await manager.add_request(f'https://{THROTTLED_DOMAIN}/page1')
|
|
|
|
assert THROTTLED_DOMAIN in manager._sub_managers
|
|
|
|
await manager.drop()
|
|
|
|
assert len(manager._sub_managers) == 0
|
|
|
|
|
|
async def test_purge_clears_requests_and_resets_throttle_state(
|
|
manager: ThrottlingRequestManager[RequestQueue],
|
|
) -> None:
|
|
"""purge should empty inner and sub-managers, reset transient backoff, and preserve domain configuration."""
|
|
url = f'https://{THROTTLED_DOMAIN}/page1'
|
|
await manager.add_request(url)
|
|
manager.record_domain_delay(url)
|
|
|
|
state = manager._domain_states[THROTTLED_DOMAIN]
|
|
assert state.consecutive_429_count == 1
|
|
assert manager._is_domain_throttled(THROTTLED_DOMAIN)
|
|
|
|
await manager.purge()
|
|
|
|
# Domain configuration is preserved.
|
|
assert THROTTLED_DOMAIN in manager._domain_states
|
|
# Manager is empty.
|
|
assert await manager.is_empty()
|
|
# Transient backoff state is reset.
|
|
assert state.consecutive_429_count == 0
|
|
assert not manager._is_domain_throttled(THROTTLED_DOMAIN)
|
|
|
|
|
|
# ── Utility Tests ──────────────────────────────────────
|
|
|
|
|
|
def test_parse_retry_after_none_value() -> None:
|
|
assert parse_retry_after_header(None) is None
|
|
|
|
|
|
def test_parse_retry_after_empty_string() -> None:
|
|
assert parse_retry_after_header('') is None
|
|
|
|
|
|
def test_parse_retry_after_integer_seconds() -> None:
|
|
result = parse_retry_after_header('120')
|
|
assert result == timedelta(seconds=120)
|
|
|
|
|
|
def test_parse_retry_after_invalid_value() -> None:
|
|
assert parse_retry_after_header('not-a-date-or-number') is None
|
|
|
|
|
|
def test_parse_retry_after_http_date_with_timezone() -> None:
|
|
"""A future HTTP-date with explicit timezone should yield a positive delay."""
|
|
future = datetime.now(timezone.utc) + timedelta(seconds=120)
|
|
header = future.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
|
|
|
result = parse_retry_after_header(header)
|
|
|
|
assert result is not None
|
|
assert timedelta(seconds=60) < result <= timedelta(seconds=121)
|
|
|
|
|
|
def test_parse_retry_after_naive_http_date_treated_as_utc() -> None:
|
|
"""A future HTTP-date without timezone info should be treated as UTC, not raise on subtraction."""
|
|
future = datetime.now(timezone.utc) + timedelta(seconds=120)
|
|
# Format without any timezone designator — `parsedate_to_datetime` returns a naive datetime here.
|
|
header = future.strftime('%a, %d %b %Y %H:%M:%S')
|
|
|
|
result = parse_retry_after_header(header)
|
|
|
|
assert result is not None
|
|
assert timedelta(seconds=60) < result <= timedelta(seconds=121)
|