Skip to content

Commit afcaa2a

Browse files
committed
fix(scrapy): Delete stale HTTP cache items instead of overwriting them with null
1 parent d73fcb4 commit afcaa2a

2 files changed

Lines changed: 58 additions & 3 deletions

File tree

src/apify/scrapy/extensions/_httpcache.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,11 @@ async def expire_kvs() -> None:
9494
gzip_time = read_gzip_time(value)
9595
except Exception as e:
9696
logger.warning(f'Malformed cache item {item.key}: {e}')
97-
await self._kvs.set_value(item.key, None)
97+
await self._kvs.delete_value(item.key)
9898
else:
9999
if self._expiration_secs < current_time - gzip_time:
100100
logger.debug(f'Expired cache item {item.key}')
101-
await self._kvs.set_value(item.key, None)
101+
await self._kvs.delete_value(item.key)
102102
else:
103103
logger.debug(f'Valid cache item {item.key}')
104104

tests/unit/scrapy/extensions/test_httpcache.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import asyncio
12
import gzip
23
import io
34
import json
45
import pickle
56
from time import time
7+
from typing import Any
68

79
import pytest
10+
from scrapy import Request
11+
from scrapy.settings import Settings
812

9-
from apify.scrapy.extensions._httpcache import from_gzip, get_kvs_name, read_gzip_time, to_gzip
13+
from apify.scrapy.extensions._httpcache import ApifyCacheStorage, from_gzip, get_kvs_name, read_gzip_time, to_gzip
1014

1115
FIXTURE_DICT = {'name': 'Alice'}
1216

@@ -70,6 +74,57 @@ def test_from_gzip_rejects_pickle_payload() -> None:
7074
from_gzip(pickle_payload)
7175

7276

77+
class _FakeAsyncThread:
78+
def run_coro(self, coro: Any, *_: Any, **__: Any) -> Any:
79+
loop = asyncio.new_event_loop()
80+
try:
81+
return loop.run_until_complete(coro)
82+
finally:
83+
loop.close()
84+
85+
86+
class _FakeKvs:
87+
def __init__(self, value: bytes | None) -> None:
88+
self._value = value
89+
90+
async def get_value(self, _: str) -> bytes | None:
91+
return self._value
92+
93+
94+
class _FakeFingerprinter:
95+
def fingerprint(self, _: Request) -> bytes:
96+
return b'\xab\xcd'
97+
98+
99+
def _make_storage(value: bytes | None) -> ApifyCacheStorage:
100+
storage = ApifyCacheStorage(Settings({'HTTPCACHE_EXPIRATION_SECS': 0}))
101+
storage._async_thread = _FakeAsyncThread() # ty: ignore[invalid-assignment]
102+
storage._kvs = _FakeKvs(value) # ty: ignore[invalid-assignment]
103+
storage._fingerprinter = _FakeFingerprinter() # ty: ignore[invalid-assignment]
104+
return storage
105+
106+
107+
def test_retrieve_response_returns_cached_response() -> None:
108+
data = {'status': 200, 'url': 'https://example.com', 'headers': {}, 'body': b'hello'}
109+
storage = _make_storage(to_gzip(data))
110+
response = storage.retrieve_response(None, Request('https://example.com')) # ty: ignore[invalid-argument-type]
111+
assert response is not None
112+
assert response.status == 200
113+
assert response.body == b'hello'
114+
115+
116+
def test_retrieve_response_ignores_legacy_pickle_item() -> None:
117+
# A gzip-wrapped pickle payload is the legacy (pre-JSON) cache format that the JSON reader cannot
118+
# load. After the upgrade, such an item must degrade to a cache miss instead of raising and breaking
119+
# the download, so the cache self-heals (re-fetch and re-store as JSON) rather than crashing.
120+
with io.BytesIO() as byte_stream:
121+
with gzip.GzipFile(fileobj=byte_stream, mode='wb') as gzip_file:
122+
pickle.dump({'status': 200, 'body': b'x'}, gzip_file, protocol=4)
123+
legacy_pickle = byte_stream.getvalue()
124+
storage = _make_storage(legacy_pickle)
125+
assert storage.retrieve_response(None, Request('https://example.com')) is None # ty: ignore[invalid-argument-type]
126+
127+
73128
@pytest.mark.parametrize(
74129
('spider_name', 'expected'),
75130
[

0 commit comments

Comments
 (0)