Skip to content

Commit b80f562

Browse files
MantisusCopilot
andauthored
fix: Add retry logic for RedisStorageClient and SqlStorageClient (#1838)
### Description - Adds `retry_on_error` decorator with exponential backoff - Applies retry to all operations in `SqlStorageClient` and `RedisStorageClient` to handle transient failures ### Issues - Closes: #1831 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e01638c commit b80f562

15 files changed

Lines changed: 464 additions & 1 deletion

‎src/crawlee/_utils/retry.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from asyncio import sleep as _retry_sleep # Using alias for testing purposes
4+
from datetime import timedelta
5+
from functools import wraps
6+
from logging import getLogger
7+
from typing import TYPE_CHECKING, ParamSpec, TypeVar
8+
9+
if TYPE_CHECKING:
10+
from collections.abc import Awaitable, Callable
11+
12+
P = ParamSpec('P')
13+
T = TypeVar('T')
14+
15+
logger = getLogger(__name__)
16+
17+
18+
def retry_on_error(
19+
*exception_types: type[Exception],
20+
max_attempts: int = 3,
21+
base_delay: timedelta = timedelta(milliseconds=500),
22+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
23+
"""Retry an async function with exponential backoff on specified exceptions.
24+
25+
Args:
26+
*exception_types: Exception types to catch and retry on.
27+
max_attempts: Maximum number of attempts including the first one.
28+
base_delay: Base delay between retries; doubles on each subsequent attempt.
29+
"""
30+
if max_attempts < 1:
31+
raise ValueError('max_attempts must be at least 1')
32+
33+
if base_delay < timedelta(0):
34+
raise ValueError('base_delay must be a non-negative timedelta')
35+
36+
if not exception_types:
37+
raise ValueError('At least one exception type must be specified')
38+
39+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
40+
41+
func_qualname = getattr(func, '__qualname__', repr(func))
42+
base_delay_seconds = base_delay.total_seconds()
43+
44+
@wraps(func)
45+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
46+
for attempt in range(max_attempts):
47+
try:
48+
return await func(*args, **kwargs)
49+
except exception_types as exc: # noqa: PERF203
50+
if attempt >= max_attempts - 1:
51+
raise
52+
53+
delay = base_delay_seconds * (2**attempt)
54+
logger.debug(
55+
f'{func_qualname}: attempt {attempt + 1}/{max_attempts} failed. '
56+
f'Retrying in {delay:.2f} seconds.',
57+
exc_info=exc,
58+
)
59+
await _retry_sleep(delay)
60+
raise RuntimeError('Unreachable')
61+
62+
return wrapper
63+
64+
return decorator

‎src/crawlee/storage_clients/_redis/_dataset_client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from logging import getLogger
44
from typing import TYPE_CHECKING, Any, cast
55

6+
from redis.exceptions import RedisError
67
from typing_extensions import NotRequired, override
78

9+
from crawlee._utils.retry import retry_on_error
810
from crawlee.storage_clients._base import DatasetClient
911
from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
1012

@@ -102,14 +104,17 @@ async def open(
102104
instance_kwargs={},
103105
)
104106

107+
@retry_on_error(RedisError)
105108
@override
106109
async def get_metadata(self) -> DatasetMetadata:
107110
return await self._get_metadata(DatasetMetadata)
108111

112+
@retry_on_error(RedisError)
109113
@override
110114
async def drop(self) -> None:
111115
await self._drop(extra_keys=[self._items_key])
112116

117+
@retry_on_error(RedisError)
113118
@override
114119
async def purge(self) -> None:
115120
await self._purge(
@@ -119,6 +124,7 @@ async def purge(self) -> None:
119124
),
120125
)
121126

127+
@retry_on_error(RedisError)
122128
@override
123129
async def push_data(self, data: list[dict[str, Any]] | dict[str, Any]) -> None:
124130
if isinstance(data, dict):
@@ -133,6 +139,7 @@ async def push_data(self, data: list[dict[str, Any]] | dict[str, Any]) -> None:
133139
),
134140
)
135141

