""" RedisCache که با قطعی‌ی Redis درخواست‌ها را خراب نمی‌کند، ولی بی‌صدا هم نیست. چرا: از ۲۰۲۶-۱۰-۰۷ سقفِ نرخ، کشِ برنامه‌ی درسی، کشِ جوابِ دستیار و شمارنده‌های ورود روی Redisِ مشترکِ ماشین‌اند. `django.core.cache.backends.redis.RedisCache` با هر خطای شبکه استثنا می‌دهد و چون DRF برای هر درخواستِ throttleدار کش را می‌خواند، قطعیِ Redis یعنی ۵۰۰ برای تقریباً همه‌ی API. Redis مالِ ما نیست و ما کنترلی روی بالا بودنش نداریم. رفتار هنگامِ خطا (همه با `logger.error`، حداکثر هر ۳۰ ثانیه یک بار برای هر پروسه تا لاگ پر نشود؛ و `/api/health` در همان حال ۵۰۳ می‌دهد چون پروبِ کش شکست می‌خورد): get → default (miss) set/delete → نادیده add → True («کلید نبود، ادامه بده») incr → ValueError («کلید نیست»؛ همان‌چیزی که کدِ فراخواننده از قبل می‌شناسد) نتیجه‌ی عملی: در طولِ قطعی سقف‌های مبتنی بر کش (throttleهای DRF، پنجره‌ی ساعتیِ OTP، سقفِ ورودِ ادمین، قفلِ یک‌دقیقه‌ایِ بازسنجیِ پرداخت) **اعمال نمی‌شوند** (fail-open)؛ کشِ درس‌ها هر بار از دیتابیس ساخته می‌شود. آن‌چه در دیتابیس شمرده می‌شود (cooldown و سقفِ روزانه‌ی هر شماره، سقفِ کلِ پیامک، حدسِ هر کدِ OTP) سر جایش می‌ماند. انتخابِ fail-open تصمیمِ ما بود: بسته‌شدنِ کلِ API به‌خاطر خرابیِ یک سرویسِ مشترک بدتر از چند دقیقه بی‌سقف است. نیاز به بازنگری دارد اگر ترافیکِ مخرب دیده شود. آزموده نشده: خطاهایی که از `redis.exceptions.RedisError` و `OSError` نیستند (مثلاً خطای serializer) عمداً گرفته نمی‌شوند. """ import logging import threading import time import redis from django.core.cache.backends.base import DEFAULT_TIMEOUT from django.core.cache.backends.redis import RedisCache logger = logging.getLogger(__name__) _ERRORS = (redis.exceptions.RedisError, OSError) _LOG_EVERY_SECONDS = 30 _last_logged = 0.0 _lock = threading.Lock() def _report(operation: str, exc: Exception) -> None: global _last_logged now = time.monotonic() with _lock: due = (now - _last_logged) >= _LOG_EVERY_SECONDS or _last_logged == 0.0 if due: _last_logged = now if due: logger.error( 'cache: %s روی Redis شکست خورد (%s: %s)؛ سقف‌های مبتنی بر کش تا برگشتنِ Redis اعمال نمی‌شوند ' 'و /api/health ۵۰۳ می‌دهد.', operation, type(exc).__name__, exc, ) class ResilientRedisCache(RedisCache): def get(self, key, default=None, version=None): try: return super().get(key, default, version) except _ERRORS as exc: _report('get', exc) return default def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): try: return super().set(key, value, timeout, version) except _ERRORS as exc: _report('set', exc) def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): try: return super().add(key, value, timeout, version) except _ERRORS as exc: _report('add', exc) return True def delete(self, key, version=None): try: return super().delete(key, version) except _ERRORS as exc: _report('delete', exc) return False def get_many(self, keys, version=None): try: return super().get_many(keys, version) except _ERRORS as exc: _report('get_many', exc) return {} def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None): try: return super().set_many(data, timeout, version) except _ERRORS as exc: _report('set_many', exc) return list(data) def delete_many(self, keys, version=None): try: return super().delete_many(keys, version) except _ERRORS as exc: _report('delete_many', exc) def has_key(self, key, version=None): try: return super().has_key(key, version) except _ERRORS as exc: _report('has_key', exc) return False def incr(self, key, delta=1, version=None): try: return super().incr(key, delta, version) except _ERRORS as exc: _report('incr', exc) raise ValueError(f"Key '{key}' not found") from exc def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None): try: return super().touch(key, timeout, version) except _ERRORS as exc: _report('touch', exc) return False def clear(self): try: return super().clear() except _ERRORS as exc: _report('clear', exc)