pararamio-aio 2.1.1__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.
Files changed (57) hide show
  1. pararamio_aio/__init__.py +78 -0
  2. pararamio_aio/_core/__init__.py +125 -0
  3. pararamio_aio/_core/_types.py +120 -0
  4. pararamio_aio/_core/base.py +143 -0
  5. pararamio_aio/_core/client_protocol.py +90 -0
  6. pararamio_aio/_core/constants/__init__.py +7 -0
  7. pararamio_aio/_core/constants/base.py +9 -0
  8. pararamio_aio/_core/constants/endpoints.py +84 -0
  9. pararamio_aio/_core/cookie_decorator.py +208 -0
  10. pararamio_aio/_core/cookie_manager.py +1222 -0
  11. pararamio_aio/_core/endpoints.py +67 -0
  12. pararamio_aio/_core/exceptions/__init__.py +6 -0
  13. pararamio_aio/_core/exceptions/auth.py +91 -0
  14. pararamio_aio/_core/exceptions/base.py +124 -0
  15. pararamio_aio/_core/models/__init__.py +17 -0
  16. pararamio_aio/_core/models/base.py +66 -0
  17. pararamio_aio/_core/models/chat.py +92 -0
  18. pararamio_aio/_core/models/post.py +65 -0
  19. pararamio_aio/_core/models/user.py +54 -0
  20. pararamio_aio/_core/py.typed +2 -0
  21. pararamio_aio/_core/utils/__init__.py +73 -0
  22. pararamio_aio/_core/utils/async_requests.py +417 -0
  23. pararamio_aio/_core/utils/auth_flow.py +202 -0
  24. pararamio_aio/_core/utils/authentication.py +235 -0
  25. pararamio_aio/_core/utils/captcha.py +92 -0
  26. pararamio_aio/_core/utils/helpers.py +336 -0
  27. pararamio_aio/_core/utils/http_client.py +199 -0
  28. pararamio_aio/_core/utils/requests.py +424 -0
  29. pararamio_aio/_core/validators.py +78 -0
  30. pararamio_aio/_types.py +29 -0
  31. pararamio_aio/client.py +989 -0
  32. pararamio_aio/constants/__init__.py +16 -0
  33. pararamio_aio/cookie_manager.py +15 -0
  34. pararamio_aio/exceptions/__init__.py +31 -0
  35. pararamio_aio/exceptions/base.py +1 -0
  36. pararamio_aio/file_operations.py +232 -0
  37. pararamio_aio/models/__init__.py +32 -0
  38. pararamio_aio/models/activity.py +127 -0
  39. pararamio_aio/models/attachment.py +141 -0
  40. pararamio_aio/models/base.py +83 -0
  41. pararamio_aio/models/bot.py +274 -0
  42. pararamio_aio/models/chat.py +722 -0
  43. pararamio_aio/models/deferred_post.py +174 -0
  44. pararamio_aio/models/file.py +103 -0
  45. pararamio_aio/models/group.py +361 -0
  46. pararamio_aio/models/poll.py +275 -0
  47. pararamio_aio/models/post.py +643 -0
  48. pararamio_aio/models/team.py +403 -0
  49. pararamio_aio/models/user.py +239 -0
  50. pararamio_aio/py.typed +2 -0
  51. pararamio_aio/utils/__init__.py +18 -0
  52. pararamio_aio/utils/authentication.py +383 -0
  53. pararamio_aio/utils/requests.py +75 -0
  54. pararamio_aio-2.1.1.dist-info/METADATA +269 -0
  55. pararamio_aio-2.1.1.dist-info/RECORD +57 -0
  56. pararamio_aio-2.1.1.dist-info/WHEEL +5 -0
  57. pararamio_aio-2.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1222 @@