142+
@retry_on_error(RedisError)
136143
@override
137144
async def get_data(
138145
self,

‎src/crawlee/storage_clients/_redis/_key_value_store_client.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from logging import getLogger
55
from typing import TYPE_CHECKING, Any
66

7+
from redis.exceptions import RedisError
78
from typing_extensions import override
89

910
from crawlee._utils.file import infer_mime_type
11+
from crawlee._utils.retry import retry_on_error
1012
from crawlee.storage_clients._base import KeyValueStoreClient
1113
from crawlee.storage_clients.models import KeyValueStoreMetadata, KeyValueStoreRecord, KeyValueStoreRecordMetadata
1214

@@ -100,21 +102,25 @@ async def open(
100102
instance_kwargs={},
101103
)
102104

105+
@retry_on_error(RedisError)
103106
@override
104107
async def get_metadata(self) -> KeyValueStoreMetadata:
105108
return await self._get_metadata(KeyValueStoreMetadata)
106109

110+
@retry_on_error(RedisError)
107111
@override
108112
async def drop(self) -> None:
109113
await self._drop(extra_keys=[self._items_key, self._metadata_items_key])
110114

115+
@retry_on_error(RedisError)
111116
@override
112117
async def purge(self) -> None:
113118
await self._purge(
114119
extra_keys=[self._items_key, self._metadata_items_key],
115120
metadata_kwargs=MetadataUpdateParams(update_accessed_at=True, update_modified_at=True),
116121
)
117122

123+
@retry_on_error(RedisError)
118124
@override
119125
async def set_value(self, *, key: str, value: Any, content_type: str | None = None) -> None:
120126
# Special handling for None values
@@ -141,7 +147,6 @@ async def set_value(self, *, key: str, value: Any, content_type: str | None = No
141147
content_type=content_type,
142148
size=size,
143149
)
144-
145150
async with self._get_pipeline() as pipe:
146151
# redis-py typing issue
147152
await await_redis_response(pipe.hset(self._items_key, key, value_bytes)) # ty: ignore[invalid-argument-type]
@@ -155,6 +160,7 @@ async def set_value(self, *, key: str, value: Any, content_type: str | None = No
155160
)
156161
await self._update_metadata(pipe, **MetadataUpdateParams(update_accessed_at=True, update_modified_at=True))
157162

163+
@retry_on_error(RedisError)
158164
@override
159165
async def get_value(self, *, key: str) -> KeyValueStoreRecord | None:
160166
serialized_metadata_item = await await_redis_response(self._redis.hget(self._metadata_items_key, key))
@@ -200,6 +206,7 @@ async def get_value(self, *, key: str) -> KeyValueStoreRecord | None:
200206

201207
return KeyValueStoreRecord(value=value, **metadata_item.model_dump())
202208

209+
@retry_on_error(RedisError)
203210
@override
204211
async def delete_value(self, *, key: str) -> None:
205212
async with self._get_pipeline() as pipe:
@@ -251,6 +258,7 @@ async def iterate_keys(
251258
async def get_public_url(self, *, key: str) -> str:
252259
raise NotImplementedError('Public URLs are not supported for memory key-value stores.')
253260

261+
@retry_on_error(RedisError)
254262
@override
255263
async def record_exists(self, *, key: str) -> bool:
256264
async with self._get_pipeline(with_execute=False) as pipe:

‎src/crawlee/storage_clients/_redis/_request_queue_client.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from logging import getLogger
77
from typing import TYPE_CHECKING, Any, Literal
88

9+
from redis.exceptions import RedisError
910
from typing_extensions import NotRequired, override
1011

1112
from crawlee import Request
1213
from crawlee._utils.crypto import crypto_random_object_id
14+
from crawlee._utils.retry import retry_on_error
1315
from crawlee.storage_clients._base import RequestQueueClient
1416
from crawlee.storage_clients.models import AddRequestsResponse, ProcessedRequest, RequestQueueMetadata
1517

@@ -207,10 +209,12 @@ async def open(
207209
instance_kwargs={'dedup_strategy': dedup_strategy, 'bloom_error_rate': bloom_error_rate},
208210
)
209211

212+
@retry_on_error(RedisError)
210213
@override
211214
async def get_metadata(self) -> RequestQueueMetadata:
212215
return await self._get_metadata(RequestQueueMetadata)
213216

217+
@retry_on_error(RedisError)
214218
@override
215219
async def drop(self) -> None:
216220
if self._dedup_strategy == 'bloom':
@@ -222,6 +226,7 @@ async def drop(self) -> None:
222226
extra_keys.extend([self._queue_key, self._data_key, self._in_progress_key])
223227
await self._drop(extra_keys=extra_keys)
224228

229+
@retry_on_error(RedisError)
225230
@override
226231
async def purge(self) -> None:
227232
if self._dedup_strategy == 'bloom':
@@ -349,6 +354,7 @@ async def add_batch_of_requests(
349354
unprocessed_requests=[],
350355
)
351356

357+
@retry_on_error(RedisError)
352358
@override
353359
async def fetch_next_request(self) -> Request | None:
354360
if self._pending_fetch_cache:
@@ -377,6 +383,7 @@ async def fetch_next_request(self) -> Request | None:
377383

378384
return requests[0]
379385

386+
@retry_on_error(RedisError)
380387
@override
381388
async def get_request(self, unique_key: str) -> Request | None:
382389
request_data = await await_redis_response(self._redis.hget(self._data_key, unique_key))
@@ -386,6 +393,7 @@ async def get_request(self, unique_key: str) -> Request | None:
386393

387394
return None
388395

396+
@retry_on_error(RedisError)
389397
@override
390398
async def mark_request_as_handled(self, request: Request) -> ProcessedRequest | None:
391399
# Check if the request is in progress.
@@ -424,6 +432,7 @@ async def mark_request_as_handled(self, request: Request) -> ProcessedRequest |
424432
was_already_handled=True,
425433
)
426434

435+
@retry_on_error(RedisError)
427436
@override
428437
async def reclaim_request(
429438
self,
@@ -469,6 +478,7 @@ async def reclaim_request(
469478
was_already_handled=False,
470479
)
471480

481+
@retry_on_error(RedisError)
472482
@override
473483
async def is_empty(self) -> bool:
474484
"""Check if the queue is empty.

‎src/crawlee/storage_clients/_sql/_client_mixin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ async def get_session(self, *, with_simple_commit: bool = False) -> AsyncIterato
205205
except SQLAlchemyError as e:
206206
logger.warning(f'Error occurred during session transaction: {e}')
207207
await session.rollback()
208+
raise
208209
else:
209210
yield session
210211

‎src/crawlee/storage_clients/_sql/_dataset_client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
from sqlalchemy import Select, insert, select
88
from sqlalchemy import func as sql_func
9+
from sqlalchemy.exc import SQLAlchemyError
910
from typing_extensions import Self, override
1011

12+
from crawlee._utils.retry import retry_on_error
1113
from crawlee.storage_clients._base import DatasetClient
1214
from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
1315

@@ -109,11 +111,13 @@ async def open(
109111
extra_metadata_fields={'item_count': 0},
110112
)
111113

114+
@retry_on_error(SQLAlchemyError)
112115
@override
113116
async def get_metadata(self) -> DatasetMetadata:
114117
# The database is a single place of truth
115118
return await self._get_metadata(DatasetMetadata)
116119

120+
@retry_on_error(SQLAlchemyError)
117121
@override
118122
async def drop(self) -> None:
119123
"""Delete this dataset and all its items from the database.
@@ -122,6 +126,7 @@ async def drop(self) -> None:
122126
"""
123127
await self._drop()
124128

129+
@retry_on_error(SQLAlchemyError)
125130
@override
126131
async def purge(self) -> None:
127132
"""Remove all items from this dataset while keeping the dataset structure.
@@ -137,6 +142,7 @@ async def purge(self) -> None:
137142
)
138143
)
139144

145+
@retry_on_error(SQLAlchemyError)
140146
@override
141147
async def push_data(self, data: list[dict[str, Any]] | dict[str, Any]) -> None:
142148
if not isinstance(data, list):
@@ -150,6 +156,7 @@ async def push_data(self, data: list[dict[str, Any]] | dict[str, Any]) -> None:
150156

151157
await self._add_buffer_record(session, update_modified_at=True, delta_item_count=len(data))
152158

159+
@retry_on_error(SQLAlchemyError)
153160
@override
154161
async def get_data(
155162
self,

‎src/crawlee/storage_clients/_sql/_key_value_store_client.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
from sqlalchemy import CursorResult, delete, select
99
from sqlalchemy import func as sql_func
10+
from sqlalchemy.exc import SQLAlchemyError
1011
from typing_extensions import Self, override
1112

1213
from crawlee._utils.file import infer_mime_type
14+
from crawlee._utils.retry import retry_on_error
1315
from crawlee.storage_clients._base import KeyValueStoreClient
1416
from crawlee.storage_clients.models import (
1517
KeyValueStoreMetadata,
@@ -117,11 +119,13 @@ async def open(
117119
extra_metadata_fields={},
118120
)
119121

122+
@retry_on_error(SQLAlchemyError)
120123
@override
121124
async def get_metadata(self) -> KeyValueStoreMetadata:
122125
# The database is a single place of truth
123126
return await self._get_metadata(KeyValueStoreMetadata)
124127

128+
@retry_on_error(SQLAlchemyError)
125129
@override
126130
async def drop(self) -> None:
127131
"""Delete this key-value store and all its records from the database.
@@ -130,6 +134,7 @@ async def drop(self) -> None:
130134
"""
131135
await self._drop()
132136

137+
@retry_on_error(SQLAlchemyError)
133138
@override
134139
async def purge(self) -> None:
135140
"""Remove all items from this key-value store while keeping the key-value store structure.
@@ -139,6 +144,7 @@ async def purge(self) -> None:
139144
now = datetime.now(timezone.utc)
140145
await self._purge(metadata_kwargs=MetadataUpdateParams(accessed_at=now, modified_at=now))
141146

147+
@retry_on_error(SQLAlchemyError)
142148
@override
143149
async def set_value(self, *, key: str, value: Any, content_type: str | None = None) -> None:
144150
# Special handling for None values
@@ -180,6 +186,7 @@ async def set_value(self, *, key: str, value: Any, content_type: str | None = No
180186

181187
await self._add_buffer_record(session, update_modified_at=True)
182188

189+
@retry_on_error(SQLAlchemyError)
183190
@override
184191
async def get_value(self, *, key: str) -> KeyValueStoreRecord | None:
185192
# Query the record by key
@@ -226,6 +233,7 @@ async def get_value(self, *, key: str) -> KeyValueStoreRecord | None:
226233
size=record_db.size,
227234
)
228235

236+
@retry_on_error(SQLAlchemyError)
229237
@override
230238
async def delete_value(self, *, key: str) -> None:
231239
stmt = delete(self._ITEM_TABLE).where(
@@ -274,6 +282,7 @@ async def iterate_keys(
274282

275283
await self._add_buffer_record(session)
276284

285+
@retry_on_error(SQLAlchemyError)
277286
@override
278287
async def record_exists(self, *, key: str) -> bool:
279288
stmt = select(self._ITEM_TABLE.key).where(

0 commit comments

Comments
 (0)