Files
MathApp/config/cache.py
Zareian 277927046d Redis outage must not 500 the API: ResilientRedisCache, key prefix, 1s socket timeouts
Since 2026-10-07 the cache is the shared Redis on the box. Django's RedisCache raises on any network
error and DRF reads the cache for every throttled request, so a Redis outage meant 500 on almost every
route, /admin/login/ and OTP request included (all seven drill tests fail on the stock backend).

config.cache.ResilientRedisCache turns Redis errors into logged (rate-limited ERROR) misses:
get->default, set/delete->ignored, add->True, incr->ValueError. Cache-based caps (DRF throttles, hourly
OTP window, admin-login limiter, payment reconcile lock) are NOT enforced during an outage (fail-open);
DB-backed caps stay. /api/health returns 503 meanwhile. KEY_PREFIX namespaces our keys on the shared
Redis; 1s connect/read timeouts stop a hung Redis from holding requests for gunicorn's 120s.

Tested: 11 tests, incl. a real local redis-server killed mid-test and restarted without restarting the
app. Not tested yet on the server at the time of this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:16:01 +03:30

126 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)