oplnk-python-utils 1.2.5__py3-none-any.whl → 1.3.0__py3-none-any.whl

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.
@@ -7,7 +7,7 @@ from . import core
7
7
  from . import cache
8
8
  from . import query
9
9
 
10
- __version__ = "1.2.5"
10
+ __version__ = "1.3.0"
11
11
  __author__ = "OpenLink SpA"
12
12
 
13
13
  __all__ = ["core", "cache", "query", "reports", "__version__"]
@@ -4,16 +4,25 @@ Cache utilities - Generic caching functions that can be used across projects
4
4
 
5
5
  import hashlib
6
6
  import json
7
+ import logging
8
+ import time
9
+ import asyncio
7
10
  from typing import Callable, Any, Optional, Union
8
11
  from fastapi.encoders import jsonable_encoder
9
12
  from bson.objectid import ObjectId
10
13
  from fastapi import Request
11
14
  import functools
15
+ from redis.exceptions import (
16
+ ConnectionError as RedisConnectionError,
17
+ TimeoutError as RedisTimeoutError,
18
+ )
12
19
 
13
20
  # Import core utilities
14
21
  from ..core.data import normalize
15
22
  from ..core.serialization import datetime_serializer
16
23
 
24
+ logger = logging.getLogger(__name__)
25
+
17
26
 
18
27
  def generate_cache_id(params: dict) -> str:
19
28
  """
@@ -39,6 +48,8 @@ def get_or_set_cache(
39
48
  clear_cache: Union[str, bool] = False,
40
49
  ttl: Optional[int] = None,
41
50
  serializer: Optional[Callable] = None,
51
+ max_retries: int = 3,
52
+ retry_delay: float = 0.1,
42
53
  ) -> Any:
43
54
  """
44
55
  Retrieve data from cache if available, otherwise fetch, cache, and return it.
@@ -51,24 +62,45 @@ def get_or_set_cache(
51
62
  clear_cache: Pattern to clear or boolean to clear specific key
52
63
  ttl: Time to live in seconds
53
64
  serializer: Function to serialize data before caching
65
+ max_retries: Maximum number of retry attempts for Redis operations
66
+ retry_delay: Delay between retry attempts in seconds
54
67
 
55
68
  Returns:
56
69
  Cached or fetched data
57
70
  """
71
+ # Intentar limpiar cache si se solicita
58
72
  if clear_cache:
59
73
  invalidate_cache_keys(
60
74
  redis_client, clear_cache if isinstance(clear_cache, str) else cache_key
61
75
  )
62
76
 
63
- if redis_client.exists(cache_key):
64
- return json.loads(redis_client.get(cache_key))
77
+ # Intentar obtener del cache
78
+ cached_data = _execute_redis_op(
79
+ lambda: redis_client.get(cache_key) if redis_client.exists(cache_key) else None,
80
+ max_retries=max_retries,
81
+ retry_delay=retry_delay,
82
+ )
83
+
84
+ if cached_data is not None:
85
+ try:
86
+ return json.loads(cached_data)
87
+ except (json.JSONDecodeError, TypeError) as e:
88
+ logger.warning(f"Failed to decode cached data for key {cache_key}: {e}")
65
89
 
90
+ # Cache miss o error: obtener datos frescos
66
91
  data = fetch_func()
67
92
  if serializer:
68
93
  data = serializer(data)
69
94
 
70
95
  encoded_data = jsonable_encoder(data, custom_encoder={ObjectId: str})
71
- redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
96
+
97
+ # Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
98
+ _execute_redis_op(
99
+ lambda: redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl),
100
+ max_retries=max_retries,
101
+ retry_delay=retry_delay,
102
+ )
103
+
72
104
  return encoded_data
73
105
 
74
106
 
@@ -81,21 +113,27 @@ def invalidate_cache_keys(redis_client, pattern: str) -> int:
81
113
  pattern: Pattern to match keys for deletion
82
114
 
83
115
  Returns:
84
- Number of keys deleted
116
+ Number of keys deleted (0 if Redis is unavailable)
85
117
  """
86
- cursor = 0
87
- batch_size = 1000
88
- total_deleted = 0
89
-
90
- while True:
91
- cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=batch_size)
92
- if keys:
93
- redis_client.unlink(*keys)
94
- total_deleted += len(keys)
95
- if cursor == 0:
96
- break
118
+ try:
119
+ cursor = 0
120
+ batch_size = 1000
121
+ total_deleted = 0
122
+
123
+ while True:
124
+ cursor, keys = redis_client.scan(
125
+ cursor=cursor, match=pattern, count=batch_size
126
+ )
127
+ if keys:
128
+ redis_client.unlink(*keys)
129
+ total_deleted += len(keys)
130
+ if cursor == 0:
131
+ break
97
132
 
98
- return total_deleted
133
+ return total_deleted
134
+ except (RedisConnectionError, RedisTimeoutError) as e:
135
+ logger.warning(f"Redis connection error during cache invalidation: {e}")
136
+ return 0
99
137
 
100
138
 
101
139
  async def build_cache_key(
@@ -157,6 +195,71 @@ async def build_cache_key(
157
195
  return f"{api_name}:{collection}:{endpoint_type}:{hashed}"
158
196
 
159
197
 
198
+ def _is_async_client(redis_client) -> bool:
199
+ """
200
+ Detecta si el cliente Redis es asíncrono.
201
+
202
+ Args:
203
+ redis_client: Cliente Redis a verificar
204
+
205
+ Returns:
206
+ True si es asíncrono, False si es síncrono
207
+ """
208
+ return (
209
+ hasattr(redis_client, "__aenter__") or "async" in type(redis_client).__module__
210
+ )
211
+
212
+
213
+ def _execute_redis_op(
214
+ operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
215
+ ) -> Any:
216
+ """
217
+ Ejecuta operación Redis síncrona con reintentos y degradación elegante.
218
+
219
+ Args:
220
+ operation: Función que ejecuta la operación Redis
221
+ max_retries: Número máximo de reintentos
222
+ retry_delay: Tiempo de espera entre reintentos en segundos
223
+
224
+ Returns:
225
+ Resultado de la operación o None si falla después de todos los reintentos
226
+ """
227
+ for attempt in range(max_retries):
228
+ try:
229
+ return operation()
230
+ except (RedisConnectionError, RedisTimeoutError) as e:
231
+ if attempt == max_retries - 1:
232
+ logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
233
+ return None
234
+ time.sleep(retry_delay)
235
+ return None
236
+
237
+
238
+ async def _execute_redis_op_async(
239
+ operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
240
+ ) -> Any:
241
+ """
242
+ Ejecuta operación Redis asíncrona con reintentos y degradación elegante.
243
+
244
+ Args:
245
+ operation: Función async que ejecuta la operación Redis
246
+ max_retries: Número máximo de reintentos
247
+ retry_delay: Tiempo de espera entre reintentos en segundos
248
+
249
+ Returns:
250
+ Resultado de la operación o None si falla después de todos los reintentos
251
+ """
252
+ for attempt in range(max_retries):
253
+ try:
254
+ return await operation()
255
+ except (RedisConnectionError, RedisTimeoutError) as e:
256
+ if attempt == max_retries - 1:
257
+ logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
258
+ return None
259
+ await asyncio.sleep(retry_delay)
260
+ return None
261
+
262
+
160
263
  def cache_response(
161
264
  redis_client,
162
265
  api_name: str,
@@ -165,22 +268,25 @@ def cache_response(
165
268
  tags: list[str],
166
269
  ttl: int | None = None,
167
270
  use_cache: bool = True,
271
+ max_retries: int = 3,
168
272
  ):
169
273
  """
170
274
  Decorator to cache the response of an endpoint.
171
275
 
172
276
  Args:
173
- redis_client: Redis client instance
277
+ redis_client: Redis client instance (sync or async)
174
278
  api_name: Name of the API
175
279
  collection: Name of the collection
176
280
  endpoint_type: Type of endpoint
177
281
  tags: List of tags to use for caching
178
282
  ttl: Time to live in seconds
179
283
  use_cache: Whether to use cache
284
+ max_retries: Maximum number of retry attempts for Redis operations
180
285
 
181
286
  Returns:
182
287
  Decorator function
183
288
  """
289
+ is_async = _is_async_client(redis_client)
184
290
 
185
291
  def decorator(func: Callable):
186
292
  @functools.wraps(func)
@@ -209,21 +315,81 @@ def cache_response(
209
315
  }
210
316
  else:
211
317
  clear_cache_flag = bool(clear_cache_value)
318
+
319
+ # Intentar limpiar cache si se solicita
212
320
  if clear_cache_flag:
213
- invalidate_tag(redis_client, f"{collection}")
321
+ try:
322
+ invalidate_tag(redis_client, f"{collection}")
323
+ except (RedisConnectionError, RedisTimeoutError) as e:
324
+ logger.warning(f"Redis connection error during cache clear: {e}")
214
325
 
215
326
  # Construir clave
216
327
  cache_key = await build_cache_key(
217
328
  request, api_name, collection, endpoint_type
218
329
  )
219
- if redis_client.exists(cache_key):
220
- return json.loads(redis_client.get(cache_key))
221
330
 
331
+ # Intentar obtener del cache
332
+ cached_data = None
333
+ if is_async:
334
+
335
+ async def get_cached_data():
336
+ exists_result = await redis_client.exists(cache_key)
337
+ if exists_result:
338
+ return await redis_client.get(cache_key)
339
+ return None
340
+
341
+ cached_data = await _execute_redis_op_async(
342
+ get_cached_data, max_retries=max_retries
343
+ )
344
+ else:
345
+ cached_data = _execute_redis_op(
346
+ lambda: (
347
+ redis_client.get(cache_key)
348
+ if redis_client.exists(cache_key)
349
+ else None
350
+ ),
351
+ max_retries=max_retries,
352
+ )
353
+
354
+ if cached_data is not None:
355
+ try:
356
+ return json.loads(cached_data)
357
+ except (json.JSONDecodeError, TypeError) as e:
358
+ logger.warning(
359
+ f"Failed to decode cached data for key {cache_key}: {e}"
360
+ )
361
+
362
+ # Cache miss o error: ejecutar función y cachear respuesta
222
363
  response = await func(*args, **kwargs)
223
364
  encoded_data = jsonable_encoder(response, custom_encoder={ObjectId: str})
224
- redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
225
- for tag in tags:
226
- redis_client.sadd(f"tag:{tag}", cache_key)
365
+
366
+ # Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
367
+ if is_async:
368
+
369
+ async def set_cache_data():
370
+ await redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
371
+
372
+ await _execute_redis_op_async(set_cache_data, max_retries=max_retries)
373
+ # Agregar tags
374
+ for tag in tags:
375
+ # Capturar el valor de tag en la closure usando parámetro por defecto
376
+ async def add_tag(tag_name=tag):
377
+ await redis_client.sadd(f"tag:{tag_name}", cache_key)
378
+
379
+ await _execute_redis_op_async(add_tag, max_retries=max_retries)
380
+ else:
381
+ _execute_redis_op(
382
+ lambda: redis_client.set(
383
+ cache_key, json.dumps(encoded_data), ex=ttl
384
+ ),
385
+ max_retries=max_retries,
386
+ )
387
+ # Agregar tags
388
+ for tag in tags:
389
+ _execute_redis_op(
390
+ lambda t=tag: redis_client.sadd(f"tag:{t}", cache_key),
391
+ max_retries=max_retries,
392
+ )
227
393
 
228
394
  return encoded_data
229
395
 
@@ -272,7 +438,10 @@ def invalidate_tag(redis_client, tag: str):
272
438
  redis_client: Redis client instance
273
439
  tag: Tag to invalidate
274
440
  """
275
- keys = redis_client.smembers(f"tag:{tag}")
276
- for key in keys:
277
- redis_client.delete(key)
278
- redis_client.delete(f"tag:{tag}")
441
+ try:
442
+ keys = redis_client.smembers(f"tag:{tag}")
443
+ for key in keys:
444
+ redis_client.delete(key)
445
+ redis_client.delete(f"tag:{tag}")
446
+ except (RedisConnectionError, RedisTimeoutError) as e:
447
+ logger.warning(f"Redis connection error during tag invalidation: {e}")