Merge remote-tracking branch 'origin/master' into max-header-length

This commit is contained in:
Adrian Chaves 2026-08-14 14:38:09 +02:00
commit ce5ece4bdb
7 changed files with 116 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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):

View File

@ -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,