1
+ """Cookie management with versioning, locking and multiple storage backends."""
2
+ # pylint: disable=too-many-lines
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import functools
8
+ import json
9
+ import logging
10
+ import os
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from abc import ABC, abstractmethod
15
+ from datetime import datetime, timezone
16
+ from http.cookiejar import Cookie, CookieJar
17
+ from pathlib import Path
18
+ from typing import Any, Callable, TypeVar
19
+
20
+ from .exceptions.base import PararamioException
21
+
22
+ T = TypeVar('T')
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ class CookieManager(ABC):
29
+ """Abstract cookie manager with versioning and locking support."""
30
+
31
+ @abstractmethod
32
+ def load_cookies(self) -> bool:
33
+ """Load cookies from storage."""
34
+
35
+ @abstractmethod
36
+ def save_cookies(self) -> None:
37
+ """Save cookies to storage."""
38
+
39
+ @abstractmethod
40
+ def add_cookie(self, cookie: Cookie) -> None:
41
+ """Add or update a cookie."""
42
+
43
+ @abstractmethod
44
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
45
+ """Get a specific cookie."""
46
+
47
+ @abstractmethod
48
+ def get_all_cookies(self) -> list[Cookie]:
49
+ """Get all cookies."""
50
+
51
+ @abstractmethod
52
+ def clear_cookies(self) -> None:
53
+ """Clear all cookies."""
54
+
55
+ @abstractmethod
56
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
57
+ """Update cookies from a CookieJar."""
58
+
59
+ @abstractmethod
60
+ def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
61
+ """Acquire lock for authentication process."""
62
+
63
+ @abstractmethod
64
+ def release_auth_lock(self) -> None:
65
+ """Release authentication lock."""
66
+
67
+ @abstractmethod
68
+ def check_version(self) -> bool:
69
+ """Check if our version matches storage version."""
70
+
71
+ @abstractmethod
72
+ def refresh_if_needed(self) -> bool:
73
+ """Reload cookies if version changed."""
74
+
75
+ @abstractmethod
76
+ def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
77
+ """Handle authentication error with version check and retry."""
78
+
79
+
80
+ class CookieManagerMixin:
81
+ """Mixin with common cookie management functionality."""
82
+
83
+ # These attributes are expected to be defined by classes using this mixin
84
+ _cookies: dict[str, Cookie]
85
+ _version: int
86
+
87
+ @staticmethod
88
+ def make_key(cookie: Cookie) -> str:
89
+ """Make a unique key for cookie."""
90
+ return f'{cookie.domain}:{cookie.path}:{cookie.name}'
91
+
92
+ @staticmethod
93
+ def cookie_to_dict(cookie: Cookie) -> dict[str, Any]:
94
+ """Convert Cookie object to dictionary."""
95
+ return {
96
+ 'name': cookie.name,
97
+ 'value': cookie.value,
98
+ 'domain': cookie.domain,
99
+ 'path': cookie.path,
100
+ 'secure': cookie.secure,
101
+ 'expires': cookie.expires,
102
+ 'discard': cookie.discard,
103
+ 'comment': cookie.comment,
104
+ 'comment_url': cookie.comment_url,
105
+ 'rfc2109': cookie.rfc2109,
106
+ 'port': cookie.port,
107
+ 'port_specified': cookie.port_specified,
108
+ 'domain_specified': cookie.domain_specified,
109
+ 'domain_initial_dot': cookie.domain_initial_dot,
110
+ 'path_specified': cookie.path_specified,
111
+ 'version': cookie.version,
112
+ }
113
+
114
+ @staticmethod
115
+ def dict_to_cookie(data: dict[str, Any]) -> Cookie | None:
116
+ """Convert dictionary to Cookie object."""
117
+ try:
118
+ return Cookie(
119
+ version=data.get('version', 0),
120
+ name=data['name'],
121
+ value=data['value'],
122
+ port=data.get('port'),
123
+ port_specified=data.get('port_specified', False),
124
+ domain=data['domain'],
125
+ domain_specified=data.get('domain_specified', True),
126
+ domain_initial_dot=data.get('domain_initial_dot', False),
127
+ path=data['path'],
128
+ path_specified=data.get('path_specified', True),
129
+ secure=data.get('secure', False),
130
+ expires=data.get('expires'),
131
+ discard=data.get('discard', False),
132
+ comment=data.get('comment'),
133
+ comment_url=data.get('comment_url'),
134
+ rest={},
135
+ rfc2109=data.get('rfc2109', False),
136
+ )
137
+ except (KeyError, TypeError):
138
+ log.exception('Failed to create cookie from dict')
139
+ return None
140
+
141
+ def populate_jar(self, cookie_jar: CookieJar) -> None:
142
+ """Populate a CookieJar with our cookies."""
143
+ cookies = getattr(self, '_cookies', {})
144
+ # Check if we need thread safety
145
+ lock = getattr(self, '_lock', None)
146
+ if lock is not None:
147
+ with lock:
148
+ for cookie in cookies.values():
149
+ cookie_jar.set_cookie(cookie)
150
+ else:
151
+ for cookie in cookies.values():
152
+ cookie_jar.set_cookie(cookie)
153
+
154
+ def has_cookies(self) -> bool:
155
+ """Check if manager has any cookies."""
156
+ return bool(getattr(self, '_cookies', {}))
157
+
158
+ def _get_file_version(self) -> int:
159
+ """Get current version from file."""
160
+ version_path = getattr(self, 'version_path', None)
161
+ if not version_path or not version_path.exists():
162
+ return 0
163
+
164
+ try:
165
+ with version_path.open(encoding='utf-8') as f:
166
+ return int(f.read().strip())
167
+ except (OSError, ValueError):
168
+ return 0
169
+
170
+ def _load_cookies_from_json(self, data: str | None) -> bool:
171
+ """Load cookies from JSON data.
172
+
173
+ Args:
174
+ data: JSON string containing cookies data
175
+
176
+ Returns:
177
+ True if loaded successfully, False otherwise
178
+ """
179
+ if not data:
180
+ return False
181
+
182
+ parsed_data = json.loads(data)
183
+ return self._load_cookies_from_dict(parsed_data)
184
+
185
+ def _load_cookies_from_dict(self, data: dict) -> bool:
186
+ """Load cookies from dictionary data.
187
+
188
+ Args:
189
+ data: Dictionary containing cookies data
190
+
191
+ Returns:
192
+ True if loaded successfully
193
+ """
194
+ self._version = data.get('version', 0)
195
+ cookies_data = data.get('cookies', [])
196
+
197
+ self._cookies.clear()
198
+ for cookie_dict in cookies_data:
199
+ cookie = self.dict_to_cookie(cookie_dict)
200
+ if cookie:
201
+ key = self.make_key(cookie)
202
+ self._cookies[key] = cookie
203
+
204
+ return True
205
+
206
+ def _prepare_cookies_data(self) -> dict:
207
+ """Prepare cookies data for saving.
208
+
209
+ Returns:
210
+ Dictionary with version, cookies, and timestamp
211
+ """
212
+ cookies = getattr(self, '_cookies', {})
213
+ version = getattr(self, '_version', 0)
214
+
215
+ cookies_data = [
216
+ self.cookie_to_dict(cookie)
217
+ for cookie in cookies.values()
218
+ if not cookie.discard # Only save persistent cookies
219
+ ]
220
+
221
+ return {
222
+ 'version': version,
223
+ 'cookies': cookies_data,
224
+ 'saved_at': datetime.now(timezone.utc).isoformat(),
225
+ }
226
+
227
+ def _increment_file_version(self) -> int:
228
+ """Increment version and save to file."""
229
+ version_path = getattr(self, 'version_path', None)
230
+ if not version_path:
231
+ return 0
232
+
233
+ self._version = self._get_file_version() + 1
234
+
235
+ try:
236
+ version_path.parent.mkdir(parents=True, exist_ok=True)
237
+ with version_path.open('w', encoding='utf-8') as f:
238
+ f.write(str(self._version))
239
+ except OSError:
240
+ log.exception('Failed to save version')
241
+
242
+ return self._version
243
+
244
+
245
+ class FileCookieManager(CookieManagerMixin, CookieManager):
246
+ """File-based cookie manager implementation."""
247
+
248
+ def __init__(self, file_path: str):
249
+ self.file_path = Path(file_path)
250
+ self.lock_path = Path(f'{file_path}.lock')
251
+ self.version_path = Path(f'{file_path}.version')
252
+ self._cookies: dict[str, Cookie] = {}
253
+ self._version: int = 0
254
+ self._lock = threading.Lock()
255
+ self._lock_fd: int | None = None
256
+
257
+ # Automatically load cookies if file exists
258
+ if self.file_path.exists():
259
+ try:
260
+ self.load_cookies()
261
+ except (OSError, json.JSONDecodeError, ValueError):
262
+ # Log error but don't fail initialization
263
+ log.exception(
264
+ 'Failed to load cookies from %s during initialization', self.file_path
265
+ )
266
+
267
+ @property
268
+ def version(self) -> int:
269
+ """Get current version."""
270
+ return self._version
271
+
272
+ def load_cookies(self) -> bool:
273
+ """Load cookies from file."""
274
+ with self._lock:
275
+ if not self.file_path.exists():
276
+ return False
277
+
278
+ try:
279
+ with self.file_path.open(encoding='utf-8') as f:
280
+ data = json.load(f)
281
+
282
+ self._load_cookies_from_dict(data)
283
+
284
+ except (OSError, json.JSONDecodeError) as e:
285
+ log.warning('Failed to load cookies from %s: %s', self.file_path, e)
286
+ return False
287
+ return True
288
+
289
+ def save_cookies(self) -> None:
290
+ """Save cookies to file."""
291
+ with self._lock:
292
+ data = self._prepare_cookies_data()
293
+
294
+ # Ensure directory exists
295
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
296
+
297
+ # Write to temp file first
298
+ temp_path = Path(f'{self.file_path}.tmp')
299
+ try:
300
+ with temp_path.open('w', encoding='utf-8') as f:
301
+ json.dump(data, f, indent=2)
302
+ # Atomic rename
303
+ temp_path.rename(self.file_path)
304
+ except (OSError, TypeError, ValueError):
305
+ log.exception('Failed to save cookies to %s', self.file_path)
306
+ if temp_path.exists():
307
+ temp_path.unlink()
308
+ raise
309
+
310
+ def add_cookie(self, cookie: Cookie) -> None:
311
+ """Add or update a cookie."""
312
+ with self._lock:
313
+ key = self.make_key(cookie)
314
+ self._cookies[key] = cookie
315
+ self._version = self._increment_version()
316
+
317
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
318
+ """Get a specific cookie."""
319
+ with self._lock:
320
+ key = f'{domain}:{path}:{name}'
321
+ return self._cookies.get(key)
322
+
323
+ def get_all_cookies(self) -> list[Cookie]:
324
+ """Get all cookies."""
325
+ with self._lock:
326
+ return list(self._cookies.values())
327
+
328
+ def clear_cookies(self) -> None:
329
+ """Clear all cookies."""
330
+ with self._lock:
331
+ self._cookies.clear()
332
+ self._version = self._increment_version()
333
+
334
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
335
+ """Update cookies from a CookieJar."""
336
+ with self._lock:
337
+ for cookie in cookie_jar:
338
+ key = self.make_key(cookie)
339
+ self._cookies[key] = cookie
340
+ self._version = self._increment_version()
341
+
342
+ def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
343
+ """Acquire file lock with timeout."""
344
+ start_time = time.time()
345
+
346
+ while time.time() - start_time < timeout:
347
+ try:
348
+ # Try to create lock file exclusively
349
+ self._lock_fd = os.open(
350
+ str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644
351
+ )
352
+ except FileExistsError:
353
+ # Lock exists, check if it's stale
354
+ if self._is_lock_stale():
355
+ self._remove_stale_lock()
356
+ continue
357
+ time.sleep(0.1)
358
+ continue
359
+ # Write PID for debugging
360
+ os.write(self._lock_fd, str(os.getpid()).encode())
361
+ return True
362
+
363
+ return False
364
+
365
+ def release_auth_lock(self) -> None:
366
+ """Release file lock."""
367
+ if self._lock_fd is not None:
368
+ try:
369
+ os.close(self._lock_fd)
370
+ self.lock_path.unlink(missing_ok=True)
371
+ except OSError as e:
372
+ log.warning('Failed to release lock: %s', e)
373
+ finally:
374
+ self._lock_fd = None
375
+
376
+ def check_version(self) -> bool:
377
+ """Check if our version matches file version."""
378
+ current_version = self._get_file_version()
379
+ return current_version == self._version
380
+
381
+ def refresh_if_needed(self) -> bool:
382
+ """Reload cookies if version changed."""
383
+ if not self.check_version():
384
+ log.info('Cookie version mismatch, reloading...')
385
+ return self.load_cookies()
386
+ return True
387
+
388
+ def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
389
+ """Handle authentication error with version check and retry.
390
+
391
+ Args:
392
+ retry_callback: Function to call for retry
393
+ *args, **kwargs: Arguments to pass to retry_callback
394
+
395
+ Returns:
396
+ Result of retry_callback if successful
397
+
398
+ Raises:
399
+ Original exception if all retries fail
400
+ """
401
+ log.info('Authentication error occurred, checking cookie version...')
402
+
403
+ # First, check if our cookies are outdated
404
+ if not self.check_version():
405
+ log.info('Cookie version outdated, reloading...')
406
+ if self.load_cookies():
407
+ log.info('Cookies reloaded, retrying with updated cookies...')
408
+ try:
409
+ return retry_callback(*args, **kwargs)
410
+ except Exception as e: # pylint: disable=broad-exception-caught
411
+ log.warning('Retry with updated cookies failed: %s', e)
412
+ else:
413
+ log.warning('Failed to reload cookies')
414
+
415
+ # If still failing, acquire lock and re-authenticate
416
+ log.info('Attempting re-authentication...')
417
+ if self.acquire_auth_lock(timeout=30.0):
418
+ try:
419
+ # Clear existing cookies
420
+ self.clear_cookies()
421
+ self.save_cookies()
422
+
423
+ # Call retry which should trigger re-authentication
424
+ result = retry_callback(*args, **kwargs)
425
+
426
+ # Save new cookies after successful auth
427
+ self.save_cookies()
428
+ return result
429
+
430
+ finally:
431
+ self.release_auth_lock()
432
+ msg = 'Failed to acquire authentication lock for re-authentication'
433
+ raise PararamioException(msg)
434
+
435
+ def _increment_version(self) -> int:
436
+ """Increment version and save to file."""
437
+ return self._increment_file_version()
438
+
439
+ def _is_lock_stale(self, max_age: float = 300.0) -> bool:
440
+ """Check if lock file is stale (older than max_age seconds)."""
441
+ try:
442
+ stat = self.lock_path.stat()
443
+ except FileNotFoundError:
444
+ return False
445
+ age = time.time() - stat.st_mtime
446
+ return age > max_age
447
+
448
+ def _remove_stale_lock(self) -> None:
449
+ """Remove stale lock file."""
450
+ try:
451
+ self.lock_path.unlink()
452
+ log.info('Removed stale lock file: %s', self.lock_path)
453
+ except FileNotFoundError:
454
+ pass
455
+
456
+
457
+ class RedisCookieManager(CookieManagerMixin, CookieManager):
458
+ """Redis-based cookie manager implementation."""
459
+
460
+ def __init__(self, redis_client: Any, key_prefix: str = 'pararamio:cookies'):
461
+ self.redis = redis_client
462
+ self.key_prefix = key_prefix
463
+ self.data_key = f'{key_prefix}:data'
464
+ self.lock_key = f'{key_prefix}:lock'
465
+ self.version_key = f'{key_prefix}:version'
466
+ self._cookies: dict[str, Cookie] = {}
467
+ self._version: int = 0
468
+ self._lock = threading.Lock()
469
+ self._lock_token: str | None = None
470
+
471
+ @property
472
+ def version(self) -> int:
473
+ """Get current version."""
474
+ return self._version
475
+
476
+ def load_cookies(self) -> bool:
477
+ """Load cookies from Redis."""
478
+ with self._lock:
479
+ try:
480
+ data = self.redis.get(self.data_key)
481
+ return self._load_cookies_from_json(data)
482
+ except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
483
+ log.warning('Failed to load cookies from Redis: %s', e)
484
+ return False
485
+
486
+ def save_cookies(self) -> None:
487
+ """Save cookies to Redis."""
488
+ with self._lock:
489
+ data = self._prepare_cookies_data()
490
+
491
+ try:
492
+ self.redis.set(self.data_key, json.dumps(data))
493
+ except (OSError, TypeError, ValueError, AttributeError):
494
+ log.exception('Failed to save cookies to Redis')
495
+ raise
496
+
497
+ def add_cookie(self, cookie: Cookie) -> None:
498
+ """Add or update a cookie."""
499
+ with self._lock:
500
+ key = self.make_key(cookie)
501
+ self._cookies[key] = cookie
502
+ self._version = self._increment_version()
503
+
504
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
505
+ """Get a specific cookie."""
506
+ with self._lock:
507
+ key = f'{domain}:{path}:{name}'
508
+ return self._cookies.get(key)
509
+
510
+ def get_all_cookies(self) -> list[Cookie]:
511
+ """Get all cookies."""
512
+ with self._lock:
513
+ return list(self._cookies.values())
514
+
515
+ def clear_cookies(self) -> None:
516
+ """Clear all cookies."""
517
+ with self._lock:
518
+ self._cookies.clear()
519
+ self._version = self._increment_version()
520
+
521
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
522
+ """Update cookies from a CookieJar."""
523
+ with self._lock:
524
+ for cookie in cookie_jar:
525
+ key = self.make_key(cookie)
526
+ self._cookies[key] = cookie
527
+ self._version = self._increment_version()
528
+
529
+ def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
530
+ """Acquire distributed lock using Redis."""
531
+ self._lock_token = str(uuid.uuid4())
532
+
533
+ start_time = time.time()
534
+ while time.time() - start_time < timeout:
535
+ # Try to set lock with expiration
536
+ if self.redis.set(self.lock_key, self._lock_token, nx=True, ex=300):
537
+ return True
538
+ time.sleep(0.1)
539
+
540
+ return False
541
+
542
+ def release_auth_lock(self) -> None:
543
+ """Release distributed lock."""
544
+ if self._lock_token:
545
+ # Use Lua script for atomic check-and-delete
546
+ lua_script = """
547
+ if redis.call("get", KEYS[1]) == ARGV[1] then
548
+ return redis.call("del", KEYS[1])
549
+ else
550
+ return 0
551
+ end
552
+ """
553
+ try:
554
+ self.redis.eval(lua_script, 1, self.lock_key, self._lock_token)
555
+ except (AttributeError, KeyError) as e:
556
+ log.warning('Failed to release Redis lock: %s', e)
557
+ finally:
558
+ self._lock_token = None
559
+
560
+ def check_version(self) -> bool:
561
+ """Check if our version matches Redis version."""
562
+ try:
563
+ version = self.redis.get(self.version_key)
564
+ except (ValueError, AttributeError, TypeError, KeyError):
565
+ return True
566
+ current_version = int(version) if version else 0
567
+ return current_version == self._version
568
+
569
+ def refresh_if_needed(self) -> bool:
570
+ """Reload cookies if version changed."""
571
+ if not self.check_version():
572
+ log.info('Cookie version mismatch, reloading...')
573
+ return self.load_cookies()
574
+ return True
575
+
576
+ def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
577
+ """Handle authentication error with version check and retry."""
578
+ log.info('Authentication error occurred (Redis), checking cookie version...')
579
+
580
+ # First, check if our cookies are outdated
581
+ if not self.check_version():
582
+ log.info('Cookie version outdated, reloading from Redis...')
583
+ if self.load_cookies():
584
+ log.info('Cookies reloaded, retrying with updated cookies...')
585
+ try:
586
+ return retry_callback(*args, **kwargs)
587
+ except Exception as e: # pylint: disable=broad-exception-caught
588
+ log.warning('Retry with updated cookies failed: %s', e)
589
+ else:
590
+ log.warning('Failed to reload cookies from Redis')
591
+
592
+ # If still failing, acquire distributed lock and re-authenticate
593
+ log.info('Attempting re-authentication with distributed lock...')
594
+ if self.acquire_auth_lock(timeout=30.0):
595
+ try:
596
+ # Clear existing cookies
597
+ self.clear_cookies()
598
+ self.save_cookies()
599
+
600
+ # Call retry which should trigger re-authentication
601
+ result = retry_callback(*args, **kwargs)
602
+
603
+ # Save new cookies after successful auth
604
+ self.save_cookies()
605
+ return result
606
+
607
+ finally:
608
+ self.release_auth_lock()
609
+ else:
610
+ msg = 'Failed to acquire distributed lock for re-authentication'
611
+ raise PararamioException(msg)
612
+
613
+ def _increment_version(self) -> int:
614
+ """Atomically increment version in Redis."""
615
+ try:
616
+ self._version = self.redis.incr(self.version_key)
617
+ except (AttributeError, TypeError):
618
+ log.exception('Failed to increment version in Redis')
619
+ return self._version
620
+ return self._version
621
+
622
+
623
+ class InMemoryCookieManager(CookieManagerMixin, CookieManager):
624
+ """In-memory cookie manager implementation (no persistence)."""
625
+
626
+ def __init__(self):
627
+ self._cookies: dict[str, Cookie] = {}
628
+ self._version: int = 0
629
+ self._lock = threading.Lock()
630
+
631
+ @property
632
+ def version(self) -> int:
633
+ """Get current version."""
634
+ return self._version
635
+
636
+ def load_cookies(self) -> bool:
637
+ """No-op for in-memory manager."""
638
+ # Cookies are already in memory
639
+ return bool(self._cookies)
640
+
641
+ def save_cookies(self) -> None:
642
+ """No-op for in-memory manager."""
643
+ # Cookies are already in memory, nothing to save
644
+
645
+ def add_cookie(self, cookie: Cookie) -> None:
646
+ """Add or update a cookie."""
647
+ with self._lock:
648
+ key = self.make_key(cookie)
649
+ self._cookies[key] = cookie
650
+ self._version += 1
651
+
652
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
653
+ """Get a specific cookie."""
654
+ with self._lock:
655
+ key = f'{domain}:{path}:{name}'
656
+ return self._cookies.get(key)
657
+
658
+ def get_all_cookies(self) -> list[Cookie]:
659
+ """Get all cookies."""
660
+ with self._lock:
661
+ return list(self._cookies.values())
662
+
663
+ def clear_cookies(self) -> None:
664
+ """Clear all cookies."""
665
+ with self._lock:
666
+ self._cookies.clear()
667
+ self._version += 1
668
+
669
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
670
+ """Update cookies from a CookieJar."""
671
+ with self._lock:
672
+ for cookie in cookie_jar:
673
+ key = self.make_key(cookie)
674
+ self._cookies[key] = cookie
675
+ self._version += 1
676
+
677
+ def acquire_auth_lock(self, timeout: float = 30.0) -> bool: # noqa: ARG002
678
+ """Always returns True for in-memory manager."""
679
+ return True
680
+
681
+ def release_auth_lock(self) -> None:
682
+ """No-op for in-memory manager."""
683
+
684
+ def check_version(self) -> bool:
685
+ """Always returns True for in-memory manager."""
686
+ return True
687
+
688
+ def refresh_if_needed(self) -> bool:
689
+ """No-op for in-memory manager."""
690
+ return True
691
+
692
+ def handle_auth_error(self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any) -> T:
693
+ """Handle authentication error by retrying."""
694
+ log.info('Authentication error occurred (in-memory), retrying...')
695
+
696
+ # For in-memory, just clear cookies and retry
697
+ self.clear_cookies()
698
+
699
+ try:
700
+ return retry_callback(*args, **kwargs)
701
+ except (AttributeError, TypeError):
702
+ log.exception('Retry failed')
703
+ raise
704
+
705
+
706
+ # Async versions
707
+ class AsyncCookieManager(ABC):
708
+ """Abstract async cookie manager."""
709
+
710
+ @abstractmethod
711
+ async def load_cookies(self) -> bool:
712
+ """Load cookies from storage asynchronously."""
713
+
714
+ @abstractmethod
715
+ async def save_cookies(self) -> None:
716
+ """Save cookies to storage asynchronously."""
717
+
718
+ @abstractmethod
719
+ async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
720
+ """Acquire lock for authentication process asynchronously."""
721
+
722
+ @abstractmethod
723
+ async def release_auth_lock(self) -> None:
724
+ """Release authentication lock asynchronously."""
725
+
726
+ @abstractmethod
727
+ async def handle_auth_error(
728
+ self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
729
+ ) -> T:
730
+ """Handle authentication error with version check and retry asynchronously."""
731
+
732
+
733
+ class AsyncFileCookieManager(CookieManagerMixin, AsyncCookieManager):
734
+ """Async file-based cookie manager."""
735
+
736
+ def __init__(self, file_path: str):
737
+ self.file_path = Path(file_path)
738
+ self.lock_path = Path(f'{file_path}.lock')
739
+ self.version_path = Path(f'{file_path}.version')
740
+ self._cookies: dict[str, Cookie] = {}
741
+ self._version: int = 0
742
+ self._lock = asyncio.Lock()
743
+ self._lock_fd: int | None = None
744
+
745
+ # Automatically load cookies synchronously if file exists
746
+ if self.file_path.exists():
747
+ try:
748
+ # Use synchronous file reading for initialization
749
+ with self.file_path.open(encoding='utf-8') as f:
750
+ data = json.load(f)
751
+ self._load_cookies_from_dict(data)
752
+ # Try to load version
753
+ if self.version_path.exists():
754
+ with self.version_path.open(encoding='utf-8') as f:
755
+ self._version = int(f.read().strip())
756
+ except (OSError, json.JSONDecodeError, ValueError):
757
+ # Log error but don't fail initialization
758
+ log.exception(
759
+ 'Failed to load cookies from %s during initialization', self.file_path
760
+ )
761
+
762
+ @property
763
+ def version(self) -> int:
764
+ """Get current version."""
765
+ return self._version
766
+
767
+ async def load_cookies(self) -> bool:
768
+ """Load cookies from file asynchronously."""
769
+ async with self._lock:
770
+ if not self.file_path.exists():
771
+ return False
772
+
773
+ try:
774
+ loop = asyncio.get_event_loop()
775
+ read_func = functools.partial(self.file_path.read_text, encoding='utf-8')
776
+ content = await loop.run_in_executor(None, read_func)
777
+ data = json.loads(content)
778
+
779
+ self._load_cookies_from_dict(data)
780
+
781
+ except (OSError, json.JSONDecodeError) as e:
782
+ log.warning('Failed to load cookies from %s: %s', self.file_path, e)
783
+ return False
784
+ return True
785
+
786
+ async def save_cookies(self) -> None:
787
+ """Save cookies to file asynchronously."""
788
+ async with self._lock:
789
+ data = self._prepare_cookies_data()
790
+
791
+ # Ensure directory exists
792
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
793
+
794
+ # Write to temp file first
795
+ temp_path = Path(f'{self.file_path}.tmp')
796
+ try:
797
+ loop = asyncio.get_event_loop()
798
+ content = json.dumps(data, indent=2)
799
+ # Use functools.partial to bind the arguments
800
+ write_func = functools.partial(temp_path.write_text, content, encoding='utf-8')
801
+ await loop.run_in_executor(None, write_func)
802
+ # Atomic rename
803
+ await loop.run_in_executor(None, temp_path.rename, self.file_path)
804
+ except (OSError, TypeError, ValueError):
805
+ log.exception('Failed to save cookies to %s', self.file_path)
806
+ if temp_path.exists():
807
+ temp_path.unlink()
808
+ raise
809
+
810
+ def add_cookie(self, cookie: Cookie) -> None:
811
+ """Add or update a cookie."""
812
+ key = self.make_key(cookie)
813
+ self._cookies[key] = cookie
814
+ self._version = self._increment_version()
815
+
816
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
817
+ """Get a specific cookie."""
818
+ key = f'{domain}:{path}:{name}'
819
+ return self._cookies.get(key)
820
+
821
+ def get_all_cookies(self) -> list[Cookie]:
822
+ """Get all cookies."""
823
+ return list(self._cookies.values())
824
+
825
+ def clear_cookies(self) -> None:
826
+ """Clear all cookies."""
827
+ self._cookies.clear()
828
+ self._version = self._increment_version()
829
+
830
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
831
+ """Update cookies from a CookieJar."""
832
+ for cookie in cookie_jar:
833
+ key = self.make_key(cookie)
834
+ self._cookies[key] = cookie
835
+ self._version = self._increment_version()
836
+
837
+ async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
838
+ """Acquire file lock asynchronously."""
839
+ start_time = time.time()
840
+ loop = asyncio.get_event_loop()
841
+
842
+ while time.time() - start_time < timeout:
843
+ try:
844
+ # Try to create lock file exclusively
845
+ self._lock_fd = await loop.run_in_executor(
846
+ None, os.open, str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644
847
+ )
848
+ except FileExistsError:
849
+ # Lock exists, check if it's stale
850
+ if await self._is_lock_stale():
851
+ await self._remove_stale_lock()
852
+ continue
853
+ await asyncio.sleep(0.1)
854
+ continue
855
+ # Write PID for debugging
856
+ await loop.run_in_executor(None, os.write, self._lock_fd, str(os.getpid()).encode())
857
+ return True
858
+
859
+ return False
860
+
861
+ async def release_auth_lock(self) -> None:
862
+ """Release file lock asynchronously."""
863
+ if self._lock_fd is not None:
864
+ try:
865
+ loop = asyncio.get_event_loop()
866
+ await loop.run_in_executor(None, os.close, self._lock_fd)
867
+ self.lock_path.unlink(missing_ok=True)
868
+ except OSError as e:
869
+ log.warning('Failed to release lock: %s', e)
870
+ finally:
871
+ self._lock_fd = None
872
+
873
+ def check_version(self) -> bool:
874
+ """Check if our version matches file version."""
875
+ current_version = self._get_file_version()
876
+ return current_version == self._version
877
+
878
+ def refresh_if_needed(self) -> bool:
879
+ """Reload cookies if version changed."""
880
+ # This needs to be async but is called from sync context
881
+ # For now, return False to indicate async operation needed
882
+ return not self.check_version()
883
+
884
+ async def refresh_if_needed_async(self) -> bool:
885
+ """Reload cookies if version changed (async version)."""
886
+ if not self.check_version():
887
+ log.info('Cookie version mismatch, reloading...')
888
+ return await self.load_cookies()
889
+ return True
890
+
891
+ async def handle_auth_error(
892
+ self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
893
+ ) -> T:
894
+ """Handle authentication error with version check and retry asynchronously."""
895
+ log.info('Authentication error occurred (async), checking cookie version...')
896
+
897
+ # First, check if our cookies are outdated
898
+ if not self.check_version():
899
+ log.info('Cookie version outdated, reloading...')
900
+ if await self.load_cookies():
901
+ log.info('Cookies reloaded, retrying with updated cookies...')
902
+ try:
903
+ return await retry_callback(*args, **kwargs)
904
+ except Exception as e: # pylint: disable=broad-exception-caught
905
+ log.warning('Retry with updated cookies failed: %s', e)
906
+ else:
907
+ log.warning('Failed to reload cookies')
908
+
909
+ # If still failing, acquire lock and re-authenticate
910
+ log.info('Attempting re-authentication...')
911
+ if await self.acquire_auth_lock(timeout=30.0):
912
+ try:
913
+ # Clear existing cookies
914
+ self.clear_cookies()
915
+ await self.save_cookies()
916
+
917
+ # Call retry which should trigger re-authentication
918
+ result = await retry_callback(*args, **kwargs)
919
+
920
+ # Save new cookies after successful auth
921
+ await self.save_cookies()
922
+ return result
923
+
924
+ finally:
925
+ await self.release_auth_lock()
926
+ msg = 'Failed to acquire authentication lock for re-authentication'
927
+ raise PararamioException(msg)
928
+
929
+ def _increment_version(self) -> int:
930
+ """Increment version and save to file."""
931
+ return self._increment_file_version()
932
+
933
+ async def _is_lock_stale(self, max_age: float = 300.0) -> bool:
934
+ """Check if lock file is stale (older than max_age seconds)."""
935
+ try:
936
+ stat = self.lock_path.stat()
937
+ except FileNotFoundError:
938
+ return False
939
+ age = time.time() - stat.st_mtime
940
+ return age > max_age
941
+
942
+ async def _remove_stale_lock(self) -> None:
943
+ """Remove stale lock file."""
944
+ try:
945
+ self.lock_path.unlink()
946
+ log.info('Removed stale lock file: %s', self.lock_path)
947
+ except FileNotFoundError:
948
+ pass
949
+
950
+
951
+ class AsyncRedisCookieManager(CookieManagerMixin, AsyncCookieManager):
952
+ """Async Redis-based cookie manager."""
953
+
954
+ def __init__(self, redis_client: Any, key_prefix: str = 'pararamio:cookies'):
955
+ self.redis = redis_client
956
+ self.key_prefix = key_prefix
957
+ self.data_key = f'{key_prefix}:data'
958
+ self.lock_key = f'{key_prefix}:lock'
959
+ self.version_key = f'{key_prefix}:version'
960
+ self._cookies: dict[str, Cookie] = {}
961
+ self._version: int = 0
962
+ self._lock = asyncio.Lock()
963
+ self._lock_token: str | None = None
964
+
965
+ @property
966
+ def version(self) -> int:
967
+ """Get current version."""
968
+ return self._version
969
+
970
+ async def load_cookies(self) -> bool:
971
+ """Load cookies from Redis asynchronously."""
972
+ async with self._lock:
973
+ # Assuming redis_client is async
974
+ try:
975
+ data = await self.redis.get(self.data_key)
976
+ return self._load_cookies_from_json(data)
977
+ except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
978
+ log.warning('Failed to load cookies from Redis: %s', e)
979
+ return False
980
+
981
+ async def save_cookies(self) -> None:
982
+ """Save cookies to Redis asynchronously."""
983
+ async with self._lock:
984
+ data = self._prepare_cookies_data()
985
+
986
+ try:
987
+ await self.redis.set(self.data_key, json.dumps(data))
988
+ except (OSError, TypeError, ValueError, AttributeError):
989
+ log.exception('Failed to save cookies to Redis')
990
+ raise
991
+
992
+ def add_cookie(self, cookie: Cookie) -> None:
993
+ """Add or update a cookie."""
994
+ key = self.make_key(cookie)
995
+ self._cookies[key] = cookie
996
+ self._version = self._increment_version()
997
+
998
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
999
+ """Get a specific cookie."""
1000
+ key = f'{domain}:{path}:{name}'
1001
+ return self._cookies.get(key)
1002
+
1003
+ def get_all_cookies(self) -> list[Cookie]:
1004
+ """Get all cookies."""
1005
+ return list(self._cookies.values())
1006
+
1007
+ def clear_cookies(self) -> None:
1008
+ """Clear all cookies."""
1009
+ self._cookies.clear()
1010
+ self._version = self._increment_version()
1011
+
1012
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
1013
+ """Update cookies from a CookieJar."""
1014
+ for cookie in cookie_jar:
1015
+ key = self.make_key(cookie)
1016
+ self._cookies[key] = cookie
1017
+ self._version = self._increment_version()
1018
+
1019
+ def check_version(self) -> bool:
1020
+ """Check if our version matches Redis version."""
1021
+ try:
1022
+ loop = asyncio.get_event_loop()
1023
+ future = asyncio.ensure_future(self.redis.get(self.version_key))
1024
+ version = loop.run_until_complete(future)
1025
+ except (ValueError, AttributeError, RuntimeError):
1026
+ return True
1027
+ current_version = int(version) if version else 0
1028
+ return current_version == self._version
1029
+
1030
+ async def check_version_async(self) -> bool:
1031
+ """Check if our version matches Redis version (async version)."""
1032
+ try:
1033
+ version = await self.redis.get(self.version_key)
1034
+ except (ValueError, AttributeError):
1035
+ return True
1036
+ current_version = int(version) if version else 0
1037
+ return current_version == self._version
1038
+
1039
+ def refresh_if_needed(self) -> bool:
1040
+ """Reload cookies if version changed."""
1041
+ # This needs to be async
1042
+ return not self.check_version()
1043
+
1044
+ async def refresh_if_needed_async(self) -> bool:
1045
+ """Reload cookies if version changed (async version)."""
1046
+ if not await self.check_version_async():
1047
+ log.info('Cookie version mismatch, reloading...')
1048
+ return await self.load_cookies()
1049
+ return True
1050
+
1051
+ def _increment_version(self) -> int:
1052
+ """Atomically increment version in Redis."""
1053
+ # This is sync but needs to use async redis
1054
+ # For now just increment locally
1055
+ self._version += 1
1056
+ return self._version
1057
+
1058
+ async def _increment_version_async(self) -> int:
1059
+ """Atomically increment version in Redis."""
1060
+ try:
1061
+ self._version = await self.redis.incr(self.version_key)
1062
+ except (AttributeError, TypeError):
1063
+ log.exception('Failed to increment version in Redis')
1064
+ return self._version
1065
+ return self._version
1066
+
1067
+ async def acquire_auth_lock(self, timeout: float = 30.0) -> bool:
1068
+ """Acquire distributed lock asynchronously."""
1069
+ self._lock_token = str(uuid.uuid4())
1070
+
1071
+ start_time = time.time()
1072
+ while time.time() - start_time < timeout:
1073
+ # Try to set lock with expiration
1074
+ if await self.redis.set(self.lock_key, self._lock_token, nx=True, ex=300):
1075
+ return True
1076
+ await asyncio.sleep(0.1)
1077
+
1078
+ return False
1079
+
1080
+ async def release_auth_lock(self) -> None:
1081
+ """Release distributed lock asynchronously."""
1082
+ if self._lock_token:
1083
+ lua_script = """
1084
+ if redis.call("get", KEYS[1]) == ARGV[1] then
1085
+ return redis.call("del", KEYS[1])
1086
+ else
1087
+ return 0
1088
+ end
1089
+ """
1090
+ try:
1091
+ await self.redis.eval(lua_script, 1, self.lock_key, self._lock_token)
1092
+ except (AttributeError, KeyError) as e:
1093
+ log.warning('Failed to release Redis lock: %s', e)
1094
+ finally:
1095
+ self._lock_token = None
1096
+
1097
+ async def handle_auth_error(
1098
+ self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
1099
+ ) -> T:
1100
+ """Handle authentication error with version check and retry asynchronously."""
1101
+ log.info('Authentication error occurred (async Redis), checking cookie version...')
1102
+
1103
+ # First, check if our cookies are outdated
1104
+ if not await self.check_version_async():
1105
+ log.info('Cookie version outdated, reloading from Redis...')
1106
+ if await self.load_cookies():
1107
+ log.info('Cookies reloaded, retrying with updated cookies...')
1108
+ try:
1109
+ return await retry_callback(*args, **kwargs)
1110
+ except Exception as e: # pylint: disable=broad-exception-caught
1111
+ log.warning('Retry with updated cookies failed: %s', e)
1112
+ else:
1113
+ log.warning('Failed to reload cookies from Redis')
1114
+
1115
+ # If still failing, acquire distributed lock and re-authenticate
1116
+ log.info('Attempting re-authentication with distributed lock...')
1117
+ if await self.acquire_auth_lock(timeout=30.0):
1118
+ try:
1119
+ # Clear existing cookies
1120
+ self.clear_cookies()
1121
+ await self.save_cookies()
1122
+
1123
+ # Call retry which should trigger re-authentication
1124
+ result = await retry_callback(*args, **kwargs)
1125
+
1126
+ # Save new cookies after successful auth
1127
+ await self.save_cookies()
1128
+ return result
1129
+
1130
+ finally:
1131
+ await self.release_auth_lock()
1132
+ msg = 'Failed to acquire distributed lock for re-authentication'
1133
+ raise PararamioException(msg)
1134
+
1135
+
1136
+ class AsyncInMemoryCookieManager(CookieManagerMixin, AsyncCookieManager):
1137
+ """Async in-memory cookie manager."""
1138
+
1139
+ def __init__(self):
1140
+ self._cookies: dict[str, Cookie] = {}
1141
+ self._version: int = 0
1142
+ self._lock = asyncio.Lock()
1143
+
1144
+ @property
1145
+ def version(self) -> int:
1146
+ """Get current version."""
1147
+ return self._version
1148
+
1149
+ async def load_cookies(self) -> bool:
1150
+ """No-op for in-memory manager."""
1151
+ async with self._lock:
1152
+ return bool(self._cookies)
1153
+
1154
+ async def save_cookies(self) -> None:
1155
+ """No-op for in-memory manager."""
1156
+
1157
+ def add_cookie(self, cookie: Cookie) -> None:
1158
+ """Add or update a cookie."""
1159
+ key = self.make_key(cookie)
1160
+ self._cookies[key] = cookie
1161
+ self._version += 1
1162
+
1163
+ def get_cookie(self, domain: str, path: str, name: str) -> Cookie | None:
1164
+ """Get a specific cookie."""
1165
+ key = f'{domain}:{path}:{name}'
1166
+ return self._cookies.get(key)
1167
+
1168
+ def get_all_cookies(self) -> list[Cookie]:
1169
+ """Get all cookies."""
1170
+ return list(self._cookies.values())
1171
+
1172
+ def clear_cookies(self) -> None:
1173
+ """Clear all cookies."""
1174
+ self._cookies.clear()
1175
+ self._version += 1
1176
+
1177
+ def update_from_jar(self, cookie_jar: CookieJar) -> None:
1178
+ """Update cookies from a CookieJar."""
1179
+ for cookie in cookie_jar:
1180
+ key = self.make_key(cookie)
1181
+ self._cookies[key] = cookie
1182
+ self._version += 1
1183
+
1184
+ def populate_jar(self, cookie_jar: CookieJar) -> None:
1185
+ """Populate a CookieJar with stored cookies (synchronous version for async manager)."""
1186
+ # Since this is called synchronously, we can't use the async lock
1187
+ # Just iterate over cookies without locking
1188
+ for cookie in self._cookies.values():
1189
+ cookie_jar.set_cookie(cookie)
1190
+
1191
+ # noinspection PyMethodMayBeStatic
1192
+ def check_version(self) -> bool:
1193
+ """Always returns True for in-memory manager."""
1194
+ return True
1195
+
1196
+ # noinspection PyMethodMayBeStatic
1197
+ def refresh_if_needed(self) -> bool:
1198
+ """No-op for in-memory manager."""
1199
+ return True
1200
+
1201
+ async def acquire_auth_lock(self, timeout: float = 30.0) -> bool: # noqa: ARG002
1202
+ """Always returns True for in-memory manager."""
1203
+ return True
1204
+
1205
+ async def release_auth_lock(self) -> None:
1206
+ """No-op for in-memory manager."""
1207
+
1208
+ async def handle_auth_error(
1209
+ self, retry_callback: Callable[..., T], *args: Any, **kwargs: Any
1210
+ ) -> T:
1211
+ """Handle authentication error by retrying."""
1212
+ log.info('Authentication error occurred (async in-memory), retrying...')
1213
+
1214
+ # For in-memory, just clear cookies and retry
1215
+ async with self._lock:
1216
+ self.clear_cookies()
1217
+
1218
+ try:
1219
+ return await retry_callback(*args, **kwargs)
1220
+ except (AttributeError, TypeError):
1221
+ log.exception('Retry failed')
1222
+ raise