mirror of https://github.com/scrapy/scrapy.git
Extend download error coverage
This commit is contained in:
parent
27092b2cb7
commit
b90842379d
|
|
@ -118,10 +118,16 @@ these exceptions.
|
||||||
|
|
||||||
.. autoexception:: scrapy.exceptions.DownloadCancelledError
|
.. autoexception:: scrapy.exceptions.DownloadCancelledError
|
||||||
|
|
||||||
|
.. autoexception:: scrapy.exceptions.DownloadConnectBindError
|
||||||
|
|
||||||
.. autoexception:: scrapy.exceptions.DownloadConnectionRefusedError
|
.. autoexception:: scrapy.exceptions.DownloadConnectionRefusedError
|
||||||
|
|
||||||
.. autoexception:: scrapy.exceptions.DownloadFailedError
|
.. autoexception:: scrapy.exceptions.DownloadFailedError
|
||||||
|
|
||||||
|
.. autoexception:: scrapy.exceptions.DownloadNoRouteError
|
||||||
|
|
||||||
|
.. autoexception:: scrapy.exceptions.DownloadTCPTimedOutError
|
||||||
|
|
||||||
.. autoexception:: scrapy.exceptions.DownloadTimeoutError
|
.. autoexception:: scrapy.exceptions.DownloadTimeoutError
|
||||||
|
|
||||||
.. autoexception:: scrapy.exceptions.ResponseDataLossError
|
.. autoexception:: scrapy.exceptions.ResponseDataLossError
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from http.cookiejar import Cookie, CookieJar
|
from http.cookiejar import Cookie, CookieJar
|
||||||
|
|
@ -14,8 +16,11 @@ from scrapy import Request, signals
|
||||||
from scrapy.exceptions import (
|
from scrapy.exceptions import (
|
||||||
CannotResolveHostError,
|
CannotResolveHostError,
|
||||||
DownloadCancelledError,
|
DownloadCancelledError,
|
||||||
|
DownloadConnectBindError,
|
||||||
DownloadConnectionRefusedError,
|
DownloadConnectionRefusedError,
|
||||||
DownloadFailedError,
|
DownloadFailedError,
|
||||||
|
DownloadNoRouteError,
|
||||||
|
DownloadTCPTimedOutError,
|
||||||
DownloadTimeoutError,
|
DownloadTimeoutError,
|
||||||
NotConfigured,
|
NotConfigured,
|
||||||
ResponseDataLossError,
|
ResponseDataLossError,
|
||||||
|
|
@ -53,6 +58,82 @@ except ImportError:
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _errno_values(*names: str) -> tuple[int, ...]:
|
||||||
|
return tuple(
|
||||||
|
value for name in names if (value := getattr(errno, name, None)) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONNECT_ERRNO_MAP: tuple[tuple[tuple[int, ...], type[Exception]], ...] = (
|
||||||
|
(_errno_values("ECONNREFUSED", "WSAECONNREFUSED"), DownloadConnectionRefusedError),
|
||||||
|
(
|
||||||
|
_errno_values(
|
||||||
|
"ENETUNREACH",
|
||||||
|
"EHOSTUNREACH",
|
||||||
|
"WSAENETUNREACH",
|
||||||
|
"WSAEHOSTUNREACH",
|
||||||
|
),
|
||||||
|
DownloadNoRouteError,
|
||||||
|
),
|
||||||
|
(_errno_values("ETIMEDOUT", "WSAETIMEDOUT"), DownloadTCPTimedOutError),
|
||||||
|
(
|
||||||
|
_errno_values(
|
||||||
|
"EADDRINUSE",
|
||||||
|
"EADDRNOTAVAIL",
|
||||||
|
"WSAEADDRINUSE",
|
||||||
|
"WSAEADDRNOTAVAIL",
|
||||||
|
),
|
||||||
|
DownloadConnectBindError,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_nested_os_error(exc: BaseException) -> OSError | None:
|
||||||
|
fallback_os_error: OSError | None = None
|
||||||
|
to_visit: list[BaseException] = [exc]
|
||||||
|
seen: set[int] = set()
|
||||||
|
while to_visit:
|
||||||
|
current = to_visit.pop()
|
||||||
|
if id(current) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(current))
|
||||||
|
if isinstance(current, OSError):
|
||||||
|
if isinstance(current, socket.gaierror) or current.errno is not None:
|
||||||
|
return current
|
||||||
|
if fallback_os_error is None:
|
||||||
|
fallback_os_error = current
|
||||||
|
|
||||||
|
next_exceptions = [current.__cause__, current.__context__]
|
||||||
|
to_visit.extend(
|
||||||
|
next_exception
|
||||||
|
for next_exception in next_exceptions
|
||||||
|
if isinstance(next_exception, BaseException)
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped_exceptions = getattr(current, "exceptions", None)
|
||||||
|
if isinstance(grouped_exceptions, tuple):
|
||||||
|
to_visit.extend(
|
||||||
|
nested_exception
|
||||||
|
for nested_exception in grouped_exceptions
|
||||||
|
if isinstance(nested_exception, BaseException)
|
||||||
|
)
|
||||||
|
|
||||||
|
return fallback_os_error
|
||||||
|
|
||||||
|
|
||||||
|
def _to_scrapy_connect_error(exc: httpx.ConnectError) -> Exception:
|
||||||
|
os_error = _find_nested_os_error(exc)
|
||||||
|
message = str(exc)
|
||||||
|
if os_error is None:
|
||||||
|
return DownloadFailedError(message)
|
||||||
|
if isinstance(os_error, socket.gaierror):
|
||||||
|
return CannotResolveHostError(message)
|
||||||
|
for errnos, scrapy_exc_cls in _CONNECT_ERRNO_MAP:
|
||||||
|
if os_error.errno in errnos:
|
||||||
|
return scrapy_exc_cls(message)
|
||||||
|
return DownloadFailedError(message)
|
||||||
|
|
||||||
|
|
||||||
class _BaseResponseArgs(TypedDict):
|
class _BaseResponseArgs(TypedDict):
|
||||||
status: int
|
status: int
|
||||||
url: str
|
url: str
|
||||||
|
|
@ -133,6 +214,10 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler):
|
||||||
async with self._get_httpx_response(request, timeout) as httpx_response:
|
async with self._get_httpx_response(request, timeout) as httpx_response:
|
||||||
request.meta["download_latency"] = time.monotonic() - start_time
|
request.meta["download_latency"] = time.monotonic() - start_time
|
||||||
return await self._read_response(httpx_response, request)
|
return await self._read_response(httpx_response, request)
|
||||||
|
except httpx.ConnectTimeout as e:
|
||||||
|
raise DownloadTCPTimedOutError(
|
||||||
|
f"Connecting to {request.url} took longer than {timeout} seconds."
|
||||||
|
) from e
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
raise DownloadTimeoutError(
|
raise DownloadTimeoutError(
|
||||||
f"Getting {request.url} took longer than {timeout} seconds."
|
f"Getting {request.url} took longer than {timeout} seconds."
|
||||||
|
|
@ -140,15 +225,7 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler):
|
||||||
except httpx.UnsupportedProtocol as e:
|
except httpx.UnsupportedProtocol as e:
|
||||||
raise UnsupportedURLSchemeError(str(e)) from e
|
raise UnsupportedURLSchemeError(str(e)) from e
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError as e:
|
||||||
error_message = str(e)
|
raise _to_scrapy_connect_error(e) from e
|
||||||
if (
|
|
||||||
"Name or service not known" in error_message
|
|
||||||
or "getaddrinfo failed" in error_message
|
|
||||||
or "nodename nor servname" in error_message
|
|
||||||
or "Temporary failure in name resolution" in error_message
|
|
||||||
):
|
|
||||||
raise CannotResolveHostError(error_message) from e
|
|
||||||
raise DownloadConnectionRefusedError(str(e)) from e
|
|
||||||
except httpx.NetworkError as e:
|
except httpx.NetworkError as e:
|
||||||
raise DownloadFailedError(str(e)) from e
|
raise DownloadFailedError(str(e)) from e
|
||||||
except httpx.RemoteProtocolError as e:
|
except httpx.RemoteProtocolError as e:
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,22 @@ class DownloadConnectionRefusedError(Exception):
|
||||||
"""Indicates that a connection was refused by the server."""
|
"""Indicates that a connection was refused by the server."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadConnectBindError(Exception):
|
||||||
|
"""Indicates that binding to a local address failed."""
|
||||||
|
|
||||||
|
|
||||||
class CannotResolveHostError(Exception):
|
class CannotResolveHostError(Exception):
|
||||||
"""Indicates that the provided hostname cannot be resolved."""
|
"""Indicates that the provided hostname cannot be resolved."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadNoRouteError(Exception):
|
||||||
|
"""Indicates that no route to the remote host exists."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadTCPTimedOutError(Exception):
|
||||||
|
"""Indicates that establishing a TCP connection timed out."""
|
||||||
|
|
||||||
|
|
||||||
class DownloadTimeoutError(Exception):
|
class DownloadTimeoutError(Exception):
|
||||||
"""Indicates that a request download has timed out."""
|
"""Indicates that a request download has timed out."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -458,8 +458,11 @@ REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter"
|
||||||
RETRY_ENABLED = True
|
RETRY_ENABLED = True
|
||||||
RETRY_EXCEPTIONS = [
|
RETRY_EXCEPTIONS = [
|
||||||
"scrapy.exceptions.CannotResolveHostError",
|
"scrapy.exceptions.CannotResolveHostError",
|
||||||
|
"scrapy.exceptions.DownloadConnectBindError",
|
||||||
"scrapy.exceptions.DownloadConnectionRefusedError",
|
"scrapy.exceptions.DownloadConnectionRefusedError",
|
||||||
"scrapy.exceptions.DownloadFailedError",
|
"scrapy.exceptions.DownloadFailedError",
|
||||||
|
"scrapy.exceptions.DownloadNoRouteError",
|
||||||
|
"scrapy.exceptions.DownloadTCPTimedOutError",
|
||||||
"scrapy.exceptions.DownloadTimeoutError",
|
"scrapy.exceptions.DownloadTimeoutError",
|
||||||
"scrapy.exceptions.ResponseDataLossError",
|
"scrapy.exceptions.ResponseDataLossError",
|
||||||
"twisted.internet.error.ConnectionDone",
|
"twisted.internet.error.ConnectionDone",
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ from contextlib import contextmanager
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from twisted.internet.defer import CancelledError
|
from twisted.internet.defer import CancelledError
|
||||||
|
from twisted.internet.error import ConnectBindError as TxConnectBindError
|
||||||
|
from twisted.internet.error import ConnectError as TxConnectError
|
||||||
from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError
|
from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError
|
||||||
from twisted.internet.error import DNSLookupError
|
from twisted.internet.error import DNSLookupError
|
||||||
|
from twisted.internet.error import NoRouteError as TxNoRouteError
|
||||||
|
from twisted.internet.error import TCPTimedOutError as TxTCPTimedOutError
|
||||||
from twisted.internet.error import TimeoutError as TxTimeoutError
|
from twisted.internet.error import TimeoutError as TxTimeoutError
|
||||||
|
from twisted.internet.error import UnknownHostError as TxUnknownHostError
|
||||||
from twisted.python.failure import Failure
|
from twisted.python.failure import Failure
|
||||||
from twisted.web.client import ResponseFailed
|
from twisted.web.client import ResponseFailed
|
||||||
from twisted.web.error import SchemeNotSupported
|
from twisted.web.error import SchemeNotSupported
|
||||||
|
|
@ -19,8 +24,11 @@ from scrapy.core.downloader.handlers.base import BaseDownloadHandler
|
||||||
from scrapy.exceptions import (
|
from scrapy.exceptions import (
|
||||||
CannotResolveHostError,
|
CannotResolveHostError,
|
||||||
DownloadCancelledError,
|
DownloadCancelledError,
|
||||||
|
DownloadConnectBindError,
|
||||||
DownloadConnectionRefusedError,
|
DownloadConnectionRefusedError,
|
||||||
DownloadFailedError,
|
DownloadFailedError,
|
||||||
|
DownloadNoRouteError,
|
||||||
|
DownloadTCPTimedOutError,
|
||||||
DownloadTimeoutError,
|
DownloadTimeoutError,
|
||||||
StopDownload,
|
StopDownload,
|
||||||
UnsupportedURLSchemeError,
|
UnsupportedURLSchemeError,
|
||||||
|
|
@ -54,6 +62,44 @@ class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
|
||||||
self._fail_on_dataloss_warned: bool = False
|
self._fail_on_dataloss_warned: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
_TWISTED_CONNECT_ERROR_MAP: tuple[
|
||||||
|
tuple[type[TxConnectError], type[Exception]],
|
||||||
|
...,
|
||||||
|
] = (
|
||||||
|
(TxConnectionRefusedError, DownloadConnectionRefusedError),
|
||||||
|
(TxConnectBindError, DownloadConnectBindError),
|
||||||
|
(TxUnknownHostError, CannotResolveHostError),
|
||||||
|
(TxNoRouteError, DownloadNoRouteError),
|
||||||
|
(TxTCPTimedOutError, DownloadTCPTimedOutError),
|
||||||
|
(TxTimeoutError, DownloadTimeoutError),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_twisted_connect_exception(exc: TxConnectError) -> Exception:
|
||||||
|
message = str(exc)
|
||||||
|
for twisted_exc_cls, scrapy_exc_cls in _TWISTED_CONNECT_ERROR_MAP:
|
||||||
|
if isinstance(exc, twisted_exc_cls):
|
||||||
|
return scrapy_exc_cls(message)
|
||||||
|
return DownloadFailedError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_response_failed_exception(exc: ResponseFailed) -> Exception | None:
|
||||||
|
mapped_exception: Exception | None = None
|
||||||
|
for reason in exc.reasons:
|
||||||
|
if isinstance(reason.value, TxConnectError):
|
||||||
|
current_exception: Exception = _map_twisted_connect_exception(reason.value)
|
||||||
|
elif isinstance(reason.value, DNSLookupError):
|
||||||
|
current_exception = CannotResolveHostError(str(reason.value))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if mapped_exception is None:
|
||||||
|
mapped_exception = current_exception
|
||||||
|
continue
|
||||||
|
if type(mapped_exception) is not type(current_exception):
|
||||||
|
return None
|
||||||
|
return mapped_exception
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def wrap_twisted_exceptions() -> Iterator[None]:
|
def wrap_twisted_exceptions() -> Iterator[None]:
|
||||||
"""Context manager that wraps Twisted exceptions into Scrapy exceptions."""
|
"""Context manager that wraps Twisted exceptions into Scrapy exceptions."""
|
||||||
|
|
@ -63,14 +109,14 @@ def wrap_twisted_exceptions() -> Iterator[None]:
|
||||||
raise UnsupportedURLSchemeError(str(e)) from e
|
raise UnsupportedURLSchemeError(str(e)) from e
|
||||||
except CancelledError as e:
|
except CancelledError as e:
|
||||||
raise DownloadCancelledError(str(e)) from e
|
raise DownloadCancelledError(str(e)) from e
|
||||||
except TxConnectionRefusedError as e:
|
|
||||||
raise DownloadConnectionRefusedError(str(e)) from e
|
|
||||||
except DNSLookupError as e:
|
except DNSLookupError as e:
|
||||||
raise CannotResolveHostError(str(e)) from e
|
raise CannotResolveHostError(str(e)) from e
|
||||||
|
except TxConnectError as e:
|
||||||
|
raise _map_twisted_connect_exception(e) from e
|
||||||
except ResponseFailed as e:
|
except ResponseFailed as e:
|
||||||
|
if mapped_exception := _map_response_failed_exception(e):
|
||||||
|
raise mapped_exception from e
|
||||||
raise DownloadFailedError(str(e)) from e
|
raise DownloadFailedError(str(e)) from e
|
||||||
except TxTimeoutError as e:
|
|
||||||
raise DownloadTimeoutError(str(e)) from e
|
|
||||||
|
|
||||||
|
|
||||||
def check_stop_download(
|
def check_stop_download(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import gzip
|
||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -22,6 +23,7 @@ from twisted.python.failure import Failure
|
||||||
from scrapy.exceptions import (
|
from scrapy.exceptions import (
|
||||||
CannotResolveHostError,
|
CannotResolveHostError,
|
||||||
DownloadCancelledError,
|
DownloadCancelledError,
|
||||||
|
DownloadConnectBindError,
|
||||||
DownloadConnectionRefusedError,
|
DownloadConnectionRefusedError,
|
||||||
DownloadFailedError,
|
DownloadFailedError,
|
||||||
DownloadTimeoutError,
|
DownloadTimeoutError,
|
||||||
|
|
@ -523,6 +525,15 @@ class TestHttpBase(ABC):
|
||||||
class TestHttp11Base(TestHttpBase):
|
class TestHttp11Base(TestHttpBase):
|
||||||
"""HTTP 1.1 test case"""
|
"""HTTP 1.1 test case"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _system_allows_nonlocal_bind() -> bool:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
try:
|
||||||
|
sock.bind(("198.51.100.1", 0))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@coroutine_test
|
@coroutine_test
|
||||||
async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None:
|
async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None:
|
||||||
request = Request(mockserver.url("/text", is_secure=self.is_secure))
|
request = Request(mockserver.url("/text", is_secure=self.is_secure))
|
||||||
|
|
@ -748,6 +759,18 @@ class TestHttp11Base(TestHttpBase):
|
||||||
response = await download_handler.download_request(request)
|
response = await download_handler.download_request(request)
|
||||||
assert response.body == b"127.0.0.2"
|
assert response.body == b"127.0.0.2"
|
||||||
|
|
||||||
|
@coroutine_test
|
||||||
|
async def test_download_bind_address_nonlocal(self, mockserver: MockServer) -> None:
|
||||||
|
if self._system_allows_nonlocal_bind():
|
||||||
|
pytest.skip("This system allows binding to non-local addresses")
|
||||||
|
|
||||||
|
request = Request(mockserver.url("/text", is_secure=self.is_secure))
|
||||||
|
async with self.get_dh(
|
||||||
|
{"DOWNLOAD_BIND_ADDRESS": ("198.51.100.1", 0)}
|
||||||
|
) as download_handler:
|
||||||
|
with pytest.raises(DownloadConnectBindError):
|
||||||
|
await download_handler.download_request(request)
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
sys.platform == "darwin",
|
sys.platform == "darwin",
|
||||||
reason="127.0.0.2 is not available on macOS by default",
|
reason="127.0.0.2 is not available on macOS by default",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ from twisted.internet.error import ConnectError, ConnectionDone, ConnectionLost
|
||||||
from scrapy.downloadermiddlewares.retry import RetryMiddleware, get_retry_request
|
from scrapy.downloadermiddlewares.retry import RetryMiddleware, get_retry_request
|
||||||
from scrapy.exceptions import (
|
from scrapy.exceptions import (
|
||||||
CannotResolveHostError,
|
CannotResolveHostError,
|
||||||
|
DownloadConnectBindError,
|
||||||
DownloadConnectionRefusedError,
|
DownloadConnectionRefusedError,
|
||||||
|
DownloadNoRouteError,
|
||||||
|
DownloadTCPTimedOutError,
|
||||||
DownloadTimeoutError,
|
DownloadTimeoutError,
|
||||||
IgnoreRequest,
|
IgnoreRequest,
|
||||||
)
|
)
|
||||||
|
|
@ -90,8 +93,11 @@ class TestRetry:
|
||||||
ConnectionDone,
|
ConnectionDone,
|
||||||
ConnectionLost,
|
ConnectionLost,
|
||||||
DownloadTimeoutError,
|
DownloadTimeoutError,
|
||||||
|
DownloadTCPTimedOutError,
|
||||||
DownloadConnectionRefusedError,
|
DownloadConnectionRefusedError,
|
||||||
|
DownloadConnectBindError,
|
||||||
CannotResolveHostError,
|
CannotResolveHostError,
|
||||||
|
DownloadNoRouteError,
|
||||||
]
|
]
|
||||||
|
|
||||||
for exc in exceptions:
|
for exc in exceptions:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue