feat(webapp): add 60s/60s SWR cache to getEntitlement (#3388)

Wraps getEntitlement in platform.v3.server.ts with the existing
platformCache (LRU memory + Redis) under a new `entitlement` namespace.
Eliminates a synchronous billing-service HTTP round trip on every
trigger.

Cache config: 60s fresh / 60s stale SWR. Cache key is the
organization id. Errors are caught inside the loader and return the
existing permissive { hasAccess: true } fallback, which is also
cached to prevent thundering-herd on billing outages.

Trade-off: plan upgrade/downgrade is now visible after up to ~120s
worst-case (60s fresh + 60s stale revalidation). Acceptable since
the existing limits and usage namespaces use 5min/10min, and the
defensive hasAccess: true fallback already exists.
This commit is contained in:
Eric Allam
2026-04-16 15:37:27 +01:00
committed by GitHub
parent 79b6053e13
commit 67d2025f33
2 changed files with 37 additions and 10 deletions
@@ -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.
+31 -10
View File
@@ -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
}),
});
return cache;
@@ -368,6 +373,7 @@ export async function setPlan(
if (result.accepted) {
// Invalidate billing cache since plan changed
opts?.invalidateBillingCache?.(organization.id);
platformCache.entitlement.remove(organization.id).catch(() => {});
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);
platformCache.entitlement.remove(organization.id).catch(() => {});
return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully.");
}
case "canceled_subscription": {
// Invalidate billing cache since subscription was canceled
opts?.invalidateBillingCache?.(organization.id);
platformCache.entitlement.remove(organization.id).catch(() => {});
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 });
});
if (result.err || result.val === undefined) {
return {
hasAccess: true as const,
};
}
return result.val;
}
export async function projectCreated(