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('این لینک همین الان یک بار استفاده شد.') raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.')
cache.delete(ACCESS_TOKEN_CACHE_KEY) cache.delete(ACCESS_TOKEN_CACHE_KEY)
if data.get('access_token'): if data.get('access_token'):
cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], timeout=_access_token_ttl(data))
timeout=max(int(data.get('expires_in', 3600)) - 60, 60))
logger.info('Bazaar API authorized; refresh token stored') 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: def _get_access_token() -> str:
""" """
ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک 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) logger.error('Bazaar access-token response malformed: status=%s', resp.status_code)
raise BazaarAPIError() raise BazaarAPIError()
# طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=_access_token_ttl(data))
# می‌کنیم تا لبه‌ی زمانی مشکل نسازه.
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60)
return token return token
@@ -234,7 +242,10 @@ def _auth_headers() -> dict:
""" """
if settings.BAZAAR_PISHKHAN_API_SECRET: if settings.BAZAAR_PISHKHAN_API_SECRET:
return {'CAFEBAZAAR-PISHKHAN-API-SECRET': 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: 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/validate/<pkg>/inapp/<sku>/purchases/<token>/
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token> اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
فقط ۴۰۴ نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود. تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود.
""" """
if not is_configured(): if not is_configured():
@@ -279,13 +290,16 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
except ValueError: except ValueError:
data = None data = None
if resp.status_code == 404: error = data.get('error') if isinstance(data, dict) else None
return BazaarPurchaseResult(is_valid=False, raw=data if isinstance(data, dict) else {'status_code': 404}) 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 != 200 or not isinstance(data, dict):
if resp.status_code == 401: if resp.status_code in (401, 403):
cache.delete(ACCESS_TOKEN_CACHE_KEY) cache.delete(ACCESS_TOKEN_CACHE_KEY)
logger.error('Bazaar verify-purchase failed: status=%s error=%s', resp.status_code, logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', resp.status_code,
data.get('error') if isinstance(data, dict) else None) error, data.get('error_description') if isinstance(data, dict) else None)
raise BazaarAPIError() raise BazaarAPIError()
if is_subscription: if is_subscription:

View File

@@ -119,7 +119,7 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase):
self.assertTrue(result.is_valid) self.assertTrue(result.is_valid)
mock_get.assert_called_once() mock_get.assert_called_once()
_, get_kwargs = mock_get.call_args _, 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( @override_settings(
BAZAAR_REFRESH_TOKEN='refresh-token-xyz', BAZAAR_REFRESH_TOKEN='refresh-token-xyz',

View File

@@ -162,6 +162,18 @@ class BazaarValidateAgainstDocsTests(TestCase):
self._resp(mock_get, 404, {'error': 'not_found'}) self._resp(mock_get, 404, {'error': 'not_found'})
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='forged').is_valid) 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') @patch('payment.bazaar.requests.get')
def test_our_credential_error_is_503_not_invalid(self, mock_get): def test_our_credential_error_is_503_not_invalid(self, mock_get):
# کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند. # کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند.