Skip to content

Commit 0aa1fab

Browse files
committed
fix(scrapy)!: serialize requests and HTTP cache as JSON instead of pickle
Scrapy requests stored in the Apify request queue and responses stored in the Scrapy HTTP cache were serialized with pickle. Those storages hold JSON, and pickle reconstructs a Python object graph from the stored bytes. Serialize them as JSON instead, via a single shared serializer (`_serialization.py`) used by both the request converter and the HTTP cache. Only the known binary fields (`body`, `headers`) are base64-encoded; pydantic models are dumped via `model_dump`. A non-JSON-serializable `meta`/`cb_kwargs` is reported and the request skipped rather than silently corrupted. A `_class` entry is only honored when already imported as a `scrapy.Request` subclass, and malformed or legacy (pickle-format) cache entries are treated as a cache miss so reads do not crash after the upgrade. BREAKING CHANGE: requests and HTTP cache entries are now stored as JSON, not pickle. Entries written by older versions are ignored (re-fetched), not read.
1 parent 10203bc commit 0aa1fab

6 files changed

Lines changed: 519 additions & 37 deletions

File tree

src/apify/scrapy/_serialization.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import json
5+
from typing import Any
6+
7+
from pydantic import BaseModel
8+
9+
# Scrapy persists requests and cached responses by serializing the dict produced by
10+
# `Request.to_dict()` (and a small response payload for the HTTP cache). The Apify integration
11+
# stores that payload inside the request queue and the key-value store and reads it back later.
12+
# Those storages hold JSON, so the payload is serialized as JSON here rather than as a pickled
13+
# Python object graph: JSON is a plain, portable, interoperable data format and carries no
14+
# executable object state.
15+
#
16+
# JSON cannot represent everything Scrapy emits. Of the values that actually appear, only two are
17+
# not natively JSON-serializable, and both sit at fixed, known keys with a known type:
18+
# - `body`: `bytes`
19+
# - `headers`: a `{bytes: [bytes]}` mapping (header name -> list of values)
20+
# These are base64-encoded in place. Pydantic models (e.g. Crawlee's `UserData`, which the Apify
21+
# integration injects into `meta['userData']`) are converted via `model_dump()`. Everything else —
22+
# notably the user-controlled `meta` and `cb_kwargs` — must already be JSON-serializable; if it is
23+
# not, serialization fails with a clear error rather than silently dropping the request. No in-band
24+
# sentinel is used for user data, so no legitimate value can collide with the encoding scheme.
25+
26+
27+
def encode_to_json(data: dict[str, Any]) -> str:
28+
"""Serialize a Scrapy request/response dict to a JSON string.
29+
30+
The binary `body` and `headers` fields are base64-encoded in place. All other fields must be
31+
JSON-serializable; pydantic models are dumped to plain dicts. A clear `TypeError` is raised if
32+
any remaining value (typically something in `meta` or `cb_kwargs`) cannot be JSON-encoded.
33+
34+
Args:
35+
data: The dict to serialize, e.g. the output of `scrapy.Request.to_dict()`.
36+
37+
Returns:
38+
The JSON-encoded string.
39+
"""
40+
if not isinstance(data, dict):
41+
raise TypeError(f'Expected a dict to serialize, got {type(data)}')
42+
43+
safe = dict(data)
44+
45+
if isinstance(safe.get('body'), bytes):
46+
safe['body'] = base64.b64encode(safe['body']).decode('ascii')
47+
48+
if isinstance(safe.get('headers'), dict):
49+
safe['headers'] = _encode_headers(safe['headers'])
50+
51+
try:
52+
return json.dumps(safe, default=_json_default)
53+
except TypeError as exc:
54+
raise TypeError(
55+
'Failed to JSON-serialize a Scrapy request/response for storage on the Apify platform. '
56+
'All values in `meta` and `cb_kwargs` must be JSON-serializable (str, int, float, bool, None, '
57+
'list, dict, or a pydantic model).'
58+
) from exc
59+
60+
61+
def decode_from_json(text: str) -> Any:
62+
"""Reconstruct a Scrapy request/response dict from a string produced by `encode_to_json`.
63+
64+
The base64-encoded `body` and `headers` fields are decoded back to their `bytes` representation.
65+
66+
Args:
67+
text: The JSON-encoded string.
68+
69+
Returns:
70+
The decoded object (a dict for valid request/response payloads).
71+
"""
72+
data = json.loads(text)
73+
if not isinstance(data, dict):
74+
return data
75+
76+
if isinstance(data.get('body'), str):
77+
data['body'] = base64.b64decode(data['body'])
78+
79+
if isinstance(data.get('headers'), dict):
80+
data['headers'] = _decode_headers(data['headers'])
81+
82+
return data
83+
84+
85+
def _json_default(obj: Any) -> Any:
86+
"""Fallback for values `json.dumps` cannot serialize on its own.
87+
88+
Only pydantic models are accepted (and dumped to plain dicts); anything else raises, which
89+
`encode_to_json` turns into an actionable error.
90+
"""
91+
if isinstance(obj, BaseModel):
92+
return obj.model_dump(by_alias=True)
93+
raise TypeError(f'Object of type {type(obj).__name__} is not JSON-serializable')
94+
95+
96+
def _encode_headers(headers: dict[Any, Any]) -> dict[str, list[str]]:
97+
"""Encode a Scrapy `{bytes: [bytes]}` headers mapping to a JSON-safe `{str: [base64-str]}`."""
98+
encoded: dict[str, list[str]] = {}
99+
for key, value in headers.items():
100+
str_key = key.decode('latin-1') if isinstance(key, bytes) else key
101+
values = value if isinstance(value, (list, tuple)) else [value]
102+
encoded[str_key] = [_b64encode_value(item) for item in values]
103+
return encoded
104+
105+
106+
def _decode_headers(headers: dict[str, Any]) -> dict[bytes, list[bytes]]:
107+
"""Reverse `_encode_headers`, restoring the `{bytes: [bytes]}` mapping Scrapy expects."""
108+
decoded: dict[bytes, list[bytes]] = {}
109+
for key, value in headers.items():
110+
bytes_key = key.encode('latin-1') if isinstance(key, str) else key
111+
values = value if isinstance(value, list) else [value]
112+
decoded[bytes_key] = [base64.b64decode(item) for item in values]
113+
return decoded
114+
115+
116+
def _b64encode_value(value: Any) -> str:
117+
"""Base64-encode a single header value, coercing non-bytes values to bytes first."""
118+
raw = value if isinstance(value, bytes) else str(value).encode('utf-8')
119+
return base64.b64encode(raw).decode('ascii')

src/apify/scrapy/extensions/_httpcache.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import gzip
44
import io
5-
import pickle
65
import re
76
import struct
87
from logging import getLogger
@@ -14,6 +13,7 @@
1413

1514
from apify import Configuration
1615
from apify.scrapy._async_thread import AsyncThread
16+
from apify.scrapy._serialization import decode_from_json, encode_to_json
1717
from apify.storage_clients import ApifyStorageClient
1818
from apify.storages import KeyValueStore
1919

@@ -36,7 +36,10 @@ class ApifyCacheStorage:
3636
"""
3737

3838
def __init__(self, settings: BaseSettings) -> None:
39-
self._expiration_max_items = 100
39+
# Upper bound on how many keys the per-spider-close cleanup sweeps. Configurable because the
40+
# cleanup is bounded and best-effort (see `close_spider`); the default keeps the historical
41+
# behavior.
42+
self._expiration_max_items: int = settings.getint('APIFY_HTTPCACHE_EXPIRATION_MAX_ITEMS', 100)
4043
self._expiration_secs: int = settings.getint('HTTPCACHE_EXPIRATION_SECS')
4144
self._spider: Spider | None = None
4245
self._kvs: KeyValueStore | None = None
@@ -79,8 +82,15 @@ def close_spider(self, _: Spider, current_time: int | None = None) -> None:
7982
async def expire_kvs() -> None:
8083
if self._kvs is None:
8184
raise ValueError('Key value store not initialized')
82-
i = 0
85+
# Best-effort, bounded cleanup: at most `_expiration_max_items` keys are swept per
86+
# spider close and `iterate_keys()` order is not guaranteed, so stale entries may
87+
# linger across runs. This only reclaims storage; correctness is handled at read
88+
# time, where `retrieve_response` treats an expired entry as a cache miss.
89+
processed = 0
8390
async for item in self._kvs.iterate_keys():
91+
if processed >= self._expiration_max_items:
92+
break
93+
processed += 1
8494
value = await self._kvs.get_value(item.key)
8595
try:
8696
gzip_time = read_gzip_time(value)
@@ -93,9 +103,6 @@ async def expire_kvs() -> None:
93103
await self._kvs.set_value(item.key, None)
94104
else:
95105
logger.debug(f'Valid cache item {item.key}')
96-
if i == self._expiration_max_items:
97-
break
98-
i += 1
99106

100107
self._async_thread.run_coro(expire_kvs())
101108

@@ -127,11 +134,18 @@ def retrieve_response(self, _: Spider, request: Request, current_time: int | Non
127134

128135
if current_time is None:
129136
current_time = int(time())
130-
if 0 < self._expiration_secs < current_time - read_gzip_time(value):
131-
logger.debug('Cache expired', extra={'request': request})
137+
138+
# A malformed or legacy (e.g. pickle-format) cache entry must not crash retrieval. Treat it
139+
# as a cache miss so Scrapy re-fetches and re-stores it in the current format.
140+
try:
141+
if 0 < self._expiration_secs < current_time - read_gzip_time(value):
142+
logger.debug('Cache expired', extra={'request': request})
143+
return None
144+
data = from_gzip(value)
145+
except Exception as exc:
146+
logger.warning(f'Ignoring malformed cache entry {key!r}: {exc}', extra={'request': request})
132147
return None
133148

134-
data = from_gzip(value)
135149
url = data['url']
136150
status = data['status']
137151
headers = Headers(data['headers'])
@@ -162,18 +176,26 @@ def store_response(self, _: Spider, request: Request, response: Response) -> Non
162176

163177

164178
def to_gzip(data: dict, mtime: int | None = None) -> bytes:
165-
"""Dump a dictionary to a gzip-compressed byte stream."""
179+
"""Dump a dictionary to a gzip-compressed byte stream as JSON.
180+
181+
Cached entries are stored in and read back from the Apify key-value store, which holds JSON, so
182+
the payload is serialized as JSON rather than pickled. See `apify.scrapy._serialization` for the
183+
encoding details.
184+
"""
185+
payload = encode_to_json(data).encode('utf-8')
166186
with io.BytesIO() as byte_stream:
167187
with gzip.GzipFile(fileobj=byte_stream, mode='wb', mtime=mtime) as gzip_file:
168-
pickle.dump(data, gzip_file, protocol=4)
188+
gzip_file.write(payload)
169189
return byte_stream.getvalue()
170190

171191

172192
def from_gzip(gzip_bytes: bytes) -> dict:
173-
"""Load a dictionary from a gzip-compressed byte stream."""
193+
"""Load a dictionary from a gzip-compressed JSON byte stream."""
174194
with io.BytesIO(gzip_bytes) as byte_stream, gzip.GzipFile(fileobj=byte_stream, mode='rb') as gzip_file:
175-
data: dict = pickle.load(gzip_file)
176-
return data
195+
data = decode_from_json(gzip_file.read().decode('utf-8'))
196+
if not isinstance(data, dict):
197+
raise TypeError(f'Expected a dict from the cached payload, got {type(data)}')
198+
return data
177199

178200

179201
def read_gzip_time(gzip_bytes: bytes) -> int:

src/apify/scrapy/requests.py

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import codecs
4-
import pickle
4+
import sys
55
from logging import getLogger
66
from typing import Any, cast
77

@@ -13,11 +13,41 @@
1313
from crawlee._request import UserData
1414
from crawlee._types import HttpHeaders
1515

16+
from ._serialization import decode_from_json, encode_to_json
1617
from apify import Request as ApifyRequest
1718

1819
logger = getLogger(__name__)
1920

2021

22+
def _ensure_known_request_class(request_dict: dict[str, Any]) -> None:
23+
"""Validate the optional `_class` entry before `request_from_dict` resolves it.
24+
25+
`scrapy.utils.request.request_from_dict` resolves a `_class` entry via `load_object`, which
26+
imports the dotted path it is given. To keep reconstruction self-contained — importing nothing
27+
that the running spider has not already imported — we only accept a `_class` that is already
28+
present in `sys.modules` and is a `scrapy.Request` subclass.
29+
30+
A spider that reads its own requests always has its request classes imported by the time the
31+
requests are reconstructed, so this does not restrict legitimate use.
32+
"""
33+
class_path = request_dict.get('_class')
34+
if class_path is None:
35+
return
36+
37+
if not isinstance(class_path, str):
38+
raise TypeError(f'Invalid scrapy_request `_class`, expected a string, got {type(class_path)}')
39+
40+
module_name, _, class_name = class_path.rpartition('.')
41+
module = sys.modules.get(module_name) if module_name else None
42+
request_cls = getattr(module, class_name, None) if module is not None else None
43+
44+
if not (isinstance(request_cls, type) and issubclass(request_cls, ScrapyRequest)):
45+
raise TypeError(
46+
f'Refusing to reconstruct a Scrapy request of type {class_path!r}: it is not an already-imported '
47+
f'scrapy.Request subclass.'
48+
)
49+
50+
2151
def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequest | None:
2252
"""Convert a Scrapy request to an Apify request.
2353
@@ -35,6 +65,13 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
3565
logger.debug(f'to_apify_request was called (scrapy_request={scrapy_request})...')
3666

