diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index 03f9ee12c9..9f24be3d32 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -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, diff --git a/autogpt_platform/backend/backend/data/credit_subscription_test.py b/autogpt_platform/backend/backend/data/credit_subscription_test.py index 6e62575071..2d9c7964dc 100644 --- a/autogpt_platform/backend/backend/data/credit_subscription_test.py +++ b/autogpt_platform/backend/backend/data/credit_subscription_test.py @@ -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."""