error-handling: add global exception handling, guard Bazaar API calls

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent 49d596f077
commit 83435c5a5f
4 changed files with 110 additions and 15 deletions

31
config/exceptions.py Normal file
View File

@@ -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,
)

View File

@@ -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',

View File

@@ -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),

View File

@@ -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, # ۰ یعنی خریداری‌شده و معتبر