Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 49 additions & 15 deletions src/crawlee/_utils/sitemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from hashlib import sha256
from http import HTTPStatus
from logging import getLogger
from typing import TYPE_CHECKING, Literal, TypedDict
from xml.sax import SAXParseException
Expand All @@ -19,8 +20,8 @@
from yarl import URL

from crawlee._utils.urls import filter_url
from crawlee._utils.web import is_status_code_successful
from crawlee.errors import ProxyError
from crawlee._utils.web import is_status_code_server_error, is_status_code_successful
from crawlee.errors import HttpStatusCodeError, ProxyError

if TYPE_CHECKING:
from collections.abc import AsyncGenerator
Expand All @@ -47,6 +48,21 @@
"""Default maximum depth of nested sitemaps to follow, guarding against malicious infinite sitemap chains."""


def _raise_for_sitemap_status(status_code: int) -> None:
"""Raise `HttpStatusCodeError` if the sitemap response status is not 2xx."""
if not HTTPStatus.OK <= status_code < HTTPStatus.MULTIPLE_CHOICES:
raise HttpStatusCodeError('Error status code returned while fetching sitemap', status_code)


def _is_retryable_sitemap_status(status_code: int) -> bool:
"""Return whether a sitemap response status should be retried."""
return (
HTTPStatus.MULTIPLE_CHOICES <= status_code < HTTPStatus.BAD_REQUEST
or status_code in (HTTPStatus.REQUEST_TIMEOUT, HTTPStatus.TOO_MANY_REQUESTS)
or is_status_code_server_error(status_code)
)
Comment on lines +57 to +63

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: retryability is settled, so this is just the cost - all three clients follow redirects by default and raise TooManyRedirects past the limit, so a 3xx reaching here is a 300, a 304, or a redirect with no Location, none of which a retry fixes. That is 2 wasted requests and 2s per such sitemap. Separately, discover_valid_sitemaps still gates on is_status_code_successful, which is 2xx or 3xx, so discovery can hand this function a URL it will retry three times and then skip.



