fix(marketing): stop the API limiter and stale data-cache shapes from 500ing profile pages
Problem
Public profile pages (/[lang]/user/[handle], served as /<handle>) return 500s in two ways.
MARKETING-6C (513 events, ongoing since 2026-08-29): the page's server-side fetchParticipant call gets TOO_MANY_REQUESTS from leaderboard.public.participant and the render throws. 73% of events are single crawler sweeps of the whole handle list (~45 handles in 36 s); the rest are Chrome-UA crawls of /user/<handle>. Any human who opens a profile that is not in the ISR cache during such a sweep gets the same 500, and crawlers index the 500s.
MARKETING-6D / 6E / 6F (218 events, all within 2026-09-07 01:49–01:54 UTC plus one straggler at 04:46): the page renders with a profile that has no awards and no axes, so highestPerSlug iterates undefined and factoryScore / rankingGap read .width / .sustain off undefined. Each of 101 handles crashed exactly once.
User-visible harm: a visitor or crawler opening a profile page that is not yet cached gets a 500 instead of the profile, whenever more than 60 distinct profiles were rendered in the last minute, and once per handle after any deploy that changes the participant response shape.
Root cause
Two, both in how marketing's server reads the API.
-
The anonymous limiter counts our own server as one visitor.
enforcePublicReadkeys a 60/min sliding window onx-forwarded-for. Every marketing render on Vercel leaves through a shared egress address, so all profile renders in a region share one bucket. Next's data cache bounds this to one API read per handle per 300 s, but a crawler walking the sitemap renders 60+ distinct handles a minute, and the limiter refuses the rest. Forwarding the visitor's IP (shape 1 in the task) cannot work: Vercel overwritesx-forwarded-foron inbound requests to the API and does not forward external IPs (documented under "Request headers"), so the API would keep seeing the egress address regardless of what marketing sends. It would also force the ISR page dynamic. So this PR takes shape 2: marketing's server-to-server reads authenticate as internal. -
The data cache outlives a deploy; the response contract does not. Vercel's Data Cache persists across deployments and serves stale entries while revalidating. The marketing build before #7195 had cached
leaderboard.public.participantresponses (same URL, samenext.revalidate) that had noawards/axes. The first marketing build with the new page went live at 01:48 UTC; every handle's first request after that got the previous build's cached shape once, then the revalidated one. The 04:46 straggler is a handle nobody had opened since. Types cannot fix this:ParticipantProfilealready saysawardsis an array; the runtime value came from a cache written by code with a different type.
Fix
- API (
packages/trpc):enforcePublicReadreturns early when the request carriesLEADERBOARD_INTERNAL_TOKENinx-leaderboard-internal-token. The matcher (internal-read.ts) isfalsewhen no token is configured, even if no header is presented, and compares in constant time. The header name lives inschema.tsso the browser bundle that shares the tRPC client never importsnode:crypto. PlainTRPCErrors are unchanged; no typed cause was added because nothing reads one. - Marketing (
leaderboardClient.ts): server-side reads send the token and anx-superset-build: <commit sha>header. Headers are part of Next's data-cache key, so each build starts from entries it wrote itself. The browser sends nothing extra. - Page:
loadProfile(shared bygenerateMetadataand the page) turns a refused read into arate-limitedstate; the page rendersProfileUnavailable("try again shortly", link back to the leaderboard,robots: noindex) instead of throwing. Unknown handles stillnotFound(); every other failure still throws so ISR keeps the stale page rather than caching a 404 or a retry page for a live profile. - Env:
LEADERBOARD_INTERNAL_TOKENadded to both schemas as optional with a real fallback (absent = today's behaviour), to.env.example/.env.local.example, and to the api and marketing jobs in both deploy workflows.
Operator step before the token part takes effect: gh secret set LEADERBOARD_INTERNAL_TOKEN -R superset-sh/superset --body "<random>" with one value for both environments, and add it to the root .env per docs/environment-variables.md. Until then, marketing reads stay anonymous and rate-limited exactly as today; the build-scoped cache key and the retry page are live on merge.
Trade-off stated plainly: the retry page is a normal 200 render, so in the misconfigured case (no token) ISR can hold it for that handle and locale for up to 300 s. I could not confirm from Next 16.2 source that calling connection() during an on-demand ISR render falls back to an uncached dynamic render rather than an error, so the page does not rely on it. With the token set, marketing's reads are never refused and this state is not reached.
Adjacent, left alone: opengraph-image.tsx and the fight page also call fetchParticipant and still throw on a refused read (they benefit from the token exemption). deploy-marketing and deploy-api run in parallel; if marketing ever lands first it can cache one old-shape response for 300 s. The md/[...path] route already maps failures to a 503.
Verification
bunx biome check <changed files> (after one --write on fetchLeaderboard.ts):
Checked 14 files in 15ms. No fixes applied.
cd packages/trpc && bun run typecheck:
$ tsc --noEmit --emitDeclarationOnly false
cd apps/marketing && bun run typecheck:
$ tsc --noEmit
cd packages/trpc && bun test src/router/leaderboard:
118 pass
0 fail
376 expect() calls
Ran 118 tests across 7 files. [100.00ms]
cd apps/marketing && bun test "src/app/[lang]/utils" "src/app/[lang]/user":
11 pass
0 fail
22 expect() calls
Ran 11 tests across 1 file. [47.00ms]
bun run check:i18n (three new strings, translated into all 16 locales by hand):
vi ⇒ locales/vi/messages.ts
Done in 543ms
exit=0
cd packages/i18n && bun test:
119 pass
0 fail
Ran 119 tests across 9 files. [1.51s]
The matcher test (internal-read.test.ts) covers the inputs it must NOT match: no token configured with no header, no token configured with a header presented, missing header, wrong or truncated or extended token, token in Authorization instead. The matcher is new code, so the negative cases could not be run against a prior implementation; the "no token and no header" case is the one a naive equality check would get wrong.
Refs MARKETING-6C
Refs MARKETING-6D
Refs MARKETING-6E
Refs MARKETING-6F
https://claude.ai/code/session_01QUcqsehCfQcsTTXVtC4yJj
Summary by cubic
Public profile pages no longer 500 when the API limiter refuses marketing's own reads or the data cache serves a previous deploy's response shape.
- Marketing's server-side leaderboard reads send a shared token (
LEADERBOARD_INTERNAL_TOKEN) that exempts them from the per-IP anonymous limiter; the browser never sends it, it only travels over HTTPS or to a loopback dev server, and fetches refuse redirects so the token cannot leak. - Reads include an
x-superset-buildheader with the commit sha so each deploy starts from its own data-cache entries instead of reusing stale shapes. - A rate-limited read now renders a "try again shortly" page instead of throwing; unknown handles and other failures behave as before.
Migration
- Set the
LEADERBOARD_INTERNAL_TOKENsecret with one value for both environments and add it to the root.envbefore the token part takes effect. - Without the token, marketing reads stay rate-limited exactly as today; the build-scoped cache key and retry page are live on merge.
Summary by CodeRabbit
- New Features
- Profile pages now show a localized “try again shortly” message when temporarily unavailable due to high traffic.
- Added a link back to the leaderboard from unavailable profiles.
- Server-side leaderboard reads can avoid public rate limits, improving profile availability.
- Localization
- Added profile availability and navigation translations across all supported languages.
- Bug Fixes
- Profile lookups now distinguish between missing profiles and temporarily rate-limited requests.