apify 4.0.2b2__tar.gz → 4.0.2b4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {apify-4.0.2b2 → apify-4.0.2b4}/CHANGELOG.md +2 -0
  2. {apify-4.0.2b2 → apify-4.0.2b4}/PKG-INFO +1 -1
  3. {apify-4.0.2b2 → apify-4.0.2b4}/pyproject.toml +1 -1
  4. {apify-4.0.2b2 → apify-4.0.2b4}/pyproject.toml.orig +1 -1
  5. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_async_thread.py +64 -0
  6. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/requests.py +56 -20
  7. apify-4.0.2b4/src/apify/scrapy/scheduler.py +416 -0
  8. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_single_client.py +8 -2
  9. apify-4.0.2b2/src/apify/scrapy/scheduler.py +0 -203
  10. {apify-4.0.2b2 → apify-4.0.2b4}/CONTRIBUTING.md +0 -0
  11. {apify-4.0.2b2 → apify-4.0.2b4}/LICENSE +0 -0
  12. {apify-4.0.2b2 → apify-4.0.2b4}/README.md +0 -0
  13. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/__init__.py +0 -0
  14. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_actor.py +0 -0
  15. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_charging.py +0 -0
  16. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_configuration.py +0 -0
  17. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_consts.py +0 -0
  18. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_crypto.py +0 -0
  19. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_proxy_configuration.py +0 -0
  20. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_utils.py +0 -0
  21. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_webhook.py +0 -0
  22. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/errors.py +0 -0
  23. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/__init__.py +0 -0
  24. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/_apify_event_manager.py +0 -0
  25. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/_types.py +0 -0
  26. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/py.typed +0 -0
  27. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/log.py +0 -0
  28. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/py.typed +0 -0
  29. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/__init__.py +0 -0
  30. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/_apify_request_list.py +0 -0
  31. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/py.typed +0 -0
  32. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/__init__.py +0 -0
  33. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_actor_runner.py +0 -0
  34. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_detection.py +0 -0
  35. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_logging_config.py +0 -0
  36. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_serialization.py +0 -0
  37. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/extensions/__init__.py +0 -0
  38. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/extensions/_httpcache.py +0 -0
  39. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/__init__.py +0 -0
  40. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/apify_proxy.py +0 -0
  41. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/py.typed +0 -0
  42. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/__init__.py +0 -0
  43. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/actor_dataset_push.py +0 -0
  44. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/py.typed +0 -0
  45. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/py.typed +0 -0
  46. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/utils.py +0 -0
  47. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/__init__.py +0 -0
  48. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/__init__.py +0 -0
  49. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_alias_resolving.py +0 -0
  50. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_api_client_creation.py +0 -0
  51. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_dataset_client.py +0 -0
  52. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_key_value_store_client.py +0 -0
  53. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_models.py +0 -0
  54. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_client.py +0 -0
  55. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_shared_client.py +0 -0
  56. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_storage_client.py +0 -0
  57. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_utils.py +0 -0
  58. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/py.typed +0 -0
  59. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/__init__.py +0 -0
  60. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_dataset_client.py +0 -0
  61. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_key_value_store_client.py +0 -0
  62. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_storage_client.py +0 -0
  63. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_ppe_dataset_mixin.py +0 -0
  64. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_smart_apify/__init__.py +0 -0
  65. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_smart_apify/_storage_client.py +0 -0
  66. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/py.typed +0 -0
  67. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storages/__init__.py +0 -0
  68. {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storages/py.typed +0 -0
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file.
9
9
 
10
10
  - Prolong and track shared request queue locks to prevent duplicate processing ([#1062](https://github.com/apify/apify-sdk-python/pull/1062)) ([ccaad7b](https://github.com/apify/apify-sdk-python/commit/ccaad7b71cd6cd1ddc2c71e5e9d9780001f2688a)) by [@vdusek](https://github.com/vdusek)
11
11
  - Verify shared request queue is_finished against per-request reads ([#1087](https://github.com/apify/apify-sdk-python/pull/1087)) ([22613aa](https://github.com/apify/apify-sdk-python/commit/22613aac0ce4e6ef852d5d3daf8c1a25cb3731e3)) by [@vdusek](https://github.com/vdusek)
12
+ - Keep non-forefront reclaimed requests pending in the single request queue client ([#1060](https://github.com/apify/apify-sdk-python/pull/1060)) ([b61890c](https://github.com/apify/apify-sdk-python/commit/b61890cf286c3a83faedac69315acff74b5fba5b)) by [@vdusek](https://github.com/vdusek)
13
+ - **scrapy:** Stop silently dropping in-flight requests and redirects ([#1097](https://github.com/apify/apify-sdk-python/pull/1097)) ([5a0a752](https://github.com/apify/apify-sdk-python/commit/5a0a752523b95c3143e5058fe07785e69817f4a2)) by [@vdusek](https://github.com/vdusek)
12
14
 
13
15
 
14
16
  <!-- git-cliff-unreleased-end -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apify
3
- Version: 4.0.2b2
3
+ Version: 4.0.2b4
4
4
  Summary: Apify SDK for Python
5
5
  Keywords: apify,sdk,automation,chrome,crawlee,crawler,headless,scraper,scraping
6
6
  Author: Apify Technologies s.r.o.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify"
7
- version = "4.0.2b2"
7
+ version = "4.0.2b4"
8
8
  description = "Apify SDK for Python"
9
9
  license = "Apache-2.0"
10
10
  license-files = ["LICENSE"]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify"
7
- version = "4.0.2b2"
7
+ version = "4.0.2b4"
8
8
  description = "Apify SDK for Python"
9
9
  authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
10
  license = "Apache-2.0"
@@ -12,6 +12,9 @@ if TYPE_CHECKING:
12
12
 
13
13
  logger = getLogger(__name__)
14
14
 
15
+ SUBMITTED_PRUNE_THRESHOLD = 128
16
+ """How many `submit_coro` futures may pile up before the finished ones are dropped from the tracking list."""
17
+
15
18
 
16
19
  class AsyncThread:
17
20
  """Run an asyncio event loop in a dedicated background thread.
@@ -26,6 +29,9 @@ class AsyncThread:
26
29
  self._default_timeout = default_timeout
27
30
  self._eventloop = asyncio.new_event_loop()
28
31
 
32
+ self._submitted: list[futures.Future] = []
33
+ """Futures of the coroutines submitted via `submit_coro` that may still be running."""
34
+
29
35
  # Start the event loop in a dedicated daemon thread.
30
36
  self._thread = threading.Thread(
31
37
  target=self._start_event_loop,
@@ -74,6 +80,55 @@ class AsyncThread:
74
80
  future.cancel()
75
81
  raise
76
82
 
83
+ def submit_coro(self, coro: Coroutine) -> futures.Future:
84
+ """Schedule a coroutine on the event loop without waiting for its result.
85
+
86
+ Use this for work nothing depends on, so the calling thread is not blocked by the round trip. Failures
87
+ are logged, as there is no caller to propagate them to, and `close` cancels whatever is still pending -
88
+ call `wait_for_submitted` first if that matters.
89
+
90
+ Args:
91
+ coro: The coroutine to run.
92
+
93
+ Returns:
94
+ The future of the scheduled coroutine, for callers that want to inspect its outcome later.
95
+
96
+ Raises:
97
+ RuntimeError: If the event loop has been closed.
98
+ """
99
+ if self._eventloop.is_closed():
100
+ raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
101
+
102
+ # Callers may go a long time between `wait_for_submitted` calls, so without pruning the list would hold
103
+ # every coroutine ever submitted, with its result.
104
+ if len(self._submitted) >= SUBMITTED_PRUNE_THRESHOLD:
105
+ self._submitted = [submitted for submitted in self._submitted if not submitted.done()]
106
+
107
+ future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
108
+ future.add_done_callback(self._log_failure)
109
+ self._submitted.append(future)
110
+
111
+ return future
112
+
113
+ def wait_for_submitted(self, timeout: timedelta | None = None) -> None:
114
+ """Block until the coroutines submitted via `submit_coro` have finished.
115
+
116
+ Use this before anything that would observe their effects, or before `close`, which cancels whatever is
117
+ still running. Coroutines that do not finish within the timeout stay tracked for the next call.
118
+
119
+ Args:
120
+ timeout: The maximum time to wait for the submitted coroutines. Pass `None` to use the
121
+ `default_timeout` passed to the constructor.
122
+ """
123
+ if timeout is None:
124
+ timeout = self._default_timeout
125
+
126
+ self._submitted = list(futures.wait(self._submitted, timeout=timeout.total_seconds()).not_done)
127
+
128
+ # Callers rely on the effects having landed, so a timeout has to be visible.
129
+ if self._submitted:
130
+ logger.warning(f'{len(self._submitted)} submitted coroutines did not finish within the timeout.')
131
+
77
132
  def close(self, timeout: timedelta | None = None) -> None:
78
133
  """Close the event loop and its thread gracefully.
79
134
 
@@ -110,6 +165,15 @@ class AsyncThread:
110
165
  logger.warning('Event loop thread did not exit cleanly! Forcing shutdown...')
111
166
  self._force_exit_event_loop()
112
167
 
168
+ @staticmethod
169
+ def _log_failure(future: futures.Future) -> None:
170
+ """Log the failure of a coroutine submitted via `submit_coro`."""
171
+ if future.cancelled():
172
+ return
173
+
174
+ if (exc := future.exception()) is not None:
175
+ logger.error('A coroutine submitted to the event loop failed.', exc_info=exc)
176
+
113
177
  def _start_event_loop(self) -> None:
114
178
  """Set up and run the asyncio event loop in the dedicated thread."""
115
179
  asyncio.set_event_loop(self._eventloop)
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from copy import deepcopy
4
4
  from logging import getLogger
5
- from typing import Any, cast
5
+ from typing import TYPE_CHECKING, Any, get_args
6
6
 
7
7
  from scrapy import Request as ScrapyRequest
8
8
  from scrapy import Spider
@@ -11,11 +11,15 @@ from scrapy.utils.misc import load_object
11
11
  from scrapy.utils.request import request_from_dict
12
12
 
13
13
  from crawlee._request import UserData
14
- from crawlee._types import HttpHeaders
14
+ from crawlee._types import HttpHeaders, HttpMethod
15
+ from crawlee._utils.requests import compute_unique_key
15
16
 
16
17
  from ._serialization import decode_from_json, encode_to_json
17
18
  from apify import Request as ApifyRequest
18
19
 
20
+ if TYPE_CHECKING:
21
+ from typing_extensions import TypeIs
22
+
19
23
  logger = getLogger(__name__)
20
24
 
21
25
 
@@ -47,6 +51,38 @@ def _ensure_known_request_class(request_dict: dict[str, Any]) -> None:
47
51
  )
48
52
 
49
53
 
54
+ def _is_http_method(method: str) -> TypeIs[HttpMethod]:
55
+ """Tell whether `method` is one of the HTTP methods the RQ accepts; Scrapy itself takes any string."""
56
+ return method in get_args(HttpMethod)
57
+
58
+
59
+ def _compute_fingerprint(scrapy_request: ScrapyRequest) -> str:
60
+ """Identify the request an RQ unique key was minted for.
61
+
62
+ `to_scrapy_request` stamps this beside the unique key so `to_apify_request` can tell a request that came out
63
+ of the RQ from one Scrapy derived from it. It covers the URL, the method and the body, so a redirect, a
64
+ method switch and a changed body all read as a different request rather than the parent.
65
+
66
+ Headers are left out even though the unique key covers them: `DefaultHeadersMiddleware` and
67
+ `UserAgentMiddleware` call `headers.setdefault()` on the request in place before the scheduler sees it
68
+ again, so an otherwise untouched request would no longer match its own stamp.
69
+ """
70
+ method = scrapy_request.method
71
+ if not _is_http_method(method):
72
+ raise ValueError(
73
+ f'Unsupported HTTP method {method!r} in {scrapy_request}; '
74
+ f'the request queue accepts {", ".join(get_args(HttpMethod))}.'
75
+ )
76
+
77
+ return compute_unique_key(
78
+ url=scrapy_request.url,
79
+ method=method,
80
+ payload=scrapy_request.body,
81
+ keep_url_fragment=False,
82
+ use_extended_unique_key=True,
83
+ )
84
+
85
+
50
86
  def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequest | None:
51
87
  """Convert a Scrapy request to an Apify request.
52
88
 
@@ -76,8 +112,14 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
76
112
  try:
77
113
  if scrapy_request.dont_filter:
78
114
  request_kwargs['always_enqueue'] = True
79
- elif scrapy_request.meta.get('apify_request_unique_key'):
80
- request_kwargs['unique_key'] = scrapy_request.meta['apify_request_unique_key']
115
+ elif unique_key := scrapy_request.meta.get('apify_request_unique_key'):
116
+ # Reuse the RQ unique key only while this is still the request it was minted for. Redirects
117
+ # (`Request.replace()`) and spiders forwarding `meta` to another URL inherit the stamp, and reusing
118
+ # it there deduplicates the derived request against its parent. A stamp without a fingerprint was
119
+ # set by hand, so it is taken at face value.
120
+ fingerprint = _compute_fingerprint(scrapy_request)
121
+ if scrapy_request.meta.get('apify_request_fingerprint', fingerprint) == fingerprint:
122
+ request_kwargs['unique_key'] = unique_key
81
123
 
82
124
  # Serialize the Scrapy request now, before `Request.from_url()` runs below. `from_url()` mutates the
83
125
  # `user_data` dict it receives in place (it injects a live `CrawleeRequestData` under `__crawlee`), and that
@@ -113,8 +155,9 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
113
155
  # the old behavior, which dropped such requests entirely.
114
156
  if isinstance(scrapy_request.headers, Headers):
115
157
  try:
116
- headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))
117
- request_kwargs['headers'] = HttpHeaders(headers)
158
+ # `to_unicode_dict()` yields str keys and values, but Scrapy types it as `UserDict[str | bytes, Any]`,
159
+ # so the mapping goes through the model's validator instead of a cast.
160
+ request_kwargs['headers'] = HttpHeaders.model_validate(scrapy_request.headers.to_unicode_dict())
118
161
  except UnicodeDecodeError:
119
162
  logger.warning(
120
163
  'Could not represent Scrapy request headers as Apify request headers (non-UTF-8 values); '
@@ -165,7 +208,7 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
165
208
  Returns:
166
209
  The converted Scrapy request.
167
210
  """
168
- if not isinstance(cast('Any', apify_request), ApifyRequest):
211
+ if not isinstance(apify_request, ApifyRequest):
169
212
  raise TypeError('apify_request must be an apify.Request instance')
170
213
 
171
214
  # If the apify_request comes from the Scrapy
@@ -187,21 +230,14 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
187
230
  if not isinstance(scrapy_request, ScrapyRequest):
188
231
  raise TypeError('scrapy_request must be an instance of the ScrapyRequest class')
189
232
 
190
- # Update the meta field with the meta field from the apify_request
191
- meta = scrapy_request.meta or {}
192
- meta.update({'apify_request_unique_key': apify_request.unique_key})
193
- # scrapy_request.meta is a property, so we have to set it like this
194
- scrapy_request._meta = meta # noqa: SLF001
195
-
196
233
  # If the apify_request comes directly from the Scrapy, typically start URLs.
197
234
  else:
198
- scrapy_request = ScrapyRequest(
199
- url=apify_request.url,
200
- method=apify_request.method,
201
- meta={
202
- 'apify_request_unique_key': apify_request.unique_key,
203
- },
204
- )
235
+ scrapy_request = ScrapyRequest(url=apify_request.url, method=apify_request.method)
236
+
237
+ # Stamp the unique key with a fingerprint of the request it belongs to, so `to_apify_request` can tell
238
+ # this request apart from the ones Scrapy derives from it.
239
+ scrapy_request.meta['apify_request_unique_key'] = apify_request.unique_key
240
+ scrapy_request.meta['apify_request_fingerprint'] = _compute_fingerprint(scrapy_request)
205
241
 
206
242
  # Add optional 'headers' field
207
243
  if apify_request.headers:
@@ -0,0 +1,416 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import timedelta
5
+ from logging import getLogger
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from scrapy import Spider
9
+ from scrapy import __version__ as scrapy_version
10
+ from scrapy.core.scheduler import BaseScheduler
11
+ from scrapy.utils.reactor import is_asyncio_reactor_installed
12
+
13
+ from ._async_thread import AsyncThread
14
+ from .requests import to_apify_request, to_scrapy_request
15
+ from apify import Configuration
16
+ from apify.storage_clients import ApifyStorageClient
17
+ from apify.storages import RequestQueue
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Coroutine, Iterable
21
+ from concurrent.futures import Future
22
+
23
+ from scrapy.crawler import Crawler
24
+ from scrapy.http.request import Request
25
+ from twisted.internet.defer import Deferred
26
+
27
+ from apify import Request as ApifyRequest
28
+
29
+ InFlightRequest = tuple[ApifyRequest, Request]
30
+ """A request handed over to Scrapy, paired with the RQ request it came from."""
31
+
32
+ logger = getLogger(__name__)
33
+
34
+
35
+ async def _gather_failures(operations: Iterable[Coroutine[Any, Any, Any]]) -> list[BaseException | None]:
36
+ """Run RQ updates concurrently, reporting each one's failure, or `None`, in the order given.
37
+
38
+ The whole batch costs one round trip, and one failing update neither raises nor stops the others.
39
+ """
40
+ outcomes = await asyncio.gather(*operations, return_exceptions=True)
41
+ return [outcome if isinstance(outcome, BaseException) else None for outcome in outcomes]
42
+
43
+
44
+ class ApifyScheduler(BaseScheduler):
45
+ """A Scrapy scheduler that uses the Apify `RequestQueue` to manage requests.
46
+
47
+ This scheduler requires the asyncio Twisted reactor to be installed.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ async_thread_timeout: timedelta = timedelta(seconds=60),
53
+ crawler: Crawler | None = None,
54
+ ) -> None:
55
+ if not is_asyncio_reactor_installed():
56
+ raise ValueError(
57
+ f'{ApifyScheduler.__qualname__} requires the asyncio Twisted reactor. '
58
+ 'Make sure you have it configured in the TWISTED_REACTOR setting. See the asyncio '
59
+ 'documentation of Scrapy for more information.',
60
+ )
61
+ self._rq: RequestQueue | None = None
62
+ self.spider: Spider | None = None
63
+ self._crawler = crawler
64
+
65
+ self._requests_in_flight: list[InFlightRequest] = []
66
+ """Requests handed over to Scrapy and not resolved in the RQ yet."""
67
+
68
+ self._pending_marks: list[tuple[list[InFlightRequest], Future]] = []
69
+ """Batches of mark-as-handled updates dispatched off the hot path, whose outcome is not known yet."""
70
+
71
+ # A thread with the asyncio event loop to run coroutines on.
72
+ self._async_thread = AsyncThread(default_timeout=async_thread_timeout)
73
+
74
+ @classmethod
75
+ def from_crawler(cls, crawler: Crawler) -> ApifyScheduler:
76
+ """Create the scheduler, reading the async-thread timeout from the Scrapy settings.
77
+
78
+ The `APIFY_ASYNC_THREAD_TIMEOUT_SECS` setting (in seconds) caps how long each coroutine run on the
79
+ background event loop may take before timing out; it defaults to 60 seconds.
80
+ """
81
+ timeout_secs = crawler.settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60)
82
+ return cls(async_thread_timeout=timedelta(seconds=timeout_secs), crawler=crawler)
83
+
84
+ def open(self, spider: Spider) -> Deferred[None] | None:
85
+ """Open the scheduler.
86
+
87
+ Args:
88
+ spider: The spider that the scheduler is associated with.
89
+ """
90
+ self.spider = spider
91
+
92
+ if self._crawler is None:
93
+ logger.warning(
94
+ f'{ApifyScheduler.__qualname__} was built without a crawler, so it cannot see what Scrapy is '
95
+ 'still working on. Every request is marked as handled the moment it is handed over, and an '
96
+ 'interrupted run loses whatever was in flight. Build it with `from_crawler` to avoid that.'
97
+ )
98
+ else:
99
+ self._verify_engine_internals()
100
+
101
+ async def open_rq() -> RequestQueue:
102
+ configuration = Configuration.get_global_configuration()
103
+ if configuration.is_at_home:
104
+ storage_client = ApifyStorageClient()
105
+ return await RequestQueue.open(
106
+ configuration=configuration,
107
+ storage_client=storage_client,
108
+ )
109
+ return await RequestQueue.open()
110
+
111
+ try:
112
+ self._rq = self._async_thread.run_coro(open_rq())
113
+ except Exception:
114
+ logger.exception('Failed to open the request queue.')
115
+ # Close the freshly started async thread so a failed open does not leak its event-loop thread.
116
+ # Guard the close so a secondary failure here cannot mask the original error.
117
+ try:
118
+ self._async_thread.close()
119
+ except Exception:
120
+ logger.exception('Failed to close the async thread after a failed scheduler open.')
121
+ raise
122
+
123
+ return None
124
+
125
+ def close(self, reason: str) -> None:
126
+ """Close the scheduler.
127
+
128
+ Resolve the requests Scrapy still holds, then shut down the event loop and its thread gracefully.
129
+
130
+ Args:
131
+ reason: The reason for closing the spider.
132
+ """
133
+ logger.debug(f'Closing {self.__class__.__name__} due to {reason}...')
134
+
135
+ rq = self._rq
136
+ if isinstance(rq, RequestQueue):
137
+ try:
138
+ self._resolve_finished_requests(wait=True)
139
+ except Exception:
140
+ logger.exception('Failed to resolve the requests still in flight in the request queue.')
141
+
142
+ # Scrapy drains its in-progress requests before closing the scheduler, so this only runs if a future
143
+ # Scrapy closes it earlier; then they go back to the RQ as pending, in a single round trip. One failed
144
+ # reclaim must not strand the rest either.
145
+ if self._requests_in_flight:
146
+ reclaims = _gather_failures(
147
+ rq.reclaim_request(apify_request) for apify_request, _ in self._requests_in_flight
148
+ )
149
+ try:
150
+ outcomes = self._async_thread.run_coro(reclaims)
151
+ except Exception:
152
+ logger.exception('Failed to reclaim the requests still in flight in the request queue.')
153
+ else:
154
+ for (apify_request, _), outcome in zip(self._requests_in_flight, outcomes, strict=True):
155
+ if outcome is not None:
156
+ logger.error(
157
+ f'Failed to reclaim the request {apify_request} in the request queue.',
158
+ exc_info=outcome,
159
+ )
160
+
161
+ self._requests_in_flight.clear()
162
+
163
+ # Closing the event loop would silently cancel the updates fired off on the hot path.
164
+ self._async_thread.wait_for_submitted()
165
+
166
+ try:
167
+ self._async_thread.close()
168
+
169
+ except KeyboardInterrupt:
170
+ logger.warning('Shutdown interrupted by KeyboardInterrupt!')
171
+
172
+ except Exception:
173
+ logger.exception('Exception occurred while shutting down.')
174
+
175
+ finally:
176
+ logger.debug(f'{self.__class__.__name__} closed successfully.')
177
+
178
+ def has_pending_requests(self) -> bool:
179
+ """Check if the scheduler has any pending requests.
180
+
181
+ Resolves the requests Scrapy has finished with first, as their outcome is what decides the answer.
182
+
183
+ Returns:
184
+ True if the scheduler has any pending requests, False otherwise.
185
+ """
186
+ if not isinstance(self._rq, RequestQueue):
187
+ raise TypeError('self._rq must be an instance of the RequestQueue class')
188
+
189
+ # Scrapy asks this only once both its downloader and its scraper are idle, so everything still tracked
190
+ # as in flight is provably finished.
191
+ self._resolve_finished_requests(wait=True)
192
+
193
+ # The RQ answers from its own bookkeeping, which a pending update has not reached yet.
194
+ self._async_thread.wait_for_submitted()
195
+
196
+ # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
197
+ # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
198
+ try:
199
+ is_finished = self._async_thread.run_coro(self._rq.is_finished())
200
+ except Exception:
201
+ logger.exception('Failed to check whether the request queue is finished.')
202
+ raise
203
+
204
+ return not is_finished
205
+
206
+ def enqueue_request(self, request: Request) -> bool:
207
+ """Add a request to the scheduler.
208
+
209
+ This could be called from either from a spider or a downloader middleware (e.g. redirect, retry, ...).
210
+
211
+ Args:
212
+ request: The request to add to the scheduler.
213
+
214
+ Returns:
215
+ True if the request was successfully enqueued, False otherwise.
216
+ """
217
+ if not isinstance(self.spider, Spider):
218
+ raise TypeError('self.spider must be an instance of the Spider class')
219
+
220
+ apify_request = to_apify_request(request, spider=self.spider)
221
+ if apify_request is None:
222
+ logger.warning(f'Request {request} could not be converted to Apify request; skipping it.')
223
+ return False
224
+
225
+ if not isinstance(self._rq, RequestQueue):
226
+ raise TypeError('self._rq must be an instance of the RequestQueue class')
227
+
228
+ # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
229
+ # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
230
+ try:
231
+ result = self._async_thread.run_coro(self._rq.add_request(apify_request))
232
+ except Exception:
233
+ logger.exception('Failed to enqueue the request to the request queue.')
234
+ raise
235
+
236
+ return not bool(result.was_already_present)
237
+
238
+ def next_request(self) -> Request | None:
239
+ """Fetch the next request from the scheduler.
240
+
241
+ Returns:
242
+ The next request, or None if there are no more requests.
243
+ """
244
+ if not isinstance(self._rq, RequestQueue):
245
+ raise TypeError('self._rq must be an instance of the RequestQueue class')
246
+
247
+ # The engine polls this method throughout the crawl, so resolving here keeps the RQ current without
248
+ # blocking on the round trips.
249
+ self._resolve_finished_requests(wait=False)
250
+
251
+ # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
252
+ # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
253
+ try:
254
+ apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
255
+ except Exception:
256
+ logger.exception('Failed to fetch the next request from the request queue.')
257
+ raise
258
+
259
+ if apify_request is None:
260
+ return None
261
+
262
+ if not isinstance(self.spider, Spider):
263
+ raise TypeError('self.spider must be an instance of the Spider class')
264
+
265
+ # A corrupt or legacy payload must not crash the run, and is marked as handled right away, otherwise
266
+ # the RQ would keep handing it back forever.
267
+ try:
268
+ scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
269
+ except Exception as exc:
270
+ logger.warning(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it: {exc}')
271
+ try:
272
+ self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
273
+ except Exception:
274
+ logger.exception('Failed to mark the request as handled in the request queue.')
275
+ raise
276
+ return None
277
+
278
+ # The entry stays unresolved until Scrapy is done with the request, so a run interrupted mid-flight
279
+ # leaves it pending instead of silently handled.
280
+ self._requests_in_flight.append((apify_request, scrapy_request))
281
+
282
+ return scrapy_request
283
+
284
+ def _verify_engine_internals(self) -> None:
285
+ """Fail at open time if Scrapy's engine no longer exposes what the in-flight tracking reads.
286
+
287
+ The hot path deliberately does not guard those undocumented internals: swallowing an `AttributeError`
288
+ there would quietly go back to marking every request as handled the moment it is handed over. The
289
+ scraper's slot does not exist yet when the scheduler opens, so this only proves `downloader.active` and
290
+ `scraper.slot` are there; a missing `slot.active` surfaces from `next_request` on the first request.
291
+
292
+ Raises:
293
+ RuntimeError: If the engine internals the tracking relies on cannot be read.
294
+ """
295
+ try:
296
+ self._requests_busy_in_scrapy()
297
+ except AttributeError as exc:
298
+ raise RuntimeError(
299
+ f'{ApifyScheduler.__qualname__} cannot tell which requests Scrapy is working on: this Scrapy '
300
+ f'version ({scrapy_version}) does not expose the engine internals it reads. Please report this '
301
+ 'at https://github.com/apify/apify-sdk-python/issues.'
302
+ ) from exc
303
+
304
+ def _requests_busy_in_scrapy(self) -> set[Request]:
305
+ """Return the requests Scrapy is still working on.
306
+
307
+ A request joins the downloader's active set before the middleware chain runs and leaves the scraper's
308
+ only once the callback and the item pipeline are done, so absence from both means Scrapy is done with
309
+ it - downloaded, dropped by a middleware or errored out alike.
310
+
311
+ Without a crawler there is nothing to ask and every request reads as finished; `open` warns about that.
312
+ """
313
+ engine = self._crawler.engine if self._crawler is not None else None
314
+ if engine is None:
315
+ return set()
316
+
317
+ scraper_slot = engine.scraper.slot
318
+ return engine.downloader.active | (scraper_slot.active if scraper_slot is not None else set())
319
+
320
+ def _resolve_finished_requests(self, *, wait: bool) -> None:
321
+ """Mark every request Scrapy has finished processing as handled in the RQ.
322
+
323
+ A whole pass reaches the RQ in a single round trip. A request whose update cannot be dispatched, or
324
+ turns out to have failed, stays tracked for the next call to retry.
325
+
326
+ Args:
327
+ wait: Whether to block until the RQ has been updated. Pass False on the crawl's hot path, where
328
+ nothing depends on the result and blocking would stall the Twisted reactor.
329
+ """
330
+ rq = self._rq
331
+ if not isinstance(rq, RequestQueue) or not (self._requests_in_flight or self._pending_marks):
332
+ return
333
+
334
+ # Updates dispatched by an earlier pass that did not land are marked again by this one.
335
+ finished = self._collect_failed_marks(wait=wait)
336
+ unresolved: list[InFlightRequest] = []
337
+
338
+ if self._requests_in_flight:
339
+ busy = self._requests_busy_in_scrapy()
340
+ for apify_request, scrapy_request in self._requests_in_flight:
341
+ if scrapy_request in busy:
342
+ unresolved.append((apify_request, scrapy_request))
343
+ else:
344
+ finished.append((apify_request, scrapy_request))
345
+
346
+ if finished:
347
+ marks = _gather_failures(rq.mark_request_as_handled(apify_request) for apify_request, _ in finished)
348
+ try:
349
+ if wait:
350
+ unresolved.extend(self._failed_marks(finished, self._async_thread.run_coro(marks)))
351
+ else:
352
+ self._pending_marks.append((finished, self._async_thread.submit_coro(marks)))
353
+ except Exception:
354
+ logger.exception(f'Failed to mark {len(finished)} request(s) as handled in the request queue.')
355
+ unresolved.extend(finished)
356
+
357
+ self._requests_in_flight = unresolved
358
+
359
+ def _collect_failed_marks(self, *, wait: bool) -> list[InFlightRequest]:
360
+ """Return the requests whose already dispatched mark-as-handled did not land, so it can be retried.
361
+
362
+ `submit_coro` reports nothing back to the reactor thread, so an update failing after dispatch would
363
+ otherwise drop its request from the tracking and leave it in progress in the RQ forever, which would
364
+ keep the RQ from ever reporting itself finished.
365
+
366
+ Args:
367
+ wait: Whether to block until every dispatched update has finished. Updates still running are kept
368
+ for the next call.
369
+ """
370
+ if not self._pending_marks:
371
+ return []
372
+
373
+ if wait:
374
+ self._async_thread.wait_for_submitted()
375
+
376
+ failed: list[InFlightRequest] = []
377
+ pending: list[tuple[list[InFlightRequest], Future]] = []
378
+
379
+ for requests, future in self._pending_marks:
380
+ if not future.done():
381
+ pending.append((requests, future))
382
+ elif future.cancelled():
383
+ logger.error(f'Marking {len(requests)} request(s) as handled was cancelled before it finished.')
384
+ failed.extend(requests)
385
+ else:
386
+ # `_gather_failures` reports a failed update in its result rather than raising, so an exception
387
+ # here means the dispatch itself did not survive the event loop being torn down under it.
388
+ try:
389
+ outcomes = future.result()
390
+ except Exception:
391
+ logger.exception(f'Failed to mark {len(requests)} request(s) as handled in the request queue.')
392
+ failed.extend(requests)
393
+ else:
394
+ failed.extend(self._failed_marks(requests, outcomes))
395
+
396
+ self._pending_marks = pending
397
+
398
+ return failed
399
+
400
+ @staticmethod
401
+ def _failed_marks(
402
+ requests: list[InFlightRequest],
403
+ outcomes: list[BaseException | None],
404
+ ) -> list[InFlightRequest]:
405
+ """Pair a batch of updates back with their requests, returning and logging the ones that failed."""
406
+ failed = []
407
+
408
+ for (apify_request, scrapy_request), outcome in zip(requests, outcomes, strict=True):
409
+ if outcome is not None:
410
+ logger.error(
411
+ f'Failed to mark the request {apify_request} as handled in the request queue.',
412
+ exc_info=outcome,
413
+ )
414
+ failed.append((apify_request, scrapy_request))
415
+
416
+ return failed
@@ -82,7 +82,10 @@ class ApifyRequestQueueSingleClient:
82
82
  """LRU cache storing unhandled request objects, keyed by request ID."""
83
83
 
84
84
  self._head_requests: deque[str] = deque()
85
- """Ordered queue of request IDs representing the local estimate of the queue head."""
85
+ """Ordered queue of request IDs representing the local estimate of the queue head.
86
+
87
+ Served from the right end, so `append` places a request at the top of the head and `appendleft` at the bottom.
88
+ """
86
89
 
87
90
  self._requests_already_handled: set[str] = set()
88
91
  """Set of request IDs known to be already processed on the platform.
@@ -303,9 +306,12 @@ class ApifyRequestQueueSingleClient:
303
306
  # No longer handled
304
307
  self._requests_already_handled.discard(request_id)
305
308
 
309
+ # `is_empty` reads only the head estimation, so the reclaimed request has to go back into it until the
310
+ # platform head listing catches up. Placement mirrors `add_batch_of_requests`.
306
311
  if forefront:
307
- # Append to top of the local head estimation
308
312
  self._head_requests.append(request_id)
313
+ else:
314
+ self._head_requests.appendleft(request_id)
309
315
 
310
316
  processed_request = await self._update_request(request, forefront=forefront)
311
317
  processed_request.id = request_id
@@ -1,203 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import timedelta
4
- from logging import getLogger
5
- from typing import TYPE_CHECKING
6
-
7
- from scrapy import Spider
8
- from scrapy.core.scheduler import BaseScheduler
9
- from scrapy.utils.reactor import is_asyncio_reactor_installed
10
-
11
- from ._async_thread import AsyncThread
12
- from .requests import to_apify_request, to_scrapy_request
13
- from apify import Configuration
14
- from apify.storage_clients import ApifyStorageClient
15
- from apify.storages import RequestQueue
16
-
17
- if TYPE_CHECKING:
18
- from scrapy.crawler import Crawler
19
- from scrapy.http.request import Request
20
- from twisted.internet.defer import Deferred
21
-
22
- logger = getLogger(__name__)
23
-
24
-
25
- class ApifyScheduler(BaseScheduler):
26
- """A Scrapy scheduler that uses the Apify `RequestQueue` to manage requests.
27
-
28
- This scheduler requires the asyncio Twisted reactor to be installed.
29
- """
30
-
31
- def __init__(self, async_thread_timeout: timedelta = timedelta(seconds=60)) -> None:
32
- if not is_asyncio_reactor_installed():
33
- raise ValueError(
34
- f'{ApifyScheduler.__qualname__} requires the asyncio Twisted reactor. '
35
- 'Make sure you have it configured in the TWISTED_REACTOR setting. See the asyncio '
36
- 'documentation of Scrapy for more information.',
37
- )
38
- self._rq: RequestQueue | None = None
39
- self.spider: Spider | None = None
40
-
41
- # A thread with the asyncio event loop to run coroutines on.
42
- self._async_thread = AsyncThread(default_timeout=async_thread_timeout)
43
-
44
- @classmethod
45
- def from_crawler(cls, crawler: Crawler) -> ApifyScheduler:
46
- """Create the scheduler, reading the async-thread timeout from the Scrapy settings.
47
-
48
- The `APIFY_ASYNC_THREAD_TIMEOUT_SECS` setting (in seconds) caps how long each coroutine run on the
49
- background event loop may take before timing out; it defaults to 60 seconds.
50
- """
51
- timeout_secs = crawler.settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60)
52
- return cls(async_thread_timeout=timedelta(seconds=timeout_secs))
53
-
54
- def open(self, spider: Spider) -> Deferred[None] | None:
55
- """Open the scheduler.
56
-
57
- Args:
58
- spider: The spider that the scheduler is associated with.
59
- """
60
- self.spider = spider
61
-
62
- async def open_rq() -> RequestQueue:
63
- configuration = Configuration.get_global_configuration()
64
- if configuration.is_at_home:
65
- storage_client = ApifyStorageClient()
66
- return await RequestQueue.open(
67
- configuration=configuration,
68
- storage_client=storage_client,
69
- )
70
- return await RequestQueue.open()
71
-
72
- try:
73
- self._rq = self._async_thread.run_coro(open_rq())
74
- except Exception:
75
- logger.exception('Failed to open the request queue.')
76
- # Close the freshly started async thread so a failed open does not leak its event-loop thread.
77
- # Guard the close so a secondary failure here cannot mask the original error.
78
- try:
79
- self._async_thread.close()
80
- except Exception:
81
- logger.exception('Failed to close the async thread after a failed scheduler open.')
82
- raise
83
-
84
- return None
85
-
86
- def close(self, reason: str) -> None:
87
- """Close the scheduler.
88
-
89
- Shut down the event loop and its thread gracefully.
90
-
91
- Args:
92
- reason: The reason for closing the spider.
93
- """
94
- logger.debug(f'Closing {self.__class__.__name__} due to {reason}...')
95
- try:
96
- self._async_thread.close()
97
-
98
- except KeyboardInterrupt:
99
- logger.warning('Shutdown interrupted by KeyboardInterrupt!')
100
-
101
- except Exception:
102
- logger.exception('Exception occurred while shutting down.')
103
-
104
- finally:
105
- logger.debug(f'{self.__class__.__name__} closed successfully.')
106
-
107
- def has_pending_requests(self) -> bool:
108
- """Check if the scheduler has any pending requests.
109
-
110
- Returns:
111
- True if the scheduler has any pending requests, False otherwise.
112
- """
113
- if not isinstance(self._rq, RequestQueue):
114
- raise TypeError('self._rq must be an instance of the RequestQueue class')
115
-
116
- # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
117
- # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
118
- try:
119
- is_finished = self._async_thread.run_coro(self._rq.is_finished())
120
- except Exception:
121
- logger.exception('Failed to check whether the request queue is finished.')
122
- raise
123
-
124
- return not is_finished
125
-
126
- def enqueue_request(self, request: Request) -> bool:
127
- """Add a request to the scheduler.
128
-
129
- This could be called from either from a spider or a downloader middleware (e.g. redirect, retry, ...).
130
-
131
- Args:
132
- request: The request to add to the scheduler.
133
-
134
- Returns:
135
- True if the request was successfully enqueued, False otherwise.
136
- """
137
- if not isinstance(self.spider, Spider):
138
- raise TypeError('self.spider must be an instance of the Spider class')
139
-
140
- apify_request = to_apify_request(request, spider=self.spider)
141
- if apify_request is None:
142
- logger.warning(f'Request {request} could not be converted to Apify request; skipping it.')
143
- return False
144
-
145
- if not isinstance(self._rq, RequestQueue):
146
- raise TypeError('self._rq must be an instance of the RequestQueue class')
147
-
148
- # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
149
- # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
150
- try:
151
- result = self._async_thread.run_coro(self._rq.add_request(apify_request))
152
- except Exception:
153
- logger.exception('Failed to enqueue the request to the request queue.')
154
- raise
155
-
156
- return not bool(result.was_already_present)
157
-
158
- def next_request(self) -> Request | None:
159
- """Fetch the next request from the scheduler.
160
-
161
- Returns:
162
- The next request, or None if there are no more requests.
163
- """
164
- if not isinstance(self._rq, RequestQueue):
165
- raise TypeError('self._rq must be an instance of the RequestQueue class')
166
-
167
- # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
168
- # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
169
- try:
170
- apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
171
- except Exception:
172
- logger.exception('Failed to fetch the next request from the request queue.')
173
- raise
174
-
175
- if apify_request is None:
176
- return None
177
-
178
- if not isinstance(self.spider, Spider):
179
- raise TypeError('self.spider must be an instance of the Spider class')
180
-
181
- # Reconstruct the Scrapy request before consuming the queue entry. A malformed entry must not crash
182
- # the whole run, so on failure it is logged and skipped (None) rather than propagating.
183
- try:
184
- scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
185
- except Exception as exc:
186
- logger.warning(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it: {exc}')
187
- scrapy_request = None
188
-
189
- # Mark the request as handled. This runs even when reconstruction failed above: an unrecoverable entry
190
- # (a corrupt or legacy payload) must still be consumed, otherwise the queue would keep handing it back
191
- # forever. Retrying genuine failures is the RetryMiddleware's job.
192
- # Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
193
- # otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
194
- try:
195
- self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
196
- except Exception:
197
- logger.exception('Failed to mark the request as handled in the request queue.')
198
- raise
199
-
200
- if scrapy_request is None:
201
- return None
202
-
203
- return scrapy_request
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes