Skip to content

Commit f2ccae1

Browse files
authored
fix(scrapy): dump pydantic models in JSON mode when serializing requests (#961)
Pydantic models in a Scrapy request's `meta`/`cb_kwargs` were dumped in python mode, so any model with a non-JSON-native field (`datetime`, `UUID`, `HttpUrl`, ...) failed `json.dumps` and the request was dropped. The v4 upgrade guide promises such models keep working. - Dump models with `model_dump(mode='json', by_alias=True)`. - Widen the serialization error guards to `except (TypeError, ValueError)` to cover pydantic's `PydanticSerializationError`. - Add a round-trip test with a datetime-bearing model and update the v4 upgrade guide wording.
1 parent c734786 commit f2ccae1

4 files changed

Lines changed: 48 additions & 21 deletions

File tree

docs/04_upgrading/upgrading_to_v4.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ Pickle could store arbitrary Python objects. JSON cannot, so the values in a req
218218

219219
- A `tuple` comes back as a `list`.
220220
- Non-string `dict` keys come back as strings, so `{1: 'a'}` becomes `{'1': 'a'}`.
221-
- A value JSON cannot represent (`datetime`, `set`, `Decimal`, a custom object) is no longer stored silently. The request is skipped and the failure is logged. Pydantic models are still supported and are dumped with `model_dump()`.
221+
- Non-JSON-serializable values, such as `datetime`, `set`, `Decimal`, or custom objects, are skipped and logged. Pydantic models are supported via `model_dump(mode='json')`, which converts non-JSON-native fields into JSON-compatible values, such as ISO-8601 strings for `datetime` fields.
222222

223223
Convert such values to a JSON-friendly form before yielding the request:
224224

@@ -230,4 +230,4 @@ yield scrapy.Request(url, meta={'since': datetime(2024, 1, 1)})
230230

231231
# After (v4): store a JSON-serializable value.
232232
yield scrapy.Request(url, meta={'since': datetime(2024, 1, 1).isoformat()})
233-
```
233+
```Pickle

src/apify/scrapy/_serialization.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
"""JSON serialization of Scrapy requests and cached responses for storage on the Apify platform.
22
3-
Scrapy requests and cached responses are stored in the Apify request queue and key-value store which hold JSON,
4-
so they are serialized as JSON here rather than pickled.
5-
6-
Only `body` (`bytes`) and `headers` (`{bytes: [bytes]}`) are not natively JSON-serializable; both sit at fixed keys
7-
and are base64-encoded in place. A `str` `body` is encoded as its UTF-8 bytes and comes back as `bytes`, matching
8-
Scrapy, which always stores `body` as `bytes`. Pydantic models such as Crawlee's `UserData` are dumped via
9-
`model_dump()`. Everything else, notably `meta` and `cb_kwargs`, must already be JSON-serializable, otherwise
10-
serialization fails with a clear error naming the offending value. No in-band sentinel is used, so no user value
11-
can collide with the encoding.
12-
13-
Known limitations of the pickle -> JSON switch (a documented breaking change): JSON has fewer types than pickle,
14-
so values in `meta`/`cb_kwargs` are subject to JSON's coercions. A `tuple` round-trips as a `list` and non-string
15-
`dict` keys round-trip as strings (e.g. `{1: 'a'}` becomes `{'1': 'a'}`). Values JSON cannot represent at all
16-
(`datetime`, `set`, `Decimal`, arbitrary objects, ...) are not coerced silently: serialization raises and the request
17-
is skipped loudly rather than stored in a corrupted form.
3+
Scrapy requests and cached responses are stored in the Apify request queue and key-value store, which hold JSON.
4+
They are therefore serialized as JSON here.
5+
6+
Only `body` (`bytes`) and `headers` (`{bytes: [bytes]}`) are not natively JSON-serializable. Both live at fixed keys
7+
and are base64-encoded in place. A `str` `body` is encoded as UTF-8 bytes and deserialized as `bytes`, matching
8+
Scrapy, which always stores `body` as `bytes`.
9+
10+
Pydantic models, such as Crawlee's `UserData`, are dumped with `model_dump(mode='json')`, which converts
11+
non-JSON-native fields into JSON-compatible values. For example, `datetime` fields are stored as ISO-8601 strings.
12+
13+
All other values, notably `meta` and `cb_kwargs`, must already be JSON-serializable. Non-JSON-serializable values,
14+
such as `datetime`, `set`, `Decimal`, or arbitrary objects, fail serialization with a clear error naming the
15+
offending value. The request is skipped rather than stored in a corrupted form.
16+
17+
No in-band sentinel is used, so user values cannot collide with the encoding.
18+
19+
Known JSON limitations: values in `meta` and `cb_kwargs` are subject to JSON coercions. A `tuple` round-trips as
20+
a `list`, and non-string `dict` keys round-trip as strings; for example, `{1: 'a'}` becomes `{'1': 'a'}`.
1821
"""
1922

2023
from __future__ import annotations
@@ -60,7 +63,9 @@ def encode_to_json(data: dict[str, Any]) -> str:
6063
# `ensure_ascii=False` keeps non-ASCII URLs/meta as their UTF-8 form instead of `\uXXXX` escapes, which
6164
# would otherwise roughly double the size of non-Latin text in storage.
6265
return json.dumps(safe, default=_json_default, ensure_ascii=False)
63-
except TypeError as exc:
66+
# `ValueError` covers pydantic's `PydanticSerializationError`, raised when a model field cannot be dumped
67+
# to JSON even in JSON mode.
68+
except (TypeError, ValueError) as exc:
6469
raise TypeError(
6570
'Failed to JSON-serialize a Scrapy request/response for storage on the Apify platform. '
6671
'All values in `meta` and `cb_kwargs` must be JSON-serializable (str, int, float, bool, None, '
@@ -100,7 +105,7 @@ def _json_default(obj: Any) -> Any:
100105
at the bad `meta`/`cb_kwargs` entry instead of just reporting that something failed.
101106
"""
102107
if isinstance(obj, BaseModel):
103-
return obj.model_dump(by_alias=True)
108+
return obj.model_dump(mode='json', by_alias=True)
104109
value_repr = repr(obj)
105110
if len(value_repr) > _MAX_ERROR_VALUE_REPR_LEN:
106111
value_repr = value_repr[:_MAX_ERROR_VALUE_REPR_LEN] + '...'

src/apify/scrapy/requests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
128128
# None per this function's contract), rather than crashing the crawl.
129129
try:
130130
scrapy_request_json = encode_to_json(scrapy_request_dict)
131-
except TypeError:
131+
except (TypeError, ValueError):
132132
logger.exception(
133133
f'Failed to serialize Scrapy request {scrapy_request} for storage on the Apify platform; skipping it. '
134134
'Ensure all values in `meta` and `cb_kwargs` are JSON-serializable.'

tests/unit/scrapy/test_serialization.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import UTC, datetime
44

55
import pytest
6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, ConfigDict, Field
77

88
from apify.scrapy._serialization import _MAX_ERROR_VALUE_REPR_LEN, decode_from_json, encode_to_json
99

@@ -71,6 +71,28 @@ class Model(BaseModel):
7171
assert decode_from_json(encoded)['meta']['m'] == {'First': 1}
7272

7373

74+
def test_pydantic_model_with_datetime_field_round_trips() -> None:
75+
"""A pydantic model with a `datetime` field is dumped in JSON mode, so the request is stored, not dropped."""
76+
77+
class Model(BaseModel):
78+
when: datetime
79+
80+
encoded = encode_to_json({'meta': {'m': Model(when=datetime(2020, 1, 2, 3, 4, 5, tzinfo=UTC))}})
81+
assert decode_from_json(encoded)['meta']['m'] == {'when': '2020-01-02T03:04:05Z'}
82+
83+
84+
def test_pydantic_model_with_non_serializable_field_raises() -> None:
85+
"""A model field that even JSON mode cannot dump raises the clear `TypeError`, not a bare pydantic error."""
86+
87+
class Model(BaseModel):
88+
model_config = ConfigDict(arbitrary_types_allowed=True)
89+
90+
obj: object
91+
92+
with pytest.raises(TypeError, match='JSON-serializable'):
93+
encode_to_json({'meta': {'m': Model(obj=object())}})
94+
95+
7496
def test_tuple_is_coerced_to_list() -> None:
7597
"""Documented limitation: JSON has no tuple type, so a tuple round-trips as a list."""
7698
assert _round_trip({'meta': {'coords': (1, 2, 3)}})['meta']['coords'] == [1, 2, 3]

0 commit comments

Comments
 (0)