Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/getEntitlement-swr-cache.md
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.
41 changes: 31 additions & 10 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

return cache;
Expand Down Expand Up @@ -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(
Expand All @@ -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.");
}
}
Expand Down Expand Up @@ -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 });
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +542 to +561
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 undefined on errors (lines 554, 559), and the comment at lines 542-548 explicitly states this prevents SWR from committing a fail-open value to the cache. This is critical: if @unkey/cache does cache undefined, then a transient billing error during background revalidation could overwrite a legitimate hasAccess: false cached entry, granting access to an org that should be blocked. The author clearly considered this (the comment is detailed), and most SWR cache implementations skip storing undefined/null, but this behavior should be verified against the actual @unkey/cache source. Meanwhile, the .server-changes/getEntitlement-swr-cache.md:6 description contradicts the code by claiming errors 'are also cached to prevent thundering-herd on billing outages' — if errors are NOT cached (per the code comments), thundering-herd protection only kicks in while a valid cached entry exists.

Open in Devin Review

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(
Expand Down
Loading