3767
# Configuration to behave as similarly as possible to Scrapy's default RFPDupeFilter.
68+
#
69+
# `payload` carries the request body, which is used both for platform processing and for
70+
# computing the extended unique key. The body is also part of the serialized Scrapy request
71+
# stored further below, where it is needed to faithfully reconstruct the request. Both copies
72+
# originate from `scrapy_request.body` and are kept intentionally: dropping `payload` would
73+
# change deduplication, and dropping the serialized copy would couple reconstruction to the
74+
# Apify payload.
3875
request_kwargs: dict[str, Any] = {
3976
'url': scrapy_request.url,
4077
'method': scrapy_request.method,
@@ -67,29 +104,49 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
67104

68105
request_kwargs['user_data'] = user_data if isinstance(user_data, dict) else {}
69106

70-
# Convert Scrapy's headers to a HttpHeaders and store them in the apify_request
107+
# Convert Scrapy's headers to HttpHeaders and store them on the apify_request. This is only
108+
# the Apify-platform-level view of the headers; the authoritative copy, with exact bytes,
109+
# travels inside the serialized scrapy_request below. `to_unicode_dict()` decodes as UTF-8
110+
# and raises on non-UTF-8 header values, so it is guarded: a request with binary headers
111+
# keeps them in the serialized payload instead of being dropped entirely.
71112
if isinstance(scrapy_request.headers, Headers):
72-
headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))
73-
request_kwargs['headers'] = HttpHeaders(headers)
113+
try:
114+
headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))
115+
request_kwargs['headers'] = HttpHeaders(headers)
116+
except UnicodeDecodeError:
117+
logger.warning(
118+
'Could not represent Scrapy request headers as Apify request headers (non-UTF-8 values); '
119+
'they are preserved in the serialized request instead.'
120+
)
74121
else:
75122
logger.warning(
76123
f'Invalid scrapy_request.headers type, not scrapy.http.headers.Headers: {scrapy_request.headers}'
77124
)
78125

