Skip to content

Commit db9444f

Browse files
authored
fix(scrapy): skip a request that fails to convert instead of crashing the run (#952)
`ApifyScheduler.next_request` marked an Apify request as handled before converting it to a Scrapy request, so a single malformed queue entry raised and crashed the whole run (after the entry had already been consumed). The conversion is now wrapped in a try/except: it logs the failure and skips the request (returns `None`) instead of propagating.
1 parent a6b6839 commit db9444f

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

src/apify/scrapy/scheduler.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,13 @@ def next_request(self) -> Request | None:
170170
traceback.print_exc()
171171
raise
172172

173-
scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
173+
# Reconstruct the Scrapy request. A malformed queue entry must not crash the whole run: it
174+
# has already been marked handled above, so log it and skip it instead of propagating.
175+
try:
176+
scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
177+
except Exception:
178+
logger.exception(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it.')
179+
return None
180+
174181
logger.debug(f'Converted to scrapy_request: {scrapy_request}')
175182
return scrapy_request
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import cast
5+
from unittest import mock
6+
7+
import pytest
8+
from scrapy import Request, Spider
9+
10+
from apify import Request as ApifyRequest
11+
from apify.scrapy.scheduler import ApifyScheduler
12+
from apify.storages import RequestQueue
13+
14+
15+
class DummySpider(Spider):
16+
name = 'dummy_spider'
17+
18+
19+
@pytest.fixture
20+
def spider() -> DummySpider:
21+
"""Fixture to create a "dummy" Scrapy spider."""
22+
return DummySpider()
23+
24+
25+
@pytest.fixture
26+
def scheduler(monkeypatch: pytest.MonkeyPatch, spider: DummySpider) -> ApifyScheduler:
27+
"""Create a scheduler with its reactor check and async thread stubbed out.
28+
29+
The request queue is a plain mock that satisfies the `isinstance` checks; the `run_coro` results
30+
are set per test via the mocked async thread.
31+
"""
32+
monkeypatch.setattr('apify.scrapy.scheduler.is_asyncio_reactor_installed', lambda: True)
33+
monkeypatch.setattr('apify.scrapy.scheduler.AsyncThread', mock.MagicMock())
34+
35+
scheduler = ApifyScheduler()
36+
scheduler.spider = spider
37+
38+
rq = mock.MagicMock()
39+
rq.__class__ = RequestQueue
40+
scheduler._rq = rq
41+
42+
return scheduler
43+
44+
45+
def test_next_request_skips_request_that_fails_to_convert(
46+
scheduler: ApifyScheduler,
47+
caplog: pytest.LogCaptureFixture,
48+
) -> None:
49+
rq = cast('mock.MagicMock', scheduler._rq)
50+
async_thread = cast('mock.MagicMock', scheduler._async_thread)
51+
52+
# A queue entry whose encoded Scrapy request is malformed; `to_scrapy_request` raises on it.
53+
malformed_request = ApifyRequest(
54+
url='https://example.com',
55+
method='GET',
56+
unique_key='https://example.com',
57+
user_data={'scrapy_request': 'this is not a correctly encoded Scrapy request'},
58+
)
59+
60+
# `run_coro` is called for `fetch_next_request`, then for `mark_request_as_handled`.
61+
async_thread.run_coro.side_effect = [malformed_request, None]
62+
63+
with caplog.at_level(logging.ERROR, logger='apify.scrapy.scheduler'):
64+
result = scheduler.next_request()
65+
66+
# The malformed request is skipped instead of crashing the whole run.
67+
assert result is None
68+
assert 'skipping it' in caplog.text
69+
70+
# It was still marked as handled before the failed conversion, so it is not retried forever.
71+
rq.mark_request_as_handled.assert_called_once_with(malformed_request)
72+
73+
74+
def test_next_request_returns_converted_request(scheduler: ApifyScheduler) -> None:
75+
rq = cast('mock.MagicMock', scheduler._rq)
76+
async_thread = cast('mock.MagicMock', scheduler._async_thread)
77+
78+
apify_request = ApifyRequest(
79+
url='https://example.com',
80+
method='GET',
81+
unique_key='https://example.com',
82+
user_data={},
83+
)
84+
async_thread.run_coro.side_effect = [apify_request, None]
85+
86+
result = scheduler.next_request()
87+
88+
assert isinstance(result, Request)
89+
assert result.url == apify_request.url
90+
rq.mark_request_as_handled.assert_called_once_with(apify_request)

0 commit comments

Comments
 (0)