@dataclass()
class SitemapUrl:
loc: str
Expand Down Expand Up @@ -376,6 +392,8 @@ async def _fetch_and_process_sitemap(
async with http_client.stream(
sitemap_url, method='GET', headers=SITEMAP_HEADERS, proxy_info=proxy_info, timeout=timeout
) as response:
_raise_for_sitemap_status(response.status_code)
Comment thread
vdusek marked this conversation as resolved.

# Determine content type and compression
content_type = response.headers.get('content-type', '')

Expand Down Expand Up @@ -459,9 +477,14 @@ async def _fetch_and_process_sitemap(
break

except Exception as e:
if isinstance(e, HttpStatusCodeError) and not _is_retryable_sitemap_status(e.status_code):
logger.warning(f'Skipping sitemap {sitemap_url} due to HTTP status code {e.status_code}.')
break
if retries_left > 0:
logger.warning(f'Error fetching sitemap {sitemap_url}: {e}. Retries left: {retries_left}')
await asyncio.sleep(1) # Brief pause before retry
elif isinstance(e, HttpStatusCodeError):
logger.warning(f'Failed to fetch sitemap {sitemap_url}, no retries left: {e}')
else:
logger.exception(f'Failed to fetch sitemap {sitemap_url}, no retries left.')
raise
Expand Down Expand Up @@ -537,6 +560,8 @@ async def parse_sitemap(
# Setup working state
sources = list(initial_sources)
visited_sitemap_urls: set[str] = set()
successful_sources = 0
source_errors: list[Exception] = []

# Process sources until the queue is empty
while sources:
Expand All @@ -559,6 +584,7 @@ async def parse_sitemap(
enqueue_strategy=enqueue_strategy,
):
yield result
successful_sources += 1

elif source['type'] == 'url' and 'url' in source:
# Add to visited set before processing to avoid duplicates
Expand All @@ -567,22 +593,30 @@ async def parse_sitemap(

visited_sitemap_urls.add(source['url'])

async for result in _fetch_and_process_sitemap(
http_client=http_client,
source=source,
depth=depth,
visited_sitemap_urls=visited_sitemap_urls,
sources=sources,
retries_left=sitemap_retries,
emit_nested_sitemaps=emit_nested_sitemaps,
enqueue_strategy=enqueue_strategy,
proxy_info=proxy_info,
timeout=timeout,
):
yield result
try:
async for result in _fetch_and_process_sitemap(
http_client=http_client,
source=source,
depth=depth,
visited_sitemap_urls=visited_sitemap_urls,
sources=sources,
retries_left=sitemap_retries,
emit_nested_sitemaps=emit_nested_sitemaps,
enqueue_strategy=enqueue_strategy,
proxy_info=proxy_info,
timeout=timeout,
):
yield result
successful_sources += 1
except Exception as e:
source_errors.append(e)
logger.warning(f'Failed to process sitemap source {source["url"]}: {e}')
else:
logger.warning(f'Invalid source configuration: {source}')

if source_errors and successful_sources == 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: successful_sources counts "did not raise", so a skipped sibling silences a real failure. Terminal 4xx and retry-exhausted statuses break out of _fetch_and_process_sitemap rather than raising, and raw sources can never fail at all, so all of them count as successes here:

Sitemap.load(['broken.xml'])                -> RAISED ConnectionError
Sitemap.load(['broken.xml', 'missing.xml']) -> NO RAISE, urls=[]    # missing.xml = 404

The same unreachable sitemap raises or not depending on its siblings, so callers cannot program against it. Sitemap.try_common_names hits this directly. Counting only a real 2xx parse as a success - letting the skip path be neither a success nor a source_error - keeps a lone 404 quiet while 404 + ConnectionError still raises.

raise source_errors[-1]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this discards every other failure and flips which source raises - before this PR the first failing source propagated.

Suggested change
raise source_errors[-1]
raise source_errors[0]



async def _merge_async_generators(*generators: AsyncGenerator) -> AsyncGenerator:
queue: asyncio.Queue = asyncio.Queue()
Expand Down
4 changes: 2 additions & 2 deletions src/crawlee/request_loaders/_request_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ async def is_empty(self) -> bool:

@abstractmethod
async def is_finished(self) -> bool:
"""Return True if all requests have been handled."""
"""Return True if all requests have been handled, or raise if loading failed after pending requests drain."""

@abstractmethod
async def fetch_next_request(self) -> Request | None:
"""Return the next request to be processed, or `None` if there are no more pending requests.

The method should return `None` if and only if `is_finished` would return `True`. In other cases, the method
should wait until a request appears.
should wait until a request appears. It can raise a loading error after all pending requests have been handled.
Comment on lines 46 to +49

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the retained first sentence is now false rather than merely incomplete - "return None if and only if is_finished would return True" no longer holds, because there is a third state where is_finished neither returns True nor False. The appended sentence papers over the contradiction instead of resolving it. SitemapRequestLoader.is_finished also still documents itself as just "Check if all URLs have been processed.", and the while not await loader.is_finished(): pattern in docs/guides/request_loaders.mdx can now throw from the loop condition.

"""

@abstractmethod
Expand Down
6 changes: 5 additions & 1 deletion src/crawlee/request_loaders/_sitemap_request_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ async def is_empty(self) -> bool:
async def is_finished(self) -> bool:
"""Check if all URLs have been processed."""
state = await self._get_state()
return not state.url_queue and len(state.in_progress) == 0 and self._loading_task.done()
if state.url_queue or state.in_progress:
return False
if self._loading_task.done() and not self._loading_task.cancelled():
self._loading_task.result()
Comment thread
vdusek marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: on the other timing path the same failure is swallowed entirely, so the scenario this PR opens by describing is still a clean empty success. When the loading task fails with no worker parked in the poll loop (unreachable sitemap, no other work), the raise leaves via AutoscaledPool._worker_task_orchestrator, whose finally completes run.result anyway - so crawler.run() returns finished=0 failed=0 and only logs, exactly as on master.

So the same failure either aborts the crawl with data loss or reports success, decided purely by timing. Whichever way this is resolved, it needs a BasicCrawler + to_tandem() test - the current loader-in-isolation test cannot see either path.

return self._loading_task.done()
Comment on lines +217 to +221

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: the guard only sees this loader's own buffer, so it does not protect the requests the crawl actually owns. RequestManagerTandem consults the loader first and outside any try - in is_finished (_request_manager_tandem.py:49) and again in fetch_next_request (_request_manager_tandem.py:77) - so once this raises, _read_write_manager is never reached.

With one unreachable sitemap plus two requests seeded via crawler.run([...]), master returns normally and crawls both seeded requests, while this branch raises ConnectionError out of crawler.run() and handles nothing. Same for requests enqueued mid-crawl through context.add_requests().

Either drop the raise and surface the sitemap failure some other way, or have the tandem consult _read_write_manager first so its pending work short-circuits the loader.


@override
async def fetch_next_request(self) -> Request | None:
Expand Down
91 changes: 88 additions & 3 deletions tests/unit/_utils/test_sitemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DEFAULT_MAX_DEPTH,
ParseSitemapOptions,
Sitemap,
SitemapSource,
SitemapUrl,
_TxtSitemapParser,
_XMLSaxSitemapHandler,
Expand All @@ -22,7 +23,13 @@
parse_sitemap,
)
from crawlee.http_clients._base import HttpClient, HttpResponse
from tests.unit.utils import DEFAULT_URL, get_basic_results, get_basic_sitemap
from tests.unit.utils import (
DEFAULT_URL,
get_basic_results,
get_basic_sitemap,
make_status_stream_client,
sleep_without_delay,
)

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable
Expand Down Expand Up @@ -60,6 +67,7 @@ async def read_stream() -> 'AsyncIterator[bytes]':
yield body

response = MagicMock(spec=HttpResponse)
response.status_code = 200
response.headers = {'content-type': 'application/xml; charset=utf-8'}
response.read_stream = read_stream
yield cast('HttpResponse', response)
Expand All @@ -81,6 +89,7 @@ async def read_stream() -> 'AsyncIterator[bytes]':
yield body_for_url(url)

response = MagicMock(spec=HttpResponse)
response.status_code = 200
response.headers = {'content-type': 'application/xml; charset=utf-8'}
response.read_stream = read_stream
yield cast('HttpResponse', response)
Expand Down Expand Up @@ -337,8 +346,9 @@ async def test_malformed_sitemap_keeps_urls() -> None:
assert sitemap.urls == [f'{DEFAULT_URL}first', f'{DEFAULT_URL}second']


async def test_sitemap_fetch_retries_on_transient_error() -> None:
async def test_sitemap_fetch_retries_on_transient_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Transient fetch errors are retried up to `sitemap_retries` times before giving up."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = _make_flaky_stream_client(get_basic_sitemap().encode(), fail_times=2)

items = [item async for item in parse_sitemap([{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'}], client)]
Expand All @@ -347,8 +357,9 @@ async def test_sitemap_fetch_retries_on_transient_error() -> None:
assert {item.loc for item in items} == get_basic_results()


async def test_sitemap_fetch_raises_after_retries_exhausted() -> None:
async def test_sitemap_fetch_raises_after_retries_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
"""A persistent fetch error is raised to the caller once all retries are exhausted."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = _make_flaky_stream_client(get_basic_sitemap().encode(), fail_times=10)

with pytest.raises(ConnectionError):
Expand All @@ -357,6 +368,78 @@ async def test_sitemap_fetch_raises_after_retries_exhausted() -> None:
assert len(attempts) == 3


async def test_sitemap_fetch_retries_retryable_http_status(monkeypatch: pytest.MonkeyPatch) -> None:
"""Retryable HTTP errors are retried before parsing a successful response."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = make_status_stream_client([(503, b''), (503, b''), (200, get_basic_sitemap().encode())])

items = [item async for item in parse_sitemap([{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'}], client)]

assert attempts == [503, 503, 200]
assert {item.loc for item in items} == get_basic_results()


async def test_sitemap_fetch_skips_http_error_after_retries_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
"""A persistent retryable HTTP error is skipped once retries are exhausted."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = make_status_stream_client([(503, b'')])

items = [item async for item in parse_sitemap([{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'}], client)]

assert attempts == [503, 503, 503]
assert items == []


async def test_sitemap_fetch_does_not_retry_terminal_http_status() -> None:
Comment thread
vdusek marked this conversation as resolved.
"""Terminal HTTP errors are skipped without parsing their response body or retrying."""
client, attempts = make_status_stream_client([(404, get_basic_sitemap().encode())])

items = [item async for item in parse_sitemap([{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'}], client)]

assert attempts == [404]
assert items == []


async def test_sitemap_fetch_retries_redirect_then_skips(monkeypatch: pytest.MonkeyPatch) -> None:
"""Redirect responses that reach the parser are retried and skipped after exhaustion."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = make_status_stream_client([(302, get_basic_sitemap().encode())])

items = [item async for item in parse_sitemap([{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'}], client)]

assert attempts == [302, 302, 302]
assert items == []


async def test_sitemap_partial_http_failure_keeps_healthy_source() -> None:
"""An HTTP failure in one source does not discard URLs from another source."""
client, attempts = make_status_stream_client([(200, get_basic_sitemap().encode()), (404, b'')])
sources: list[SitemapSource] = [
{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'},
{'type': 'url', 'url': f'{DEFAULT_URL}missing.xml'},
]

items = [item async for item in parse_sitemap(sources, client)]

assert attempts == [200, 404]
assert {item.loc for item in items} == get_basic_results()
Comment on lines +414 to +425

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this does not exercise what the name claims - a 404 source never raises out of _fetch_and_process_sitemap, so this test passes identically with the new try/except containment removed. Only test_sitemap_partial_fetch_failure_keeps_healthy_source covers it.

make_status_stream_client also dispatches by call order rather than by URL, so the assertions silently depend on source-iteration order. Keying the mock by URL and adding a skipped-source + raising-source case would cover the gap.



async def test_sitemap_partial_fetch_failure_keeps_healthy_source(monkeypatch: pytest.MonkeyPatch) -> None:
"""A fetch exception in one source is suppressed when another source succeeds."""
monkeypatch.setattr('crawlee._utils.sitemap.asyncio.sleep', AsyncMock(side_effect=sleep_without_delay))
client, attempts = _make_flaky_stream_client(get_basic_sitemap().encode(), fail_times=3)
sources: list[SitemapSource] = [
{'type': 'url', 'url': f'{DEFAULT_URL}broken.xml'},
{'type': 'url', 'url': f'{DEFAULT_URL}sitemap.xml'},
]

items = [item async for item in parse_sitemap(sources, client)]

assert len(attempts) == 4
assert {item.loc for item in items} == get_basic_results()


async def test_gzip_bomb_sitemap_truncated_at_size_cap(monkeypatch: pytest.MonkeyPatch) -> None:
"""A gzip sitemap inflating past the size cap is truncated instead of being decompressed without bound."""
monkeypatch.setattr('crawlee._utils.sitemap.MAX_SITEMAP_SIZE', 64 * 1024)
Expand Down Expand Up @@ -388,6 +471,7 @@ async def read_stream() -> 'AsyncIterator[bytes]':
yield b'\x00' * 65536

response = MagicMock(spec=HttpResponse)
response.status_code = 200
response.headers = {'content-type': 'application/gzip'}
response.read_stream = read_stream
yield cast('HttpResponse', response)
Expand All @@ -413,6 +497,7 @@ async def read_stream() -> 'AsyncIterator[bytes]':
yield body

response = MagicMock(spec=HttpResponse)
response.status_code = 200
response.headers = {'content-type': 'application/gzip'}
response.read_stream = read_stream
yield cast('HttpResponse', response)
Expand Down
Loading
Loading