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>
This commit is contained in:
2026-10-07 14:16:01 +03:30
parent 7929f6bd85
commit 277927046d
3 changed files with 312 additions and 1 deletions

125
config/cache.py Normal file
View File

@@ -0,0 +1,125 @@
"""
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)