send the Bazaar access token without "Bearer" and cap its cache at an hour

Tried against the real Bazaar API on 2026-09-26 after the first live
authorization: "Authorization: Bearer <t>" gets 403 "Access token is not
valid", the bare token is accepted, even though token_type says Bearer.
The refresh grant also returned expires_in=3600000, which the old code
would have cached for ~41 days; the TTL is now capped at one hour and
401/403 clear it.

404 invalid_value (wrong package or SKU) is our misconfiguration, so it
now raises 503 instead of failing the user's order; only error=not_found
means "not purchased", as Bazaar's docs say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 22:12:20 +03:30
parent ff212855a5
commit 7d205caa79
3 changed files with 39 additions and 13 deletions

View File

@@ -166,11 +166,21 @@ def complete_authorization(*, code: str, state: str = '') -> None:
raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.')
cache.delete(ACCESS_TOKEN_CACHE_KEY)
if data.get('access_token'):
cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'],
timeout=max(int(data.get('expires_in', 3600)) - 60, 60))
cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], timeout=_access_token_ttl(data))
logger.info('Bazaar API authorized; refresh token stored')
def _access_token_ttl(data: dict) -> int:
# سند ۳۶۰۰ می‌گوید ولی بازارِ واقعی ۲۰۲۶-۰۹-۲۶ `expires_in: 3600000` داد
# (احتمالاً میلی‌ثانیه). کش را به یک ساعت سقف می‌زنیم تا ۴۱ روز توکنِ
# مرده نگه نداریم؛ روی 401/403 هم پاک می‌شود.
try:
seconds = int(data.get('expires_in', 3600))
except (TypeError, ValueError):
seconds = 3600
return max(min(seconds, 3600) - 60, 60)
def _get_access_token() -> str:
"""
ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک
@@ -214,9 +224,7 @@ def _get_access_token() -> str:
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)
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=_access_token_ttl(data))
return token
@@ -234,7 +242,10 @@ def _auth_headers() -> dict:
"""
if settings.BAZAAR_PISHKHAN_API_SECRET:
return {'CAFEBAZAAR-PISHKHAN-API-SECRET': settings.BAZAAR_PISHKHAN_API_SECRET}
return {'Authorization': f'Bearer {_get_access_token()}'}
# بدونِ «Bearer»: با آن بازار 403 «Access token is not valid» می‌دهد، با
# توکنِ خام قبول می‌کند (آزمایش روی بازارِ واقعی، ۲۰۲۶-۰۹-۲۶)، هرچند
# token_typeِ خودش Bearer است.
return {'Authorization': _get_access_token()}
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
@@ -247,7 +258,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
تک‌خرید: api/validate/<pkg>/inapp/<sku>/purchases/<token>/
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
فقط ۴۰۴ نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود.
"""
if not is_configured():
@@ -279,13 +290,16 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
except ValueError:
data = None
if resp.status_code == 404:
return BazaarPurchaseResult(is_valid=False, raw=data if isinstance(data, dict) else {'status_code': 404})
error = data.get('error') if isinstance(data, dict) else None
if resp.status_code == 404 and error == 'not_found':
return BazaarPurchaseResult(is_valid=False, raw=data)
# 404 با invalid_value («Package name is invalid» / «Product is not found»)
# یعنی تنظیمِ ما غلط است، نه خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳.
if resp.status_code != 200 or not isinstance(data, dict):
if resp.status_code == 401:
if resp.status_code in (401, 403):
cache.delete(ACCESS_TOKEN_CACHE_KEY)
logger.error('Bazaar verify-purchase failed: status=%s error=%s', resp.status_code,
data.get('error') if isinstance(data, dict) else None)
logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', resp.status_code,
error, data.get('error_description') if isinstance(data, dict) else None)
raise BazaarAPIError()
if is_subscription:

View File

@@ -119,7 +119,7 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase):
self.assertTrue(result.is_valid)
mock_get.assert_called_once()
_, get_kwargs = mock_get.call_args
self.assertEqual(get_kwargs['headers']['Authorization'], 'Bearer real-access-token')
self.assertEqual(get_kwargs['headers']['Authorization'], 'real-access-token')
@override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz',

View File

@@ -162,6 +162,18 @@ class BazaarValidateAgainstDocsTests(TestCase):
self._resp(mock_get, 404, {'error': 'not_found'})
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='forged').is_valid)
@patch('payment.bazaar.requests.get')
def test_wrong_package_or_sku_is_503_not_invalid(self, mock_get):
# 404ِ invalid_value یعنی تنظیمِ ما غلط است، نه خریدِ جعلی.
self._resp(mock_get, 404, {'error': 'invalid_value', 'error_description': 'Package name is invalid.'})
with self.assertRaises(bazaar.BazaarAPIError):
bazaar.verify_purchase(product_sku='s', purchase_token='t')
def test_access_token_ttl_is_capped_at_an_hour(self):
# بازارِ واقعی expires_in=3600000 داد.
self.assertEqual(bazaar._access_token_ttl({'expires_in': 3600000}), 3540)
self.assertEqual(bazaar._access_token_ttl({'expires_in': 3600}), 3540)
@patch('payment.bazaar.requests.get')
def test_our_credential_error_is_503_not_invalid(self, mock_get):
# کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند.