Rederepcate PER_IP, deprecate PER_DOMAIN

This commit is contained in:
Adrian Chaves 2026-07-02 08:27:20 +02:00
parent 8e488d837c
commit 7784594f7a
12 changed files with 209 additions and 149 deletions

View File

@ -47,8 +47,8 @@ Increase concurrency
====================
Concurrency is the number of requests that are processed in parallel. There is
a global limit (:setting:`CONCURRENT_REQUESTS`) and an additional limit that
can be set per domain (:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`).
a global limit (:setting:`CONCURRENT_REQUESTS`) and an additional per-scope
(per-domain by default) limit (:setting:`THROTTLING_SCOPE_CONCURRENCY`).
The default global concurrency limit in Scrapy is not suitable for crawling
many different domains in parallel, so you will want to increase it. How much

View File

@ -26,15 +26,16 @@ throttling limits, as do ``toscrape.com`` and ``books.toscrape.com``.
The main throttling :ref:`settings <topics-settings>` are:
- .. setting:: CONCURRENT_REQUESTS_PER_DOMAIN
- .. setting:: THROTTLING_SCOPE_CONCURRENCY
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` (default: ``1``
(:ref:`fallback <default-settings>`: ``8``))
:setting:`THROTTLING_SCOPE_CONCURRENCY` (default: ``1``)
Maximum number of simultaneous requests per domain.
Default maximum number of simultaneous requests per :ref:`throttling scope
<throttling-scopes>`. Requests are grouped by domain by default, so this is
the maximum number of simultaneous requests per domain.
It defines a number of “slots” per domain. Each slot can send 1 request at
a time: it sends a request, waits for the response, then sends the next
It defines a number of “slots” per scope. Each slot can send 1 request at a
time: it sends a request, waits for the response, then sends the next
request, and so on.
- .. setting:: DOWNLOAD_DELAY
@ -50,7 +51,7 @@ The main throttling :ref:`settings <topics-settings>` are:
When configuring these settings, note that:
- :setting:`CONCURRENT_REQUESTS` caps :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`.
- :setting:`CONCURRENT_REQUESTS` caps :setting:`THROTTLING_SCOPE_CONCURRENCY`.
- If ``DOWNLOAD_DELAY`` ≥ response time, concurrency is effectively ``1``,
because the next request to the domain is not sent until the delay elapses,
@ -296,7 +297,7 @@ to wait between requests.
If :setting:`ROBOTSTXT_OBEY` and :setting:`THROTTLING_ROBOTSTXT_OBEY` are
``True`` (default), valid ``Crawl-Delay`` directives override
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and :setting:`DOWNLOAD_DELAY`.
:setting:`THROTTLING_SCOPE_CONCURRENCY` and :setting:`DOWNLOAD_DELAY`.
Concurrency is set to ``1`` and the delay is raised to at least the
``Crawl-Delay`` value (a larger configured delay is kept), capped at
:setting:`THROTTLING_ROBOTSTXT_MAX_DELAY` (default: ``60.0``).
@ -465,10 +466,8 @@ Its keys are scope names and its values are
following keys:
``concurrency`` (:class:`int`)
Maximum number of concurrent requests for the scope. When unset, the
default depends on the kind of scope: :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`
for domain scopes, :setting:`CONCURRENT_REQUESTS_PER_IP` for IP scopes, and
:setting:`THROTTLING_SCOPE_CONCURRENCY` for any other scope.
Maximum number of concurrent requests for the scope. Defaults to
:setting:`THROTTLING_SCOPE_CONCURRENCY`.
``delay`` (:class:`float`)
Minimum seconds between requests for the scope. Defaults to
@ -921,46 +920,30 @@ window (:setting:`THROTTLING_WINDOW`). You can use :ref:`throttling quotas
Per-IP concurrency limiting
---------------------------
.. setting:: CONCURRENT_REQUESTS_PER_IP
A concurrency limit keyed by IP is just a throttling scope whose id is the
request's IP, with a ``concurrency`` limit. A request then carries two scopes,
its domain and its IP, and is only sent when **both** allow it (see
:ref:`multiple-throttling-scopes`).
A concurrency limit keyed by IP is a throttling scope whose id is the request's
IP. A request then carries two scopes, its domain and its IP, and is only sent
when **both** allow it (see :ref:`multiple-throttling-scopes`).
- Implement a :ref:`throttling manager <custom-throttling-scopes>` that adds
the request's IP as a second scope:
Set :setting:`CONCURRENT_REQUESTS_PER_IP` (default: ``0``, disabled) to a
positive number to enable this built in: the throttler adds the IP of each
request, resolved from the DNS cache, as a second scope, limited to that many
concurrent requests. IP scopes whose ``concurrency`` is left unset default to
:setting:`CONCURRENT_REQUESTS_PER_IP`.
.. code-block:: python
.. note:: Enabling :setting:`CONCURRENT_REQUESTS_PER_IP` requires the
:ref:`throttling-aware scheduler <throttling-aware-scheduler>`. The default
:class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` does not support it
and raises an error at start up if it is set, so switch
:setting:`SCHEDULER` and :setting:`SCHEDULER_PRIORITY_QUEUE` as shown
there.
import socket
For finer control (e.g. resolving IPs that are not in the DNS cache yet, or
grouping several hosts under one address), implement a :ref:`throttling manager
<custom-throttling-scopes>` that adds the request's IP as a second scope
instead:
.. code-block:: python
import socket
from scrapy.throttling import ThrottlingManager, add_scope, scope_cache
from scrapy.utils.asyncio import run_in_thread
from scrapy.utils.httpobj import urlparse_cached
from scrapy.throttling import ThrottlingManager, add_scope, scope_cache
from scrapy.utils.asyncio import run_in_thread
from scrapy.utils.httpobj import urlparse_cached
class IPThrottlingManager(ThrottlingManager):
@scope_cache
async def get_scopes(self, request):
scopes = await super().get_scopes(request)
host = urlparse_cached(request).hostname
address = await run_in_thread(socket.gethostbyname, host)
return add_scope(scopes, address)
class IPThrottlingManager(ThrottlingManager):
@scope_cache
async def get_scopes(self, request):
scopes = await super().get_scopes(request)
host = urlparse_cached(request).hostname
address = await run_in_thread(socket.gethostbyname, host)
return add_scope(scopes, address)
.. _throttling-settings:
@ -1048,26 +1031,6 @@ Additional settings
Whether to log :ref:`throttling <throttling>` decisions (per-scope delays,
backoff steps and recoveries) for debugging.
- .. setting:: THROTTLING_SCOPE_CONCURRENCY
:setting:`THROTTLING_SCOPE_CONCURRENCY` (default: ``1``)
Default maximum number of concurrent requests for :ref:`throttling scopes
<throttling-scopes>` that are neither domains nor IPs, e.g. custom scopes
added by a :ref:`custom throttling manager <custom-throttling-scopes>`.
A scope counts as a domain only if it looks like a hostname with at least
one dot, so single-label hosts such as ``localhost`` (or intranet host
names) fall in this category and default to
:setting:`THROTTLING_SCOPE_CONCURRENCY` rather than
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`. Raise it, or give the host an
explicit ``concurrency`` in :setting:`THROTTLING_SCOPES`, if you need more
concurrency against such hosts (e.g. a local development server).
Domain and IP scopes use :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and
:setting:`CONCURRENT_REQUESTS_PER_IP` instead. A scope ``concurrency`` set
in :setting:`THROTTLING_SCOPES` overrides this.
- .. setting:: THROTTLING_SCOPE_LIMIT
:setting:`THROTTLING_SCOPE_LIMIT` (default: ``100000``)

View File

@ -251,9 +251,7 @@ class Downloader:
return self.get_slot_key(request)
def get_slot_key(self, request: Request) -> str:
# Per-IP grouping (CONCURRENT_REQUESTS_PER_IP) is now a throttling scope
# handled by the throttler, not a downloader slot key; this fallback (used
# only when no throttler is set) keys by domain alone.
# This fallback (used only when no throttler is set) keys by domain.
return urlparse_cached(request).netloc or ""
async def _enqueue_request(self, request: Request) -> Response:

View File

@ -93,11 +93,10 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True)
# Keep enough persistent connections per host to match the highest
# per-host concurrency the throttler may admit: the per-domain and
# per-IP limits, the default "other"-scope limit, and any explicit
# THROTTLING_SCOPES concurrency. Per-scope concurrency can grow beyond
# this at runtime (rampup); the excess simply uses non-persistent
# connections.
# per-host concurrency the throttler may admit: the per-domain limit,
# the default "other"-scope limit, and any explicit THROTTLING_SCOPES
# concurrency. Per-scope concurrency can grow beyond this at runtime
# (rampup); the excess simply uses non-persistent connections.
scope_concurrencies = [
scope["concurrency"]
for scope in crawler.settings.getdict("THROTTLING_SCOPES").values()
@ -106,7 +105,6 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
self._pool.maxPersistentPerHost = max(
[
crawler.settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN"),
crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP"),
crawler.settings.getint("THROTTLING_SCOPE_CONCURRENCY"),
*scope_concurrencies,
]

View File

@ -344,10 +344,7 @@ class DownloaderAwarePriorityQueue:
):
if crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP") != 0:
raise ValueError(
f'"{self.__class__}" does not support CONCURRENT_REQUESTS_PER_IP. '
"Set SCHEDULER_PRIORITY_QUEUE to "
"'scrapy.pqueues.ThrottlingAwarePriorityQueue' (along with the "
"matching SCHEDULER) to use per-IP concurrency limiting."
f'"{self.__class__}" does not support CONCURRENT_REQUESTS_PER_IP'
)
if slot_startprios and not isinstance(slot_startprios, dict):

View File

@ -149,6 +149,15 @@ class BaseSettings(MutableMapping[str, Any]):
:param default: the value to return if no setting is found
:type default: object
"""
if name == "CONCURRENT_REQUESTS_PER_IP" and (
isinstance(self[name], int) and self[name] != 0
):
warnings.warn(
"The CONCURRENT_REQUESTS_PER_IP setting is deprecated, use CONCURRENT_REQUESTS_PER_DOMAIN instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
if name == "DNS_RESOLVER":
warnings.warn(
"The DNS_RESOLVER setting is deprecated, please use "

View File

@ -16,6 +16,7 @@ Scrapy developers, if you add a setting here remember to:
import sys
from importlib import import_module
from pathlib import Path
from typing import Any
__all__ = [
"ADDONS",
@ -51,7 +52,6 @@ __all__ = [
"CONCURRENT_ITEMS",
"CONCURRENT_REQUESTS",
"CONCURRENT_REQUESTS_PER_DOMAIN",
"CONCURRENT_REQUESTS_PER_IP",
"COOKIES_DEBUG",
"COOKIES_ENABLED",
"CRAWLSPIDER_FOLLOW_LINKS",
@ -285,7 +285,6 @@ CONCURRENT_ITEMS = 100
CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 8
CONCURRENT_REQUESTS_PER_IP = 0
COOKIES_ENABLED = True
COOKIES_DEBUG = False
@ -636,3 +635,19 @@ URLLENGTH_LIMIT = 2083
USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org)"
WARN_ON_GENERATOR_RETURN_VALUE = True
def __getattr__(name: str) -> Any:
if name == "CONCURRENT_REQUESTS_PER_IP":
import warnings # noqa: PLC0415
from scrapy.exceptions import ScrapyDeprecationWarning # noqa: PLC0415
warnings.warn(
"The scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_IP attribute is deprecated, use scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_DOMAIN instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return 0
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -13,7 +13,7 @@ NEWSPIDER_MODULE = "$project_name.spiders"
ROBOTSTXT_OBEY = True
# Throttle crawls to be polite to websites:
CONCURRENT_REQUESTS_PER_DOMAIN = 1
THROTTLING_SCOPE_CONCURRENCY = 1
DOWNLOAD_DELAY = 1
# Crawl the tutorial websites faster, overriding the polite defaults above only

View File

@ -1,10 +1,8 @@
from __future__ import annotations
import contextlib
import ipaddress
import logging
import random
import re
import time
import warnings
from collections import OrderedDict
@ -18,7 +16,7 @@ from typing_extensions import Self
from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.resolver import dnscache
from scrapy.settings import SETTINGS_PRIORITIES
from scrapy.utils.asyncio import sleep, wait_for_first
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler, load_object
@ -134,59 +132,67 @@ def iter_scope_values(scopes: RequestScopes) -> Iterable[tuple[ScopeID, float |
yield scope, None
# A DNS hostname with at least one dot, e.g. "books.toscrape.com"; labels are
# alphanumeric (plus hyphens, not at the edges) and an optional trailing dot
# marks a fully-qualified name.
_HOSTNAME_RE = re.compile(
r"(?i)^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+\.?$"
)
def _default_scope_concurrency(settings: BaseSettings) -> int:
"""Return the default concurrency of a throttling scope that does not set
its own ``concurrency``.
def _classify_scope(scope_id: ScopeID) -> str:
"""Classify *scope_id* as ``"ip"``, ``"domain"`` or ``"other"`` to pick its
default concurrency setting (see :func:`_default_scope_concurrency`).
A scope that parses as an IP address is ``"ip"``; one that *looks like* a
DNS hostname with at least one dot is ``"domain"``; anything else (custom
group names, single labels like ``"localhost"``) is ``"other"``.
This is :setting:`THROTTLING_SCOPE_CONCURRENCY`, except that the deprecated
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` setting is bridged in when set at a
higher :ref:`priority <populating-settings>`. When neither is set
explicitly, the historical :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` value is
kept for backward compatibility (its default flips to
:setting:`THROTTLING_SCOPE_CONCURRENCY` in a future version; see
:func:`_warn_on_deprecated_concurrency`).
"""
host = scope_id
if host.startswith("[") and "]" in host: # bracketed IPv6, e.g. "[::1]:80"
host = host[1 : host.index("]")]
with contextlib.suppress(ValueError):
ipaddress.ip_address(host)
return "ip"
# Drop a trailing ":port" for the hostname check (an IPv4 "host:port" is
# re-tested as an IP below).
if host.count(":") == 1:
host = host.rsplit(":", 1)[0]
with contextlib.suppress(ValueError):
ipaddress.ip_address(host)
return "ip"
if "." in host and _HOSTNAME_RE.match(host):
return "domain"
return "other"
def _default_scope_concurrency(settings: BaseSettings, scope_id: ScopeID) -> int:
"""Return the default concurrency for *scope_id* based on its
:func:`kind <_classify_scope>`.
Domain scopes default to :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, IP
scopes to :setting:`CONCURRENT_REQUESTS_PER_IP` (falling back to the
per-domain value when per-IP limiting is off, so IP-literal crawls are not
left unbounded), and any other scope to :setting:`THROTTLING_SCOPE_CONCURRENCY`.
"""
kind = _classify_scope(scope_id)
if kind == "ip":
return settings.getint("CONCURRENT_REQUESTS_PER_IP") or settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
)
if kind == "domain":
default_priority = SETTINGS_PRIORITIES["default"]
# An unset setting has no priority (``None``); treat it as below "default"
# so it never wins over one that is at least at its default value.
domain_priority = settings.getpriority("CONCURRENT_REQUESTS_PER_DOMAIN")
domain_priority = (
default_priority - 1 if domain_priority is None else domain_priority
)
scope_priority = settings.getpriority("THROTTLING_SCOPE_CONCURRENCY")
scope_priority = default_priority - 1 if scope_priority is None else scope_priority
if domain_priority > scope_priority:
return settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN")
if scope_priority > domain_priority:
return settings.getint("THROTTLING_SCOPE_CONCURRENCY")
# Equal priority: on an explicit (higher-than-default) tie the new setting
# wins; when neither is set (both at "default") keep the historical
# per-domain value so existing behavior is preserved.
if domain_priority <= default_priority:
return settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN")
return settings.getint("THROTTLING_SCOPE_CONCURRENCY")
def _warn_on_deprecated_concurrency(settings: BaseSettings) -> None:
"""Warn about the concurrency settings bridged by
:func:`_default_scope_concurrency`. Call once per crawl (see
:meth:`ThrottlingManager.__init__`)."""
default_priority = SETTINGS_PRIORITIES["default"]
domain_priority = settings.getpriority("CONCURRENT_REQUESTS_PER_DOMAIN")
scope_priority = settings.getpriority("THROTTLING_SCOPE_CONCURRENCY")
domain_set = domain_priority is not None and domain_priority > default_priority
scope_set = scope_priority is not None and scope_priority > default_priority
if domain_set:
warnings.warn(
"The CONCURRENT_REQUESTS_PER_DOMAIN setting is deprecated, use "
"THROTTLING_SCOPE_CONCURRENCY instead.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
elif not scope_set:
current = settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN")
future = settings.getint("THROTTLING_SCOPE_CONCURRENCY")
warnings.warn(
f"The default value of THROTTLING_SCOPE_CONCURRENCY will change "
f"from {current} to {future} in a future Scrapy version. Set "
f"THROTTLING_SCOPE_CONCURRENCY explicitly to silence this warning.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
def _to_scope_dict(collection: Any, default: Callable[[], Any]) -> dict[ScopeID, Any]:
"""Normalize *collection* (``None``, str, iterable or dict) into a dict
mapping scope names to values produced by *default*."""
@ -496,10 +502,8 @@ class ThrottlingManager:
def __init__(self, crawler: Crawler) -> None:
self.crawler = crawler
_warn_on_deprecated_concurrency(crawler.settings)
self._debug = crawler.settings.getbool("THROTTLING_DEBUG")
# When set, each request also gets an IP scope (its resolved address),
# enforced alongside its domain scope (see _resolve_scopes_sync).
self._per_ip: int = crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP")
self._max_idle = crawler.settings.getfloat("THROTTLING_SCOPE_MAX_IDLE")
self._robotstxt_obey = crawler.settings.getbool(
"ROBOTSTXT_OBEY"
@ -588,15 +592,7 @@ class ThrottlingManager:
stacklevel=2,
)
return cast("RequestScopes", download_slot)
netloc = urlparse_cached(request).netloc
if self._per_ip:
# Best-effort, mirroring the legacy per-IP downloader slots: use the
# cached resolved address if available, else fall back to the domain
# scope alone (the IP is not known until the host is resolved).
ip = dnscache.get(netloc)
if isinstance(ip, str) and ip != netloc:
return (netloc, ip)
return netloc
return urlparse_cached(request).netloc
def get_slot_key(self, request: Request) -> str:
scopes = self._resolve_scopes_sync(request)
@ -1196,7 +1192,7 @@ class ThrottlingScopeManager:
# Rampup starts conservative at a single slot and probes upward.
self._concurrency = 1
else:
self._concurrency = _default_scope_concurrency(settings, self._id) or None
self._concurrency = _default_scope_concurrency(settings) or None
# Used as the load denominator when the scope enforces no explicit
# concurrency limit (see get_load()).
self._global_concurrency: int = settings.getint("CONCURRENT_REQUESTS")

View File

@ -66,13 +66,16 @@ def get_crawler(
will be used to populate the crawler settings with a project level
priority.
"""
# When needed, useful settings can be added here, e.g. ones that prevent
# deprecation warnings (see prevent_warnings).
settings: dict[str, Any] = {
"TELNETCONSOLE_ENABLED": False,
**get_reactor_settings(),
**(settings_dict or {}),
}
if prevent_warnings:
# Pin the pre-deprecation default per-scope concurrency so the suite
# keeps its historical behavior and does not emit the warn-then-flip
# deprecation warning (see throttling._warn_on_deprecated_concurrency).
settings.setdefault("THROTTLING_SCOPE_CONCURRENCY", 8)
runner: CrawlerRunnerBase
if is_reactor_installed():
runner = CrawlerRunner(settings)

View File

@ -5,6 +5,7 @@ import logging
import re
import signal
import threading
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from unittest.mock import MagicMock
@ -52,6 +53,77 @@ def get_raw_crawler(
return Crawler(spidercls or DefaultSpider, settings)
class TestScopeConcurrencyBridge:
"""CONCURRENT_REQUESTS_PER_DOMAIN is deprecated and bridged into
THROTTLING_SCOPE_CONCURRENCY by setting priority (see
scrapy.throttling._default_scope_concurrency)."""
@staticmethod
def _resolve(settings: Settings) -> int:
from scrapy.throttling import _default_scope_concurrency # noqa: PLC0415
return _default_scope_concurrency(settings)
def test_neither_set_preserves_per_domain_default(self) -> None:
# Neither setting overridden: keep the historical per-domain default.
assert self._resolve(Settings()) == 8
def test_per_domain_overrides(self) -> None:
settings = Settings()
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="project")
assert self._resolve(settings) == 4
def test_scope_concurrency_overrides(self) -> None:
settings = Settings()
settings.set("THROTTLING_SCOPE_CONCURRENCY", 4, priority="project")
assert self._resolve(settings) == 4
def test_new_setting_wins_on_non_default_tie(self) -> None:
settings = Settings()
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="project")
settings.set("THROTTLING_SCOPE_CONCURRENCY", 9, priority="project")
assert self._resolve(settings) == 9
def test_higher_priority_deprecated_setting_wins(self) -> None:
settings = Settings()
settings.set("THROTTLING_SCOPE_CONCURRENCY", 9, priority="project")
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="cmdline")
assert self._resolve(settings) == 4
def test_warns_flip_when_neither_set(self) -> None:
with pytest.warns(
ScrapyDeprecationWarning,
match="THROTTLING_SCOPE_CONCURRENCY will change",
):
get_crawler(prevent_warnings=False)
def test_warns_deprecated_when_per_domain_set(self) -> None:
with pytest.warns(
ScrapyDeprecationWarning,
match="CONCURRENT_REQUESTS_PER_DOMAIN setting is deprecated",
):
get_crawler(
settings_dict={"CONCURRENT_REQUESTS_PER_DOMAIN": 4},
prevent_warnings=False,
)
def test_no_concurrency_warning_when_scope_set(self) -> None:
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
get_crawler(
settings_dict={"THROTTLING_SCOPE_CONCURRENCY": 4},
prevent_warnings=False,
)
messages = [str(w.message) for w in recorded]
assert not any(
"THROTTLING_SCOPE_CONCURRENCY will change" in m for m in messages
)
assert not any(
"CONCURRENT_REQUESTS_PER_DOMAIN setting is deprecated" in m
for m in messages
)
class TestBaseCrawler:
@staticmethod
def assertOptionIsDefault(settings: Settings, key: str) -> None:

View File

@ -769,6 +769,15 @@ def test_deprecated_dns_resolver_setting():
settings.get("DNS_RESOLVER")
def test_deprecated_concurrent_requests_per_ip_setting():
settings = Settings({"CONCURRENT_REQUESTS_PER_IP": 1})
with pytest.warns(
ScrapyDeprecationWarning,
match="The CONCURRENT_REQUESTS_PER_IP setting is deprecated",
):
settings.get("CONCURRENT_REQUESTS_PER_IP")
class Component1:
pass