Skip to content

Commit 50aa539

Browse files
committed
fix(scrapy): keep traceback.print_exc() on background-loop coroutine errors
1 parent 2275ad3 commit 50aa539

4 files changed

Lines changed: 55 additions & 24 deletions

File tree

src/apify/scrapy/extensions/_httpcache.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io
55
import re
66
import struct
7+
import traceback
78
from datetime import timedelta
89
from logging import getLogger
910
from time import time
@@ -39,8 +40,8 @@ def __init__(self, settings: BaseSettings) -> None:
3940
# Upper bound on how many keys the per-spider-close cleanup sweeps (best-effort; `close_spider`).
4041
self._expiration_max_items: int = settings.getint('APIFY_HTTPCACHE_EXPIRATION_MAX_ITEMS', 100)
4142
self._expiration_secs: int = settings.getint('HTTPCACHE_EXPIRATION_SECS')
42-
# Caps how long each coroutine run on the background event loop may take; defaults to 60 seconds.
4343
self._async_thread_timeout = timedelta(seconds=settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60))
44+
"""Caps how long each coroutine run on the background event loop may take; defaults to 60 seconds."""
4445
self._spider: Spider | None = None
4546
self._kvs: KeyValueStore | None = None
4647
self._fingerprinter: RequestFingerprinterProtocol | None = None
@@ -67,7 +68,11 @@ async def open_kvs() -> KeyValueStore:
6768
logger.debug("Starting background thread for cache storage's event loop")
6869
self._async_thread = AsyncThread(default_timeout=self._async_thread_timeout)
6970
logger.debug(f"Opening cache storage's {kvs_name!r} key value store")
70-
self._kvs = self._async_thread.run_coro(open_kvs())
71+
try:
72+
self._kvs = self._async_thread.run_coro(open_kvs())
73+
except Exception:
74+
traceback.print_exc()
75+
raise
7176

7277
def close_spider(self, _: Spider, current_time: int | None = None) -> None:
7378
"""Close the cache storage for a spider."""
@@ -106,7 +111,11 @@ async def expire_kvs() -> None:
106111
else:
107112
logger.debug(f'Valid cache item {item.key}')
108113

109-
self._async_thread.run_coro(expire_kvs())
114+
try:
115+
self._async_thread.run_coro(expire_kvs())
116+
except Exception:
117+
traceback.print_exc()
118+
raise
110119
finally:
111120
logger.debug('Closing cache storage')
112121
try:
@@ -128,7 +137,11 @@ def retrieve_response(self, _: Spider, request: Request, current_time: int | Non
128137
raise ValueError('Request fingerprinter not initialized')
129138

130139
key = self._fingerprinter.fingerprint(request).hex()
131-
value = self._async_thread.run_coro(self._kvs.get_value(key))
140+
try:
141+
value = self._async_thread.run_coro(self._kvs.get_value(key))
142+
except Exception:
143+
traceback.print_exc()
144+
raise
132145

133146
if value is None:
134147
logger.debug('Cache miss', extra={'request': request})
@@ -175,7 +188,11 @@ def store_response(self, _: Spider, request: Request, response: Response) -> Non
175188
'body': response.body,
176189
}
177190
value = to_gzip(data)
178-
self._async_thread.run_coro(self._kvs.set_value(key, value))
191+
try:
192+
self._async_thread.run_coro(self._kvs.set_value(key, value))
193+
except Exception:
194+
traceback.print_exc()
195+
raise
179196

180197

181198
def to_gzip(data: dict, mtime: int | None = None) -> bytes:

src/apify/scrapy/scheduler.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import traceback
34
from datetime import timedelta
45
from logging import getLogger
56
from typing import TYPE_CHECKING
@@ -73,6 +74,7 @@ async def open_rq() -> RequestQueue:
7374
self._rq = self._async_thread.run_coro(open_rq())
7475
except Exception:
7576
self._async_thread.close()
77+
traceback.print_exc()
7678
raise
7779

7880
return None
@@ -107,7 +109,12 @@ def has_pending_requests(self) -> bool:
107109
if not isinstance(self._rq, RequestQueue):
108110
raise TypeError('self._rq must be an instance of the RequestQueue class')
109111

110-
is_finished = self._async_thread.run_coro(self._rq.is_finished())
112+
try:
113+
is_finished = self._async_thread.run_coro(self._rq.is_finished())
114+
except Exception:
115+
traceback.print_exc()
116+
raise
117+
111118
return not is_finished
112119

