From 277927046dea7f93a11daa872e4ea8d930b8e861 Mon Sep 17 00:00:00 2001 From: Zareian Date: Wed, 7 Oct 2026 14:16:01 +0330 Subject: [PATCH] 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 --- config/cache.py | 125 +++++++++++++++++++++ config/settings.py | 8 +- config/tests_cache_resilience.py | 180 +++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 config/cache.py create mode 100644 config/tests_cache_resilience.py diff --git a/config/cache.py b/config/cache.py new file mode 100644 index 0000000..d8be5a6 --- /dev/null +++ b/config/cache.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 15dfcd6..8156ac6 100644 --- a/config/settings.py +++ b/config/settings.py @@ -353,8 +353,14 @@ CACHE_URL = os.environ.get('CACHE_URL', '') if CACHE_URL: CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + # ResilientRedisCache: قطعیِ Redisِ مشترکِ ماشین API را ۵۰۰ نمی‌کند (config/cache.py شرح می‌دهد + # چه چیزی در عوضش اعمال نمی‌شود). KEY_PREFIX چون Redis بینِ چند اپ مشترک است و کلیدِ ما با + # هیچ کلیدِ دیگری قاطی نشود؛ مقدارش یک انتخابِ نام‌گذاری است و عوض‌کردنش فقط کشِ فعلی را خالی می‌کند. + 'BACKEND': 'config.cache.ResilientRedisCache', 'LOCATION': CACHE_URL, + 'KEY_PREFIX': os.environ.get('CACHE_KEY_PREFIX', 'tadrisino-mathapp'), + # بدونِ timeout، Redisِ گیرکرده هر درخواست را تا پایانِ timeoutِ gunicorn (۱۲۰ ثانیه) نگه می‌دارد. + 'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1}, } } elif DEBUG: diff --git a/config/tests_cache_resilience.py b/config/tests_cache_resilience.py new file mode 100644 index 0000000..d4220b6 --- /dev/null +++ b/config/tests_cache_resilience.py @@ -0,0 +1,180 @@ +""" +قطعیِ Redisِ مشترک نباید API را ۵۰۰ کند (config/cache.py). دو دسته: + ۱. Redis در دسترس نیست (پورتِ بسته، و آدرسِ بی‌جواب برای timeout): هر نوع کارکرد را می‌سنجد. + ۲. یک redis-serverِ واقعیِ محلی (اگر نصب باشد): کارکردِ عادی، پیشوندِ کلید، و «وسطِ کار خاموش و دوباره روشن». +""" +import shutil +import socket +import subprocess +import tempfile +import time +import unittest +from unittest import mock + +from django.core.cache import caches +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from content.models import Grade, Lesson, Node, Unit + +from . import cache as cache_module + +BACKEND = 'config.cache.ResilientRedisCache' + + +def _caches(location, prefix=None): + conf = {'BACKEND': BACKEND, 'LOCATION': location, + 'OPTIONS': {'socket_connect_timeout': 1, 'socket_timeout': 1}} + if prefix: + conf['KEY_PREFIX'] = prefix + return {'default': conf} + + +class _Quiet: + def setUp(self): + super().setUp() + cache_module._last_logged = 0.0 + + +@override_settings(CACHES=_caches('redis://127.0.0.1:1/0')) +class RedisDownTests(_Quiet, TestCase): + """پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود.""" + + def test_every_operation_degrades_the_documented_way(self): + c = caches['default'] + with self.assertLogs('config.cache', 'ERROR'): + self.assertEqual(c.get('k', 'dflt'), 'dflt') + self.assertIsNone(c.set('k', 1, 10)) + self.assertIs(c.add('k', 1, 10), True) # «کلید نبود» → فراخواننده ادامه می‌دهد + self.assertIs(c.delete('k'), False) + self.assertEqual(c.get_many(['a', 'b']), {}) + self.assertEqual(c.set_many({'a': 1}), ['a']) + self.assertIs(c.has_key('k'), False) + with self.assertRaises(ValueError): # همان استثنایی که فراخواننده‌ها می‌شناسند + c.incr('k') + self.assertEqual(c.get_or_set('k', lambda: 'built', 10), 'built') + + def test_errors_are_logged_but_not_flooded(self): + c = caches['default'] + with self.assertLogs('config.cache', 'ERROR') as logs: + c.get('a'); c.get('b'); c.set('c', 1) + self.assertEqual(len(logs.output), 1) + + def test_throttled_public_endpoint_still_answers(self): + grade = Grade.objects.create(title='G', icon='', color='', number=88) + lesson = Lesson.objects.create(node=Node.objects.create( + unit=Unit.objects.create(grade=grade, title='U', order=1), title='N', order=1), title='L') + client = APIClient() + for _ in range(5): + self.assertEqual(client.get(f'/api/lessons/{lesson.id}/questions').status_code, 200) + self.assertEqual(client.get('/api/grades').status_code, 200) + + def test_authenticated_endpoint_still_answers(self): + user = User.objects.create(phone='09120000888') + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + self.assertEqual(client.get('/api/user/hearts').status_code, 200) + self.assertEqual(client.get('/api/home').status_code, 200) + + def test_otp_request_still_works_because_its_caps_live_in_the_database(self): + with mock.patch('accounts.views.send_otp_sms'): + response = APIClient().post('/api/auth/otp/request', {'phone': '09120000889'}, format='json') + self.assertEqual(response.status_code, 200, response.content) + + def test_admin_login_page_is_not_blocked(self): + response = self.client.post('/admin/login/', {'username': 'x', 'password': 'y'}) + self.assertNotEqual(response.status_code, 429) + self.assertNotEqual(response.status_code, 500) + + def test_health_says_so(self): + response = APIClient().get('/api/health') + self.assertEqual(response.status_code, 503) + self.assertIs(response.json()['checks']['cache']['ok'], False) + + +class RedisHangsTests(_Quiet, TestCase): + def test_an_unanswering_redis_costs_about_a_second_not_a_hang(self): + # آدرسی که جواب نمی‌دهد (بلاک‌هول)؛ اگر شبکه فوراً خطا بدهد هم تست درست است. + with override_settings(CACHES=_caches('redis://10.255.255.1:6379/0')): + started = time.monotonic() + self.assertEqual(caches['default'].get('k', 'dflt'), 'dflt') + self.assertLess(time.monotonic() - started, 4) + + +def _free_port(): + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست') +class RealRedisTests(_Quiet, TestCase): + def setUp(self): + super().setUp() + self.port = _free_port() + self.dir = tempfile.mkdtemp() + self.proc = None + self.start() + + def tearDown(self): + self.stop() + shutil.rmtree(self.dir, ignore_errors=True) + super().tearDown() + + def start(self): + self.proc = subprocess.Popen( + ['redis-server', '--port', str(self.port), '--bind', '127.0.0.1', '--save', '', + '--appendonly', 'no', '--dir', self.dir], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + with socket.socket() as s: + if s.connect_ex(('127.0.0.1', self.port)) == 0: + return + time.sleep(0.05) + self.fail('redis-server بالا نیامد') + + def stop(self): + if self.proc and self.proc.poll() is None: + self.proc.terminate() + self.proc.wait(timeout=5) + + def test_normal_behaviour_and_key_prefix(self): + import redis + with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1', prefix='tadrisino-test')): + c = caches['default'] + c.set('k', {'a': 1}, 30) + self.assertEqual(c.get('k'), {'a': 1}) + self.assertTrue(c.add('n', 0, 30)) + self.assertFalse(c.add('n', 0, 30)) + self.assertEqual(c.incr('n'), 1) + with self.assertRaises(ValueError): + c.incr('missing') + raw = redis.Redis(host='127.0.0.1', port=self.port, db=1) + self.assertTrue(all(b'tadrisino-test' in k for k in raw.keys('*'))) + self.assertEqual(redis.Redis(host='127.0.0.1', port=self.port, db=0).dbsize(), 0) # دیتابیسِ دیگران دست نخورد + self.assertGreater(raw.ttl(raw.keys('*k')[0]), 0) # TTL گذاشته شده؛ کلیدِ بی‌انقضا نمی‌ماند + + def test_throttle_is_shared_and_health_is_ok(self): + with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1', prefix='t')): + response = APIClient().get('/api/health') + self.assertIs(response.json()['checks']['cache']['ok'], True) + + def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self): + with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1', prefix='t')): + c = caches['default'] + c.set('k', 'before', 60) + self.assertEqual(c.get('k'), 'before') + + self.stop() # Redis می‌میرد + with self.assertLogs('config.cache', 'ERROR'): + self.assertEqual(c.get('k', 'miss'), 'miss') + self.assertEqual(APIClient().get('/api/grades').status_code, 200) + self.assertEqual(APIClient().get('/api/health').status_code, 503) + + self.start() # برمی‌گردد، بدونِ ری‌استارتِ برنامه + c.set('k2', 'after', 60) + self.assertEqual(c.get('k2'), 'after') + self.assertEqual(APIClient().get('/api/health').status_code, 200)