mirror of https://github.com/scrapy/scrapy.git
Merge remote-tracking branch 'origin/master' into max-header-length
This commit is contained in:
commit
ce5ece4bdb
|
|
@ -243,12 +243,8 @@ Known limitations of the HTTP/2 support:
|
||||||
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
|
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
|
||||||
HTTP/2 unencrypted (refer `http2 faq`_).
|
HTTP/2 unencrypted (refer `http2 faq`_).
|
||||||
|
|
||||||
- No setting to specify a maximum `frame size`_ larger than the default
|
|
||||||
value, 16384. Connections to servers that send a larger frame will fail.
|
|
||||||
|
|
||||||
- No support for `server pushes`_, which are ignored.
|
- No support for `server pushes`_, which are ignored.
|
||||||
|
|
||||||
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
|
|
||||||
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
|
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
|
||||||
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
|
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1563,6 +1563,28 @@ Default: ``None``
|
||||||
|
|
||||||
The Project ID that will be used when storing data on `Google Cloud Storage`_.
|
The Project ID that will be used when storing data on `Google Cloud Storage`_.
|
||||||
|
|
||||||
|
.. setting:: HTTP2_MAX_FRAME_SIZE
|
||||||
|
|
||||||
|
HTTP2_MAX_FRAME_SIZE
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
.. versionadded:: VERSION
|
||||||
|
|
||||||
|
Default: ``16384``
|
||||||
|
|
||||||
|
Maximum `frame size`_, in bytes, that servers may send, between ``16384`` and
|
||||||
|
``16777215``. Connections to servers that send a larger frame fail.
|
||||||
|
|
||||||
|
Raise it for servers that send larger frames regardless of this value. Note
|
||||||
|
that :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` are checked
|
||||||
|
once per received frame, so a higher value allows a response to exceed them by
|
||||||
|
more before being caught.
|
||||||
|
|
||||||
|
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` ignores
|
||||||
|
this setting, as ``httpx`` does not allow configuring the frame size.
|
||||||
|
|
||||||
|
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
|
||||||
|
|
||||||
.. setting:: ITEM_PIPELINES
|
.. setting:: ITEM_PIPELINES
|
||||||
|
|
||||||
ITEM_PIPELINES
|
ITEM_PIPELINES
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from h2.events import (
|
||||||
WindowUpdated,
|
WindowUpdated,
|
||||||
)
|
)
|
||||||
from h2.exceptions import DenialOfServiceError, FrameTooLargeError, H2Error
|
from h2.exceptions import DenialOfServiceError, FrameTooLargeError, H2Error
|
||||||
|
from h2.settings import SettingCodes
|
||||||
from twisted.internet.interfaces import (
|
from twisted.internet.interfaces import (
|
||||||
IAddress,
|
IAddress,
|
||||||
IHandshakeListener,
|
IHandshakeListener,
|
||||||
|
|
@ -282,6 +283,9 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
|
||||||
|
|
||||||
# Initiate H2 Connection
|
# Initiate H2 Connection
|
||||||
self.conn.initiate_connection()
|
self.conn.initiate_connection()
|
||||||
|
max_frame_size = self._crawler.settings.getint("HTTP2_MAX_FRAME_SIZE")
|
||||||
|
if max_frame_size != self.conn.local_settings.max_frame_size:
|
||||||
|
self.conn.update_settings({SettingCodes.MAX_FRAME_SIZE: max_frame_size})
|
||||||
self._write_to_transport()
|
self._write_to_transport()
|
||||||
|
|
||||||
def _lose_connection_with_error(self, errors: list[BaseException]) -> None:
|
def _lose_connection_with_error(self, errors: list[BaseException]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
|
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from inspect import isasyncgenfunction
|
from inspect import isasyncgenfunction, iscoroutine
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
|
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
@ -244,8 +244,20 @@ class SpiderMiddlewareManager(MiddlewareManager):
|
||||||
warn(msg, category=ScrapyDeprecationWarning, stacklevel=2)
|
warn(msg, category=ScrapyDeprecationWarning, stacklevel=2)
|
||||||
self._set_compat_spider(spider)
|
self._set_compat_spider(spider)
|
||||||
start = self._spider.start()
|
start = self._spider.start()
|
||||||
|
if not hasattr(start, "__aiter__"):
|
||||||
|
if iscoroutine(start):
|
||||||
|
start.close()
|
||||||
|
start = self._reject_start(start)
|
||||||
return await self._process_chain("process_start", start)
|
return await self._process_chain("process_start", start)
|
||||||
|
|
||||||
|
async def _reject_start(self, start: Any) -> AsyncIterator[Any]:
|
||||||
|
raise TypeError(
|
||||||
|
f"{global_object_name(type(self._spider))}.start() must be an"
|
||||||
|
f" asynchronous generator, i.e. an async def method with yield"
|
||||||
|
f" statements, got {type(start)}"
|
||||||
|
)
|
||||||
|
yield # pylint: disable=unreachable # makes this method an asynchronous generator
|
||||||
|
|
||||||
# This method is only needed until _async compatibility methods are removed.
|
# This method is only needed until _async compatibility methods are removed.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_process_spider_output(mw: Any) -> Callable[..., Any] | None:
|
def _get_process_spider_output(mw: Any) -> Callable[..., Any] | None:
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ __all__ = [
|
||||||
"FTP_PASSWORD",
|
"FTP_PASSWORD",
|
||||||
"FTP_USER",
|
"FTP_USER",
|
||||||
"GCS_PROJECT_ID",
|
"GCS_PROJECT_ID",
|
||||||
|
"HTTP2_MAX_FRAME_SIZE",
|
||||||
"HTTPAUTH_DOMAIN",
|
"HTTPAUTH_DOMAIN",
|
||||||
"HTTPAUTH_PASS",
|
"HTTPAUTH_PASS",
|
||||||
"HTTPAUTH_USER",
|
"HTTPAUTH_USER",
|
||||||
|
|
@ -409,6 +410,8 @@ FTP_PASSWORD = "guest" # noqa: S105
|
||||||
|
|
||||||
GCS_PROJECT_ID = None
|
GCS_PROJECT_ID = None
|
||||||
|
|
||||||
|
HTTP2_MAX_FRAME_SIZE = 16384
|
||||||
|
|
||||||
HTTPAUTH_USER = ""
|
HTTPAUTH_USER = ""
|
||||||
HTTPAUTH_PASS = ""
|
HTTPAUTH_PASS = ""
|
||||||
HTTPAUTH_DOMAIN = None
|
HTTPAUTH_DOMAIN = None
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from collections import deque
|
||||||
from logging import ERROR
|
from logging import ERROR
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from scrapy import Request, Spider, signals
|
from scrapy import Request, Spider, signals
|
||||||
from scrapy.core.scheduler import BaseScheduler
|
from scrapy.core.scheduler import BaseScheduler
|
||||||
from scrapy.exceptions import CloseSpider
|
from scrapy.exceptions import CloseSpider
|
||||||
|
|
@ -13,7 +15,7 @@ from tests.mockserver.http import MockServer
|
||||||
from tests.utils.decorators import coroutine_test
|
from tests.utils.decorators import coroutine_test
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from scrapy.http import Response
|
from scrapy.http import Response
|
||||||
|
|
||||||
|
|
@ -50,6 +52,27 @@ class MemoryScheduler(BaseScheduler):
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
|
||||||
|
|
||||||
|
class NoneStartSpider(Spider):
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
def start(self) -> None: # type: ignore[override]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CoroutineStartSpider(Spider):
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
async def start(self) -> None: # type: ignore[override]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStartSpider(Spider):
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
def start(self) -> Iterator[Request]: # type: ignore[override]
|
||||||
|
yield Request("data:,a")
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@coroutine_test
|
@coroutine_test
|
||||||
async def test_sleep(self):
|
async def test_sleep(self):
|
||||||
|
|
@ -141,6 +164,34 @@ class TestMain:
|
||||||
assert crawler.stats.get_value("finish_reason") == "shutdown"
|
assert crawler.stats.get_value("finish_reason") == "shutdown"
|
||||||
assert not actual_urls
|
assert not actual_urls
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("spider_cls", "expected_type"),
|
||||||
|
[
|
||||||
|
(NoneStartSpider, "<class 'NoneType'>"),
|
||||||
|
(CoroutineStartSpider, "<class 'coroutine'>"),
|
||||||
|
(SyncStartSpider, "<class 'generator'>"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@coroutine_test
|
||||||
|
async def test_start_not_an_async_generator(
|
||||||
|
self,
|
||||||
|
spider_cls: type[Spider],
|
||||||
|
expected_type: str,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
crawler = get_crawler(spider_cls)
|
||||||
|
|
||||||
|
caplog.clear()
|
||||||
|
with caplog.at_level(ERROR):
|
||||||
|
await crawler.crawl_async()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
f"{spider_cls.__name__}.start() must be an asynchronous generator,"
|
||||||
|
f" i.e. an async def method with yield statements, got {expected_type}"
|
||||||
|
) in caplog.text
|
||||||
|
assert crawler.stats
|
||||||
|
assert crawler.stats.get_value("finish_reason") == "start_error"
|
||||||
|
|
||||||
@coroutine_test
|
@coroutine_test
|
||||||
async def test_start_error(self, caplog: pytest.LogCaptureFixture) -> None:
|
async def test_start_error(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
class TestSpider(Spider):
|
class TestSpider(Spider):
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
|
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
|
||||||
|
|
||||||
from scrapy.core._http2.protocol import H2ClientProtocol
|
from scrapy.core._http2.protocol import H2ClientProtocol
|
||||||
|
from scrapy.crawler import Crawler
|
||||||
|
|
||||||
|
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
|
|
@ -75,7 +76,7 @@ class Data:
|
||||||
STR_LARGE = generate_random_string(LARGE_SIZE)
|
STR_LARGE = generate_random_string(LARGE_SIZE)
|
||||||
|
|
||||||
EXTRA_SMALL = generate_random_string(1024 * 15)
|
EXTRA_SMALL = generate_random_string(1024 * 15)
|
||||||
EXTRA_LARGE = generate_random_string((1024**2) * 15)
|
EXTRA_LARGE = generate_random_string(LARGE_SIZE)
|
||||||
|
|
||||||
HTML_SMALL = make_html_body(STR_SMALL)
|
HTML_SMALL = make_html_body(STR_SMALL)
|
||||||
HTML_LARGE = make_html_body(STR_LARGE)
|
HTML_LARGE = make_html_body(STR_LARGE)
|
||||||
|
|
@ -236,9 +237,16 @@ class TestHttps2ClientProtocol:
|
||||||
) + self.certificate_file.read_text(encoding="utf-8")
|
) + self.certificate_file.read_text(encoding="utf-8")
|
||||||
return PrivateCertificate.loadPEM(pem) # type: ignore[no-any-return]
|
return PrivateCertificate.loadPEM(pem) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def crawler(self, request: pytest.FixtureRequest) -> Crawler:
|
||||||
|
return get_crawler(settings_dict=getattr(request, "param", None))
|
||||||
|
|
||||||
@async_yield_fixture # type: ignore[untyped-decorator]
|
@async_yield_fixture # type: ignore[untyped-decorator]
|
||||||
async def client(
|
async def client(
|
||||||
self, server_port: int, client_certificate: PrivateCertificate
|
self,
|
||||||
|
server_port: int,
|
||||||
|
client_certificate: PrivateCertificate,
|
||||||
|
crawler: Crawler,
|
||||||
) -> AsyncGenerator[H2ClientProtocol]:
|
) -> AsyncGenerator[H2ClientProtocol]:
|
||||||
from twisted.internet import reactor
|
from twisted.internet import reactor
|
||||||
|
|
||||||
|
|
@ -250,7 +258,7 @@ class TestHttps2ClientProtocol:
|
||||||
acceptableProtocols=[b"h2"],
|
acceptableProtocols=[b"h2"],
|
||||||
)
|
)
|
||||||
uri = URI.fromBytes(bytes(self.get_url(server_port, "/"), "utf-8"))
|
uri = URI.fromBytes(bytes(self.get_url(server_port, "/"), "utf-8"))
|
||||||
h2_client_factory = H2ClientFactory(uri, get_crawler(), Deferred())
|
h2_client_factory = H2ClientFactory(uri, crawler, Deferred())
|
||||||
client_endpoint = SSL4ClientEndpoint(
|
client_endpoint = SSL4ClientEndpoint(
|
||||||
reactor, self.host, server_port, client_options
|
reactor, self.host, server_port, client_options
|
||||||
)
|
)
|
||||||
|
|
@ -312,6 +320,17 @@ class TestHttps2ClientProtocol:
|
||||||
request = Request(self.get_url(server_port, "/get-data-html-large"))
|
request = Request(self.get_url(server_port, "/get-data-html-large"))
|
||||||
await self._check_GET(client, request, Data.HTML_LARGE, 200)
|
await self._check_GET(client, request, Data.HTML_LARGE, 200)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"crawler", [{"HTTP2_MAX_FRAME_SIZE": 1024**2}], indirect=True
|
||||||
|
)
|
||||||
|
@deferred_f_from_coro_f
|
||||||
|
async def test_GET_large_frames(
|
||||||
|
self, server_port: int, client: H2ClientProtocol
|
||||||
|
) -> None:
|
||||||
|
request = Request(self.get_url(server_port, "/get-data-html-large"))
|
||||||
|
await self._check_GET(client, request, Data.HTML_LARGE, 200)
|
||||||
|
assert client.conn.local_settings.max_frame_size == 1024**2
|
||||||
|
|
||||||
async def _check_GET_x10(
|
async def _check_GET_x10(
|
||||||
self,
|
self,
|
||||||
client: H2ClientProtocol,
|
client: H2ClientProtocol,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue