-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(webapp): add 60s/60s SWR cache to getEntitlement #3388
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
33253dc
312c91b
834a021
b2e9108
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: improvement | ||
| --- | ||
|
|
||
| Add 60s fresh / 60s stale SWR cache to `getEntitlement` in `platform.v3.server.ts`. Eliminates a synchronous billing-service HTTP round trip on every trigger. Reuses the existing `platformCache` (LRU memory + Redis) pattern already used for `limits` and `usage`. Cache key is `${orgId}`. Errors return a permissive `{ hasAccess: true }` fallback (existing behavior) and are also cached to prevent thundering-herd on billing outages. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,6 +71,11 @@ function initializePlatformCache() { | |
| fresh: 60_000 * 5, // 5 minutes | ||
| stale: 60_000 * 10, // 10 minutes | ||
| }), | ||
| entitlement: new Namespace<ReportUsageResult>(ctx, { | ||
| stores: [memory, redisCacheStore], | ||
| fresh: 60_000, // serve without revalidation for 60s | ||
| stale: 120_000, // total TTL — fresh 0-60s, stale-revalidate 60-120s | ||
| }), | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| return cache; | ||
|
|
@@ -368,6 +373,7 @@ export async function setPlan( | |
| if (result.accepted) { | ||
| // Invalidate billing cache since plan changed | ||
| opts?.invalidateBillingCache?.(organization.id); | ||
| await platformCache.entitlement.remove(organization.id); | ||
| return redirect(newProjectPath(organization, "You're on the Free plan.")); | ||
| } else { | ||
| return redirectWithErrorMessage( | ||
|
|
@@ -384,11 +390,13 @@ export async function setPlan( | |
| case "updated_subscription": { | ||
| // Invalidate billing cache since subscription changed | ||
| opts?.invalidateBillingCache?.(organization.id); | ||
| await platformCache.entitlement.remove(organization.id); | ||
| return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully."); | ||
| } | ||
| case "canceled_subscription": { | ||
| // Invalidate billing cache since subscription was canceled | ||
| opts?.invalidateBillingCache?.(organization.id); | ||
| await platformCache.entitlement.remove(organization.id); | ||
| return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); | ||
| } | ||
| } | ||
|
|
@@ -531,21 +539,34 @@ export async function getEntitlement( | |
| ): Promise<ReportUsageResult | undefined> { | ||
| if (!client) return undefined; | ||
|
|
||
| try { | ||
| const result = await client.getEntitlement(organizationId); | ||
| if (!result.success) { | ||
| logger.error("Error getting entitlement - no success", { error: result.error }); | ||
| return { | ||
| hasAccess: true as const, | ||
| }; | ||
| // Errors must be caught inside the loader — @unkey/cache passes the loader | ||
| // promise to waitUntil() with no .catch(), so an unhandled rejection during | ||
| // background SWR revalidation would crash the process. Returning undefined | ||
| // on error tells SWR not to commit a fail-open value to the cache, which | ||
| // prevents transient billing errors from overwriting a legitimate | ||
| // hasAccess: false entry. The fail-open default is applied *outside* the | ||
| // SWR call so it never becomes a cached access decision. | ||
| const result = await platformCache.entitlement.swr(organizationId, async () => { | ||
| try { | ||
| const response = await client.getEntitlement(organizationId); | ||
| if (!response.success) { | ||
| logger.error("Error getting entitlement - no success", { error: response.error }); | ||
| return undefined; | ||
| } | ||
| return response; | ||
| } catch (e) { | ||
| logger.error("Error getting entitlement - caught error", { error: e }); | ||
| return undefined; | ||
| } | ||
| return result; | ||
| } catch (e) { | ||
| logger.error("Error getting entitlement - caught error", { error: e }); | ||
| }); | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines
+542
to
+561
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 SWR undefined-return strategy depends on @unkey/cache not caching undefined The SWR loader returns Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| if (result.err || result.val === undefined) { | ||
| return { | ||
| hasAccess: true as const, | ||
| }; | ||
| } | ||
|
|
||
| return result.val; | ||
| } | ||
|
|
||
| export async function projectCreated( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.