error-handling: add global exception handling, guard Bazaar API calls
This commit is contained in:
31
config/exceptions.py
Normal file
31
config/exceptions.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -216,6 +216,10 @@ REST_FRAMEWORK = {
|
|||||||
'rest_framework.permissions.AllowAny',
|
'rest_framework.permissions.AllowAny',
|
||||||
],
|
],
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'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': [
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
'rest_framework.renderers.JSONRenderer',
|
'rest_framework.renderers.JSONRenderer',
|
||||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||||
|
|||||||
@@ -4,14 +4,40 @@
|
|||||||
هر اپ زیر پیشوند /api/ خودش mount شده. مسیرهای هر endpoint دقیقاً طبق
|
هر اپ زیر پیشوند /api/ خودش mount شده. مسیرهای هر endpoint دقیقاً طبق
|
||||||
سند «Endpointهای API» طراحیشده تا مقایسه با Swagger راحت باشه.
|
سند «Endpointهای API» طراحیشده تا مقایسه با Swagger راحت باشه.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
from django.views import defaults as django_default_views
|
||||||
from drf_spectacular.views import (
|
from drf_spectacular.views import (
|
||||||
SpectacularAPIView,
|
SpectacularAPIView,
|
||||||
SpectacularRedocView,
|
SpectacularRedocView,
|
||||||
SpectacularSwaggerView,
|
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 = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,29 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api
|
|||||||
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
||||||
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token'
|
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
|
@dataclass
|
||||||
class BazaarPurchaseResult:
|
class BazaarPurchaseResult:
|
||||||
is_valid: bool
|
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.')
|
raise RuntimeError('BAZAAR_REFRESH_TOKEN is not configured; cannot obtain a Bazaar access token.')
|
||||||
|
|
||||||
resp = requests.post(
|
try:
|
||||||
f'{settings.BAZAAR_API_BASE}/auth/token/',
|
resp = requests.post(
|
||||||
data={
|
f'{settings.BAZAAR_API_BASE}/auth/token/',
|
||||||
'grant_type': 'refresh_token',
|
data={
|
||||||
'client_id': settings.BAZAAR_CLIENT_ID,
|
'grant_type': 'refresh_token',
|
||||||
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
'client_id': settings.BAZAAR_CLIENT_ID,
|
||||||
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
|
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
||||||
},
|
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
|
||||||
timeout=10,
|
},
|
||||||
)
|
timeout=10,
|
||||||
resp.raise_for_status()
|
)
|
||||||
data = resp.json()
|
resp.raise_for_status()
|
||||||
token = data['access_token']
|
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 معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی
|
# طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی
|
||||||
# میکنیم تا لبهی زمانی مشکل نسازه.
|
# میکنیم تا لبهی زمانی مشکل نسازه.
|
||||||
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60)
|
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_API_BASE}/api/applications/'
|
||||||
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/'
|
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:
|
if resp.status_code != 200:
|
||||||
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
|
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')
|
purchase_state = data.get('purchaseState')
|
||||||
return BazaarPurchaseResult(
|
return BazaarPurchaseResult(
|
||||||
is_valid=purchase_state == 0, # ۰ یعنی خریداریشده و معتبر
|
is_valid=purchase_state == 0, # ۰ یعنی خریداریشده و معتبر
|
||||||
|
|||||||
Reference in New Issue
Block a user