From 33253dc5841efbda34f8feb666f3090a52ded99f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 15:19:32 +0100 Subject: [PATCH 1/4] feat(webapp): add 60s/60s SWR cache to getEntitlement 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. --- .server-changes/getEntitlement-swr-cache.md | 6 ++++ .../webapp/app/services/platform.v3.server.ts | 31 ++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 .server-changes/getEntitlement-swr-cache.md diff --git a/.server-changes/getEntitlement-swr-cache.md b/.server-changes/getEntitlement-swr-cache.md new file mode 100644 index 0000000000..1c9c887a33 --- /dev/null +++ b/.server-changes/getEntitlement-swr-cache.md @@ -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. diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 3a98139600..49f1d6908c 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -71,6 +71,11 @@ function initializePlatformCache() { fresh: 60_000 * 5, // 5 minutes stale: 60_000 * 10, // 10 minutes }), + entitlement: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, // 60 seconds + stale: 60_000, // 60 seconds + }), }); return cache; @@ -531,21 +536,25 @@ export async function getEntitlement( ): Promise { if (!client) return undefined; - try { - const result = await client.getEntitlement(organizationId); - if (!result.success) { - logger.error("Error getting entitlement - no success", { error: result.error }); + 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 { + hasAccess: true as const, + }; + } + return response; + } catch (e) { + logger.error("Error getting entitlement - caught error", { error: e }); return { hasAccess: true as const, }; } - return result; - } catch (e) { - logger.error("Error getting entitlement - caught error", { error: e }); - return { - hasAccess: true as const, - }; - } + }); + + return result.val; } export async function projectCreated( From 312c91be520ae080290bb71c4c3e5824f76004d0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 16:51:36 +0100 Subject: [PATCH 2/4] fix(webapp): don't cache getEntitlement fail-open fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught a correctness bug in the previous commit. Returning { hasAccess: true } from inside the SWR loader on error caused that fail-open value to be cached for 60-120s, which could overwrite a legitimate hasAccess: false during a transient billing outage and grant a blocked org access for up to 120s. Fix: catch errors inside the loader (so we don't trigger the @unkey/cache unhandled-rejection issue during background revalidation) and return undefined. Apply the fail-open default *outside* the SWR call so it never becomes a cached access decision. Trade-off: returning undefined from the loader still overwrites the previous cached entry with an undefined value, but @unkey/cache's swr() treats an undefined cached value as a miss and re-fetches on the next request — so on billing recovery, the cache picks up the real result immediately rather than serving a stale fail-open for up to 120s. --- .../webapp/app/services/platform.v3.server.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 49f1d6908c..a7e6ec151d 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -536,24 +536,33 @@ export async function getEntitlement( ): Promise { if (!client) return undefined; + // 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 { - hasAccess: true as const, - }; + return undefined; } return response; } catch (e) { logger.error("Error getting entitlement - caught error", { error: e }); - return { - hasAccess: true as const, - }; + return undefined; } }); + if (result.err || result.val === undefined) { + return { + hasAccess: true as const, + }; + } + return result.val; } From 834a021c0729a7efae4cb1e9e1ba911fa1040d97 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 17:34:31 +0100 Subject: [PATCH 3/4] fix(webapp): correct entitlement cache stale TTL The stale parameter on @unkey/cache Namespace is the TOTAL ttl, not an additional window beyond fresh. Setting fresh: 60_000 and stale: 60_000 gave a fresh-only entry that expired entirely at 60s with no SWR window. Setting stale: 120_000 yields the intended behavior: fresh 0-60s, stale-revalidate 60-120s. Verified locally with agentcrumbs by running through cache miss, fresh hit, stale revalidate, and stale-with-failed-bg-revalidate scenarios against a live billing server. --- apps/webapp/app/services/platform.v3.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index a7e6ec151d..097d723890 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -73,8 +73,8 @@ function initializePlatformCache() { }), entitlement: new Namespace(ctx, { stores: [memory, redisCacheStore], - fresh: 60_000, // 60 seconds - stale: 60_000, // 60 seconds + fresh: 60_000, // serve without revalidation for 60s + stale: 120_000, // total TTL — fresh 0-60s, stale-revalidate 60-120s }), }); From b2e910869fdb242316677cd662a09771dfccbe26 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 15 Apr 2026 17:52:09 +0100 Subject: [PATCH 4/4] fix(webapp): invalidate entitlement cache on plan changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setPlan transitions a customer's plan (free_connected, updated_subscription, canceled_subscription), invalidate the new entitlement cache alongside the existing billing cache invalidation. Without this, a downgrade or cancellation could leave hasAccess: true served from cache for up to 120s — meaning revoked access would linger. Per Devin's review on the swr cache PR. --- apps/webapp/app/services/platform.v3.server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 097d723890..e4ca57f796 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -373,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( @@ -389,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."); } }