Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
52 lines
2.8 KiB
Python
52 lines
2.8 KiB
Python
"""
|
||
هندلرِ سفارشیِ exception برای DRF: هندلرِ پیشفرض فقط APIException/Http404/
|
||
PermissionDenied رو میشناسه؛ برای بقیه None برمیگردونه و exception خام تا
|
||
مدیریتِ خطای جنگو بالا میره که (DEBUG=False) یه صفحهی HTML عمومی میده، نه
|
||
JSON. این هندلر اون حالت رو با traceback کامل لاگ میکنه و یه پاسخِ JSON
|
||
ژنریکِ ۵۰۰ برمیگردونه.
|
||
"""
|
||
import logging
|
||
|
||
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||
|
||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import exception_handler as drf_exception_handler
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def custom_exception_handler(exc, context):
|
||
response = drf_exception_handler(exc, context)
|
||
if response is not None:
|
||
return response
|
||
|
||
request = context.get('request')
|
||
path = getattr(request, 'path', '')
|
||
|
||
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایینتر، که اگر
|
||
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool میماند.
|
||
if is_pool_timeout(exc):
|
||
log_server_busy(request)
|
||
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
|
||
if isinstance(exc, RequestDataTooBig):
|
||
logger.warning('request_too_large path=%s', path)
|
||
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||
if isinstance(exc, SuspiciousOperation):
|
||
logger.warning('suspicious_operation path=%s type=%s', path, type(exc).__name__)
|
||
return Response({'detail': 'درخواستِ نامعتبر.'}, status=400)
|
||
|
||
user = getattr(request, 'user', None)
|
||
user_id = user.id if getattr(user, 'is_authenticated', False) else None
|
||
logger.exception(
|
||
'unhandled_exception path=%s user_id=%s',
|
||
path, user_id,
|
||
)
|
||
return Response(
|
||
{'detail': 'خطای غیرمنتظرهای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'},
|
||
status=500,
|
||
)
|