Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.
Comment thread
ericallam 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."));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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 thread
ericallam marked this conversation as resolved.

if (result.err || result.val === undefined) {
return {
hasAccess: true as const,
};
}

return result.val;
Comment thread
ericallam marked this conversation as resolved.
}

export async function projectCreated(
Expand Down
Loading