79126
apify_request = ApifyRequest.from_url(**request_kwargs)
80-
81-
# Serialize the Scrapy ScrapyRequest and store it in the apify_request.
82-
# - This process involves converting the Scrapy ScrapyRequest object into a dictionary, encoding it to base64,
83-
# and storing it as 'scrapy_request' within the 'userData' dictionary of the apify_request.
84-
# - The serialization process can be referenced at: https://stackoverflow.com/questions/30469575/.
85127
scrapy_request_dict = scrapy_request.to_dict(spider=spider)
86-
scrapy_request_dict_encoded = codecs.encode(pickle.dumps(scrapy_request_dict), 'base64').decode()
87-
apify_request.user_data['scrapy_request'] = scrapy_request_dict_encoded
88128

89129
except Exception as exc:
90130
logger.warning(f'Conversion of Scrapy request {scrapy_request} to Apify request failed; {exc}')
91131
return None
92132

133+
# Serialize the Scrapy request and store it (base64-encoded JSON) under 'scrapy_request' in the
134+
# Apify request's user data. This is intentionally outside the broad except above so that a
135+
# non-JSON-serializable value in `meta`/`cb_kwargs` is reported loudly rather than hidden as a
136+
# generic warning. The failure is logged with a full traceback and the request is skipped (None
137+
# is returned, honoring this function's contract) instead of crashing the whole crawl. See
138+
# `_serialization` for the encoding details.
139+
try:
140+
scrapy_request_json = encode_to_json(scrapy_request_dict)
141+
except TypeError:
142+
logger.exception(
143+
f'Failed to serialize Scrapy request {scrapy_request} for storage on the Apify platform; skipping it. '
144+
'Ensure all values in `meta` and `cb_kwargs` are JSON-serializable.'
145+
)
146+
return None
147+
148+
apify_request.user_data['scrapy_request'] = codecs.encode(scrapy_request_json.encode('utf-8'), 'base64').decode()
149+
93150
logger.debug(f'scrapy_request was converted to the apify_request={apify_request}')
94151
return apify_request
95152

