fix(backend): add cache_none=False to get_subscription_price_id

A transient LaunchDarkly failure returned None from get_subscription_price_id,
which was cached for the full 60-second TTL, blocking subscription upgrades
until expiry. Adding cache_none=False ensures None is never stored in the cache
so the next call retries LD immediately.

Adds a regression test verifying that two consecutive calls where the first
returns None (LD transient error) and the second returns the real price ID
both hit LD, confirming the None sentinel is not cached.

Flagged by sentry[bot] (credit.py:1352, Severity: MEDIUM).
This commit is contained in:
majdyz
2026-04-14 14:38:39 +07:00
parent 14e1b47b5a
commit ec65fd5c84
2 changed files with 29 additions and 1 deletions

View File

@@ -1348,13 +1348,18 @@ async def get_auto_top_up(user_id: str) -> AutoTopUpConfig:
return AutoTopUpConfig.model_validate(user.top_up_config)
@cached(ttl_seconds=60, maxsize=8)
@cached(ttl_seconds=60, maxsize=8, cache_none=False)
async def get_subscription_price_id(tier: SubscriptionTier) -> str | None:
"""Return Stripe Price ID for a tier from LaunchDarkly, cached for 60 seconds.
Price IDs are LaunchDarkly flag values that change only at deploy time.
Caching for 60 seconds avoids hitting the LD SDK on every webhook delivery
and every GET /credits/subscription page load (called 2x per request).
``cache_none=False`` prevents a transient LD failure from caching ``None``
and blocking subscription upgrades for the full 60-second TTL window.
A tier with no configured flag (FREE, ENTERPRISE) returns ``None`` from an
O(1) dict lookup before hitting LD, so the extra LD call is never made.
"""
flag_map = {
SubscriptionTier.PRO: Flag.STRIPE_PRICE_PRO,

View File

@@ -770,6 +770,29 @@ async def test_get_subscription_price_id_empty_flag_returns_none():
get_subscription_price_id.cache_clear() # type: ignore[attr-defined]
@pytest.mark.asyncio
async def test_get_subscription_price_id_none_not_cached():
"""None returns from transient LD failures are not cached (cache_none=False).
Without cache_none=False a single LD hiccup would block upgrades for the
full 60-second TTL window because the ``None`` sentinel would be served from
cache on every subsequent call.
"""
from backend.data.credit import get_subscription_price_id
get_subscription_price_id.cache_clear() # type: ignore[attr-defined]
mock_ld = AsyncMock(side_effect=["", "price_pro_monthly"])
with patch("backend.data.credit.get_feature_flag_value", mock_ld):
# First call: LD returns empty string → None (transient failure)
first = await get_subscription_price_id(SubscriptionTier.PRO)
assert first is None
# Second call: LD returns the real price ID — must NOT be blocked by cached None
second = await get_subscription_price_id(SubscriptionTier.PRO)
assert second == "price_pro_monthly"
assert mock_ld.call_count == 2 # both calls hit LD (None was not cached)
get_subscription_price_id.cache_clear() # type: ignore[attr-defined]
@pytest.mark.asyncio
async def test_cancel_stripe_subscription_raises_on_cancel_error():
"""Stripe errors during cancellation are re-raised so the DB tier is not updated."""