From 83435c5a5f129c6c9abc4f84a1314fc148f866d8 Mon Sep 17 00:00:00 2001 From: Zareian Date: Wed, 26 Aug 2026 03:22:20 +0330 Subject: [PATCH] error-handling: add global exception handling, guard Bazaar API calls --- config/exceptions.py | 31 +++++++++++++++++++++ config/settings.py | 4 +++ config/urls.py | 26 ++++++++++++++++++ payment/bazaar.py | 64 +++++++++++++++++++++++++++++++++----------- 4 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 config/exceptions.py diff --git a/config/exceptions.py b/config/exceptions.py new file mode 100644 index 0000000..b5911bb --- /dev/null +++ b/config/exceptions.py @@ -0,0 +1,31 @@ +""" +هندلرِ سفارشیِ exception برای DRF: هندلرِ پیش‌فرض فقط APIException/Http404/ +PermissionDenied رو می‌شناسه؛ برای بقیه None برمی‌گردونه و exception خام تا +مدیریتِ خطای جنگو بالا می‌ره که (DEBUG=False) یه صفحه‌ی HTML عمومی می‌ده، نه +JSON. این هندلر اون حالت رو با traceback کامل لاگ می‌کنه و یه پاسخِ JSON +ژنریکِ ۵۰۰ برمی‌گردونه. +""" +import logging + +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') + 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', + getattr(request, 'path', ''), user_id, + ) + return Response( + {'detail': 'خطای غیرمنتظره‌ای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'}, + status=500, + ) diff --git a/config/settings.py b/config/settings.py index 321820b..b28bd40 100644 --- a/config/settings.py +++ b/config/settings.py @@ -216,6 +216,10 @@ REST_FRAMEWORK = { 'rest_framework.permissions.AllowAny', ], 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + # ⚠️ افزوده شده: بدونِ این، exceptionِ ناشناخته (غیر از APIException/ + # Http404/PermissionDenied) به‌جای پاسخِ JSON، صفحه‌ی HTML عمومیِ ۵۰۰ + # جنگو رو برمی‌گردونه (نگاه کنید به config/exceptions.py). + 'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler', 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', diff --git a/config/urls.py b/config/urls.py index f33c0ce..989e0bb 100644 --- a/config/urls.py +++ b/config/urls.py @@ -4,14 +4,40 @@ هر اپ زیر پیشوند /api/ خودش mount شده. مسیرهای هر endpoint دقیقاً طبق سند «Endpointهای API» طراحی‌شده تا مقایسه با Swagger راحت باشه. """ +import logging + from django.contrib import admin +from django.http import JsonResponse from django.urls import include, path +from django.views import defaults as django_default_views from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView, ) +logger = logging.getLogger(__name__) + + +def handler404(request, exception=None): + """برای مسیرهای /api/ همیشه JSON برمی‌گردونه؛ بقیه رفتارِ پیش‌فرضِ جنگو.""" + if request.path.startswith('/api/'): + return JsonResponse({'detail': 'یافت نشد.'}, status=404) + return django_default_views.page_not_found(request, exception) + + +def handler500(request): + """شبکه‌ی ایمنیِ نهایی برای exceptionی که حتی به config/exceptions.py هم + نرسیده (مثلاً در خودِ middleware/URL resolution). traceback کامل رو + config/middleware.py قبلاً لاگ کرده؛ اینجا فقط یه پاسخِ JSON می‌ده.""" + logger.error('unhandled_500 path=%s', request.path) + if request.path.startswith('/api/'): + return JsonResponse( + {'detail': 'خطای غیرمنتظره‌ای رخ داد؛ لطفاً بعداً دوباره تلاش کنید.'}, status=500, + ) + return django_default_views.server_error(request) + + urlpatterns = [ path('admin/', admin.site.urls), diff --git a/payment/bazaar.py b/payment/bazaar.py index b5fb6e9..33ea37b 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -21,15 +21,29 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌وقت به purchaseToken ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه. """ +import logging from dataclasses import dataclass import requests from django.conf import settings from django.core.cache import cache +from rest_framework.exceptions import APIException + +logger = logging.getLogger(__name__) ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token' +class BazaarAPIError(APIException): + """خطای موقتِ سرویسِ بازار (شبکه/HTTP/پاسخِ نامعتبر). عمداً is_valid=False + برنمی‌گردونیم چون نبودِ پاسخِ قابل‌اعتماد به‌معنیِ خریدِ نامعتبر نیست — + ممکنه یک خریدِ واقعاً معتبر رو غلط failed کنه.""" + + status_code = 503 + default_detail = 'در حال حاضر امکان تایید خرید با سرور بازار وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.' + default_code = 'bazaar_api_unavailable' + + @dataclass class BazaarPurchaseResult: is_valid: bool @@ -58,19 +72,29 @@ def _get_access_token() -> str: # برمی‌گرده — ولی برای دفاع در عمق، اینجا هم صراحتاً خطا می‌ده. raise RuntimeError('BAZAAR_REFRESH_TOKEN is not configured; cannot obtain a Bazaar access token.') - resp = requests.post( - f'{settings.BAZAAR_API_BASE}/auth/token/', - data={ - 'grant_type': 'refresh_token', - 'client_id': settings.BAZAAR_CLIENT_ID, - 'client_secret': settings.BAZAAR_CLIENT_SECRET, - 'refresh_token': settings.BAZAAR_REFRESH_TOKEN, - }, - timeout=10, - ) - resp.raise_for_status() - data = resp.json() - token = data['access_token'] + try: + resp = requests.post( + f'{settings.BAZAAR_API_BASE}/auth/token/', + data={ + 'grant_type': 'refresh_token', + 'client_id': settings.BAZAAR_CLIENT_ID, + 'client_secret': settings.BAZAAR_CLIENT_SECRET, + 'refresh_token': settings.BAZAAR_REFRESH_TOKEN, + }, + timeout=10, + ) + resp.raise_for_status() + except requests.RequestException: + logger.exception('Bazaar access-token request failed') + raise BazaarAPIError() + + try: + data = resp.json() + token = data['access_token'] + except (ValueError, KeyError, TypeError): + logger.error('Bazaar access-token response malformed: status=%s', resp.status_code) + raise BazaarAPIError() + # طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی # می‌کنیم تا لبه‌ی زمانی مشکل نسازه. cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60) @@ -102,11 +126,21 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b f'{settings.BAZAAR_API_BASE}/api/applications/' f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/' ) - resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10) + try: + resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10) + except requests.RequestException: + logger.exception('Bazaar verify-purchase request failed') + raise BazaarAPIError() + if resp.status_code != 200: return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code}) - data = resp.json() + try: + data = resp.json() + except ValueError: + logger.error('Bazaar verify-purchase response malformed: status=%s', resp.status_code) + raise BazaarAPIError() + purchase_state = data.get('purchaseState') return BazaarPurchaseResult( is_valid=purchase_state == 0, # ۰ یعنی خریداری‌شده و معتبر