@@ -102,14 +159,15 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
102159
spider: The Scrapy spider that the request is associated with.
103160
104161
Raises:
105-
TypeError: If the Apify request is not an instance of the `ApifyRequest` class.
106-
ValueError: If the Apify request does not contain the required keys.
162+
TypeError: If `apify_request` is not an `ApifyRequest`, if the stored Scrapy request payload
163+
is malformed, or if its `_class` does not refer to an already-imported `scrapy.Request`
164+
subclass.
107165
108166
Returns:
109167
The converted Scrapy request.
110168
"""
111169
if not isinstance(cast('Any', apify_request), ApifyRequest):
112-
raise TypeError('apify_request must be a crawlee.ScrapyRequest instance')
170+
raise TypeError('apify_request must be an apify.Request instance')
113171

114172
logger.debug(f'to_scrapy_request was called (apify_request={apify_request})...')
115173

@@ -124,10 +182,14 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
124182
if not isinstance(scrapy_request_dict_encoded, str):
125183
raise TypeError('scrapy_request_dict_encoded must be a string')
126184

127-
scrapy_request_dict = pickle.loads(codecs.decode(scrapy_request_dict_encoded.encode(), 'base64'))
185+
scrapy_request_json = codecs.decode(scrapy_request_dict_encoded.encode(), 'base64').decode('utf-8')
186+
scrapy_request_dict = decode_from_json(scrapy_request_json)
128187
if not isinstance(scrapy_request_dict, dict):
129188
raise TypeError('scrapy_request_dict must be a dictionary')
130189

190+
# Validate any `_class` entry before request_from_dict resolves and imports it.
191+
_ensure_known_request_class(scrapy_request_dict)
192+
131193
scrapy_request = request_from_dict(scrapy_request_dict, spider=spider)
132194
if not isinstance(scrapy_request, ScrapyRequest):
133195
raise TypeError('scrapy_request must be an instance of the ScrapyRequest class')

0 commit comments

Comments
 (0)