import asyncio import warnings from typing import Any import pytest from twisted.internet.defer import Deferred from scrapy import Request, Spider from scrapy.crawler import Crawler from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response from scrapy.utils.asyncio import sleep from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.utils.decorators import coroutine_test def _count_backout_logs(caplog: pytest.LogCaptureFixture) -> int: return sum( 1 for record in caplog.records if record.levelname == "INFO" and str(record.msg).startswith("Pausing request processing") ) def _backout_stats(crawler: Crawler) -> dict[str, Any]: assert crawler.stats return { k: v for k, v in crawler.stats.get_stats().items() if k.startswith("request_backout_seconds/") } class OfflineSpider(Spider): name = "offline" start_urls = ["data:,"] def parse(self, response): pass def _assert_scraper_slot_deprecation( warning_messages: pytest.WarningsRecorder, *, ignored: bool = False ) -> None: """Assert that a crawl emitted exactly one Scrapy deprecation warning, the one about SCRAPER_SLOT_MAX_ACTIVE_SIZE. Only Scrapy deprecation warnings are counted: a crawl may emit unrelated warnings (e.g. a ResourceWarning for a socket garbage-collected while the recorder is active), and those must not make the assertion flaky. Pass ``ignored=True`` when RESPONSE_MAX_ACTIVE_SIZE is set with an equal or higher priority and therefore SCRAPER_SLOT_MAX_ACTIVE_SIZE is being ignored.""" deprecations = [ message for message in warning_messages if issubclass(message.category, ScrapyDeprecationWarning) ] assert len(deprecations) == 1 if ignored: assert str(deprecations[0].message) == ( "The SCRAPER_SLOT_MAX_ACTIVE_SIZE setting is deprecated and is " "being ignored because RESPONSE_MAX_ACTIVE_SIZE is set with an " "equal or higher priority. Remove SCRAPER_SLOT_MAX_ACTIVE_SIZE " "from your settings." ) else: assert str(deprecations[0].message) == ( "The SCRAPER_SLOT_MAX_ACTIVE_SIZE setting is deprecated, use " "RESPONSE_MAX_ACTIVE_SIZE instead." ) class gt: __hash__ = None # type: ignore[assignment] def __init__(self, value: float): self.value = value def __eq__(self, other: object) -> bool: return isinstance(other, (int, float)) and other > self.value def __repr__(self) -> str: return f">{self.value}" class TestResponseMaxActiveSize: @coroutine_test async def test_default(self): """A crawl without custom settings has its effective response max active size set to 5 000 000, and triggers no deprecation warning.""" crawler = get_crawler(OfflineSpider) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 5_000_000 @coroutine_test async def test_custom(self): """Setting RESPONSE_MAX_ACTIVE_SIZE to a custom value changes the effective response max active size.""" crawler = get_crawler( OfflineSpider, settings_dict={"RESPONSE_MAX_ACTIVE_SIZE": 0} ) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 0 @coroutine_test async def test_deprecated_default(self): """Setting SCRAPER_SLOT_MAX_ACTIVE_SIZE triggers a deprecation warning, even if it is the default value.""" crawler = get_crawler( OfflineSpider, settings_dict={"SCRAPER_SLOT_MAX_ACTIVE_SIZE": 5_000_000} ) with pytest.warns(ScrapyDeprecationWarning) as warning_messages: await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 5_000_000 _assert_scraper_slot_deprecation(warning_messages) @coroutine_test async def test_deprecated_custom(self): """Setting SCRAPER_SLOT_MAX_ACTIVE_SIZE to a custom value triggers a deprecation warning, and changes the effective response max active size.""" crawler = get_crawler( OfflineSpider, settings_dict={"SCRAPER_SLOT_MAX_ACTIVE_SIZE": 0} ) with pytest.warns(ScrapyDeprecationWarning) as warning_messages: await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 0 _assert_scraper_slot_deprecation(warning_messages) @coroutine_test async def test_both(self): """Setting RESPONSE_MAX_ACTIVE_SIZE and SCRAPER_SLOT_MAX_ACTIVE_SIZE to different values with the same setting priority triggers a deprecation warning about SCRAPER_SLOT_MAX_ACTIVE_SIZE being ignored, and makes the value of RESPONSE_MAX_ACTIVE_SIZE the effective response max active size.""" crawler = get_crawler( OfflineSpider, settings_dict={ "RESPONSE_MAX_ACTIVE_SIZE": 1, "SCRAPER_SLOT_MAX_ACTIVE_SIZE": 2, }, ) with pytest.warns(ScrapyDeprecationWarning) as warning_messages: await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 1 _assert_scraper_slot_deprecation(warning_messages, ignored=True) @coroutine_test async def test_both_deprecated_priority(self): """Setting RESPONSE_MAX_ACTIVE_SIZE and SCRAPER_SLOT_MAX_ACTIVE_SIZE to different values and SCRAPER_SLOT_MAX_ACTIVE_SIZE with a higher priority triggers a deprecation warning about SCRAPER_SLOT_MAX_ACTIVE_SIZE but also makes the value of SCRAPER_SLOT_MAX_ACTIVE_SIZE the effective response max active size.""" class TestSpider(Spider): name = "test" start_urls = ["data:,"] @classmethod def update_settings(cls, settings): settings.set("RESPONSE_MAX_ACTIVE_SIZE", 1, priority=100) settings.set("SCRAPER_SLOT_MAX_ACTIVE_SIZE", 2, priority=101) def parse(self, response): pass crawler = get_crawler(TestSpider) with pytest.warns(ScrapyDeprecationWarning) as warning_messages: await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._max_active_size == 2 _assert_scraper_slot_deprecation(warning_messages) class TestResponseRoughSize: @pytest.fixture(autouse=True) def use_caplog(self, caplog): self.caplog = caplog @pytest.mark.parametrize( ("settings_dict", "expected"), [ # A quarter of RESPONSE_MAX_ACTIVE_SIZE split among # CONCURRENT_REQUESTS requests. ({}, 78125), ({"CONCURRENT_REQUESTS": 100}, 12500), ({"RESPONSE_MAX_ACTIVE_SIZE": 8_000_000}, 125_000), # Unlimited concurrency leaves no number of requests to split it # among. ({"CONCURRENT_REQUESTS": 0}, 0), ({"RESPONSE_MAX_ACTIVE_SIZE": 0}, 0), ({"RESPONSE_ROUGH_SIZE": 1}, 1), ({"RESPONSE_ROUGH_SIZE": 0}, 0), ], ) @coroutine_test async def test_value(self, settings_dict, expected): crawler = get_crawler(OfflineSpider, settings_dict=settings_dict) with warnings.catch_warnings(): warnings.simplefilter("error", ScrapyDeprecationWarning) await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine assert crawler.engine.downloader.middleware._response_rough_size == expected @coroutine_test async def test_response_replaces_rough_size(self): """The rough size of a request stops counting as soon as the size of its response is known.""" sizes: list[tuple[int, int]] = [] class DownloaderMiddleware: def __init__(self, crawler): self.crawler = crawler @classmethod def from_crawler(cls, crawler): return cls(crawler) def process_response(self, request, response): middleware = self.crawler.engine.downloader.middleware sizes.append( (middleware._rough_active_size, middleware._response_active_size) ) return response class TestSpider(Spider): name = "test" start_urls = ["data:,a"] custom_settings = { "DOWNLOADER_MIDDLEWARES": {DownloaderMiddleware: 0}, } def parse(self, response): pass crawler = get_crawler(TestSpider) await maybe_deferred_to_future(crawler.crawl()) assert sizes == [(0, 1)] assert crawler.engine assert crawler.engine.downloader.middleware._rough_sizes == {} @pytest.mark.only_asyncio @coroutine_test async def test_same_request_downloaded_twice(self): """The rough size of a request object being downloaded twice at the same time is counted once, so that it is gone once both downloads finish.""" class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = {"RESPONSE_ROUGH_SIZE": 1024} async def parse(self, response): assert self.crawler.engine request = Request("data:,a") # Only one of the two downloads succeeds, the other one fails # because the request object is already being downloaded. await asyncio.gather( self.crawler.engine.download_async(request), self.crawler.engine.download_async(request), return_exceptions=True, ) crawler = get_crawler(TestSpider) await maybe_deferred_to_future(crawler.crawl()) assert crawler.engine middleware = crawler.engine.downloader.middleware assert middleware._rough_sizes == {} assert middleware._rough_active_size == 0 @coroutine_test async def test_rough_size_triggers_backout(self): """Rough sizes of requests being downloaded count toward the limit, even if their responses turn out to be empty.""" class SlowDown: """Keeps requests in flight long enough for the engine to check for backout while their rough size is being counted.""" async def process_request(self, request): await sleep(0.01) class TestSpider(Spider): name = "test" start_urls = ["data:,", "data:,"] custom_settings = { "DOWNLOADER_MIDDLEWARES": {SlowDown: 0}, "RESPONSE_MAX_ACTIVE_SIZE": 512, "RESPONSE_ROUGH_SIZE": 1024, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats class TestRequestBackout: @pytest.fixture(autouse=True) def use_caplog(self, caplog): self.caplog = caplog @coroutine_test async def test_none(self): class TestSpider(Spider): name = "test" start_urls = ["data:,"] def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 0 assert _backout_stats(crawler) == {} @coroutine_test async def test_concurrency(self): class SlowDown: """Downloader middleware that returns a non-instant deferred from process_request, to force need_backout calls to happen at that point. The delay is deliberately non-zero so that the concurrency backout state lasts a measurable amount of wall-clock time, which keeps the request_backout_seconds/concurrency stat reliably above 0.""" def process_request(self, request, spider): from twisted.internet import reactor d: Deferred[None] = Deferred() reactor.callLater(0.01, d.callback, None) return d class TestSpider(Spider): name = "test" # Several start URLs so that, with CONCURRENT_REQUESTS=1, the engine # reliably attempts to schedule a second request while the first one # is still active, which is what triggers the concurrency backout. start_urls = ["data:,"] * 5 custom_settings = { "CONCURRENT_REQUESTS": 1, "DOWNLOADER_MIDDLEWARES": {SlowDown: 0}, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 0 expected_stats = { "request_backout_seconds/concurrency": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats @coroutine_test async def test_response_size(self): class TestSpider(Spider): name = "test" start_urls = ["data:,a"] custom_settings = { "RESPONSE_MAX_ACTIVE_SIZE": 1, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats @coroutine_test async def test_response_size_process_request(self): class DownloaderMiddleware: def process_request(self, request, spider): return Response("https://example.com", body=b"a") class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = { "DOWNLOADER_MIDDLEWARES": {DownloaderMiddleware: 0}, "RESPONSE_MAX_ACTIVE_SIZE": 1, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats @coroutine_test async def test_request_from_process_request(self): """A request returned from process_request does not count toward the limit, even though requests also have a body.""" class DownloaderMiddleware: def __init__(self): self.replaced = False def process_request(self, request): if self.replaced: return None self.replaced = True return Request("data:,b", body=b"a" * 2000) class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = { "DOWNLOADER_MIDDLEWARES": {DownloaderMiddleware: 0}, "RESPONSE_MAX_ACTIVE_SIZE": 1000, "RESPONSE_ROUGH_SIZE": 0, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 0 assert _backout_stats(crawler) == {} @coroutine_test async def test_response_size_process_response(self): class DownloaderMiddleware: def process_response(self, request, response, spider): return Response("https://example.com", body=b"a") class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = { "DOWNLOADER_MIDDLEWARES": {DownloaderMiddleware: 0}, "RESPONSE_MAX_ACTIVE_SIZE": 1, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats @coroutine_test async def test_response_size_process_exception(self): class DownloaderMiddleware1: def process_exception(self, request, exception, spider): return Response("https://example.com", body=b"a") class DownloaderMiddleware2: def process_request(self, request, spider): raise ValueError class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = { "DOWNLOADER_MIDDLEWARES": { DownloaderMiddleware1: 0, DownloaderMiddleware2: 1, }, "RESPONSE_MAX_ACTIVE_SIZE": 1, } def parse(self, response): pass crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats @coroutine_test async def test_response_size_download(self): """Ensure that responses from engine.download calls are also taken into account for the RESPONSE_MAX_ACTIVE_SIZE setting.""" class SlowDown: """Item pipeline that returns a non-instant deferred, to force need_backout calls to happen at that point.""" def process_item(self, item, spider): from twisted.internet import reactor d: Deferred[dict[Any, Any]] = Deferred() reactor.callLater(0, d.callback, {}) return d class TestSpider(Spider): name = "test" start_urls = ["data:,"] custom_settings = { "ITEM_PIPELINES": {SlowDown: 0}, "RESPONSE_MAX_ACTIVE_SIZE": 1, } async def parse(self, response): assert self.crawler.engine response = await self.crawler.engine.download(Request("data:,a")) yield {"response": response} crawler = get_crawler(TestSpider) self.caplog.clear() with self.caplog.at_level("INFO"): await maybe_deferred_to_future(crawler.crawl()) assert _count_backout_logs(self.caplog) == 1 expected_stats = { "request_backout_seconds/response_max_active_size": gt(0), "request_backout_seconds/total": gt(0), } assert _backout_stats(crawler) == expected_stats