113120
def enqueue_request(self, request: Request) -> bool:
@@ -135,7 +142,12 @@ def enqueue_request(self, request: Request) -> bool:
135142
if not isinstance(self._rq, RequestQueue):
136143
raise TypeError('self._rq must be an instance of the RequestQueue class')
137144

138-
result = self._async_thread.run_coro(self._rq.add_request(apify_request))
145+
try:
146+
result = self._async_thread.run_coro(self._rq.add_request(apify_request))
147+
except Exception:
148+
traceback.print_exc()
149+
raise
150+
139151
logger.debug(f'rq.add_request result: {result}')
140152
return not bool(result.was_already_present)
141153

@@ -149,7 +161,12 @@ def next_request(self) -> Request | None:
149161
if not isinstance(self._rq, RequestQueue):
150162
raise TypeError('self._rq must be an instance of the RequestQueue class')
151163

152-
apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
164+
try:
165+
apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
166+
except Exception:
167+
traceback.print_exc()
168+
raise
169+
153170
logger.debug(f'Fetched apify_request: {apify_request}')
154171
if apify_request is None:
155172
return None
@@ -168,7 +185,11 @@ def next_request(self) -> Request | None:
168185
# Mark the request as handled. This runs even when reconstruction failed above: an unrecoverable entry
169186
# (a corrupt or legacy payload) must still be consumed, otherwise the queue would keep handing it back
170187
# forever. Retrying genuine failures is the RetryMiddleware's job.
171-
self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
188+
try:
189+
self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
190+
except Exception:
191+
traceback.print_exc()
192+
raise
172193

173194
if scrapy_request is None:
174195
return None

tests/unit/scrapy/test_async_thread.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,20 @@
33
import asyncio
44
import logging
55
import threading
6-
import time
76
from concurrent import futures
87
from datetime import timedelta
98
from typing import Any, Literal
109

1110
import pytest
1211

12+
from ..._utils import poll_until_condition
1313
from apify.scrapy._async_thread import AsyncThread
1414

1515

1616
def _wait_until_running(thread: AsyncThread, timeout: float = 2.0) -> None:
1717
"""Block until the background event loop is running, so `run_coro` does not race the thread startup."""
18-
deadline = time.monotonic() + timeout
19-
while not thread._eventloop.is_running():
20-
if time.monotonic() > deadline:
21-
raise AssertionError('The event loop did not start in time.')
22-
time.sleep(0.01)
23-
24-
25-
# Coroutine execution
18+
if not asyncio.run(poll_until_condition(thread._eventloop.is_running, timeout=timeout, poll_interval=0.01)):
19+
raise AssertionError('The event loop did not start in time.')
2620

2721

2822
def test_run_coro_cancels_the_coroutine_on_timeout() -> None:
@@ -66,9 +60,6 @@ async def boom() -> None:
6660
assert [record for record in caplog.records if record.levelno >= logging.ERROR] == []
6761

6862

69-
# Shutdown
70-
71-
7263
def test_close_is_idempotent() -> None:
7364
"""Calling `close` twice is a no-op the second time, not a `RuntimeError` on the closed loop."""
7465
thread = AsyncThread()

tests/unit/scrapy/test_scheduler.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,20 @@ def test_next_request_returns_none_when_queue_empty(scheduler: ApifyScheduler) -
155155
rq.mark_request_as_handled.assert_not_called()
156156

157157

158-
def test_next_request_does_not_print_traceback_to_stderr(
158+
def test_next_request_prints_traceback_to_stderr(
159159
scheduler: ApifyScheduler,
160160
capsys: pytest.CaptureFixture[str],
161161
) -> None:
162-
"""A failure propagates as-is, without `traceback.print_exc()` printing a second copy past the log formatter."""
162+
"""A failure in the coroutine run prints a traceback to stderr via `traceback.print_exc()` before propagating."""
163163
async_thread = cast('mock.MagicMock', scheduler._async_thread)
164164
async_thread.run_coro.side_effect = RuntimeError('boom')
165165

166166
with pytest.raises(RuntimeError, match='boom'):
167167
scheduler.next_request()
168168

169-
assert capsys.readouterr().err == ''
169+
captured = capsys.readouterr()
170+
assert 'Traceback (most recent call last)' in captured.err
171+
assert 'RuntimeError: boom' in captured.err
170172

171173

172174
def test_from_crawler_reads_async_thread_timeout_setting(monkeypatch: pytest.MonkeyPatch) -> None:

0 commit comments

Comments
 (0)