From 82d7b9ce57227eafa49b70e957ea66ec03fc9bb4 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 19:42:35 +0000 Subject: [PATCH 1/3] fix(web): answer a failed profile, community or wave lookup as retryable, not 404 A server lookup of an account, community or wave post that failed upstream (RPC error, rate limit, timeout) was treated as "does not exist": the page answered 404, telling readers and crawlers a live page was gone. - prefetchRequired: returns the record, calls notFound() only when the lookup succeeded and found nothing (or a node asserted it does not exist). It otherwise marks the response lookup-unavailable and throws SsrLookupUnavailableError. Same React Query fetch as prefetchQuery. - Profile, community and wave pages use it for their record lookup. The profile section page answers an unknown section before any lookup. - ssr-degraded.js sends a lookup-unavailable response that ended in a server error as 503 with Retry-After (still private, no-store). Headers handed to writeHead can no longer override a degraded response's Cache-Control. The minute log line carries a short sample of the failing errors. - Profile, community and wave segments get an error module with a retry inside the layout instead of the global 500. Client render errors are still reported, through the lazy Sentry facade. - The server Sentry config drops SsrLookupUnavailableError. - isHiveNotFoundError also recognises the asserts for an account name no account can have, so those URLs stay cacheable 404s. Closes #2000 --- apps/web/sentry.server.config.ts | 6 + .../community/[community]/[tag]/page.tsx | 13 +- .../community/[community]/activities/page.tsx | 8 +- .../community/[community]/error.tsx | 5 + .../community/[community]/page.tsx | 10 +- .../community/[community]/roles/page.tsx | 8 +- .../[community]/subscribers/page.tsx | 8 +- .../profile/[username]/[section]/page.tsx | 31 +-- .../profile/[username]/communities/page.tsx | 10 +- .../profile/[username]/error.tsx | 5 + .../profile/[username]/followers/page.tsx | 9 +- .../profile/[username]/following/page.tsx | 9 +- .../profile/[username]/insights/page.tsx | 9 +- .../profile/[username]/page.tsx | 18 +- .../profile/[username]/permissions/page.tsx | 10 +- .../profile/[username]/referrals/page.tsx | 9 +- .../profile/[username]/settings/page.tsx | 10 +- .../profile/[username]/trail/page.tsx | 9 +- apps/web/src/app/global-error.tsx | 15 +- .../app/waves/[author]/[permlink]/error.tsx | 5 + .../app/waves/[author]/[permlink]/page.tsx | 12 +- .../web/src/core/react-query/query-helpers.ts | 77 +++++++- apps/web/src/features/i18n/locales/en-US.json | 5 + .../shared/lookup-unavailable/index.ts | 1 + .../lookup-unavailable-error.tsx | 63 +++++++ ...lookup-unavailable-error-boundary.spec.tsx | 79 ++++++++ ...ofile-community-lookup-unavailable.spec.ts | 178 ++++++++++++++++++ .../app/profile-page-ssr-stream.spec.tsx | 20 +- .../profile-section-page-ssr-stream.spec.tsx | 20 +- .../specs/app/wave-page-ssr-stream.spec.tsx | 12 +- .../react-query/prefetch-required.spec.ts | 120 ++++++++++++ apps/web/src/specs/setup-any-spec.ts | 22 +++ apps/web/src/specs/ssr-degraded.spec.ts | 91 ++++++++- .../specs/utils/hive-not-found-error.spec.ts | 7 +- .../specs/utils/sentry-config-wiring.spec.ts | 14 ++ apps/web/src/utils/hive-not-found-error.ts | 8 +- apps/web/ssr-degraded.js | 79 +++++++- 37 files changed, 870 insertions(+), 145 deletions(-) create mode 100644 apps/web/src/app/(dynamicPages)/community/[community]/error.tsx create mode 100644 apps/web/src/app/(dynamicPages)/profile/[username]/error.tsx create mode 100644 apps/web/src/app/waves/[author]/[permlink]/error.tsx create mode 100644 apps/web/src/features/shared/lookup-unavailable/index.ts create mode 100644 apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx create mode 100644 apps/web/src/specs/app/lookup-unavailable-error-boundary.spec.tsx create mode 100644 apps/web/src/specs/app/profile-community-lookup-unavailable.spec.ts create mode 100644 apps/web/src/specs/core/react-query/prefetch-required.spec.ts diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts index 139d1ccd13..5a2c403fce 100644 --- a/apps/web/sentry.server.config.ts +++ b/apps/web/sentry.server.config.ts @@ -46,6 +46,12 @@ Sentry.init({ // inbox at fatal level — the request is already abandoned by the // client so there's nothing actionable on our side. const ex = event.exception?.values?.[0]; + // A page's required record lookup failed upstream (query-helpers.ts, + // prefetchRequired). Expected, answered as a retryable 503 and counted in + // the ssr-degraded log line with its error: not a code defect. + if (ex?.type === "SsrLookupUnavailableError") { + return null; + } if (ex?.type === "Error" && ex?.value === "aborted") { const frames = JSON.stringify(ex?.stacktrace?.frames ?? ""); if (frames.includes("_http_server") || frames.includes("abortIncoming")) { diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsx index b4e98a7810..e3a75a4ec8 100644 --- a/apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsx +++ b/apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsx @@ -1,11 +1,11 @@ import { getCommunityCache } from "@/core/caches"; -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { prefetchGetPostsFeedQuery } from "@/api/queries"; import { EntryListContent } from "@/features/shared/entry-list-content"; import { EntryArchivePager } from "@/features/shared/entry-archive-pager"; import { LinearProgress } from "@/features/shared/linear-progress"; import { dehydrate, HydrationBoundary, InfiniteData } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; import { stripAnonEntryCacheInPlace } from "@/core/react-query/strip-active-votes"; @@ -61,10 +61,9 @@ export default async function CommunityPostsPage({ params, searchParams }: Props // fully server-rendered (no infinite scroll) with a crawlable pager. if (cursor) { const [communityData, archive] = await Promise.all([ - prefetchQuery(getCommunityCache(community)), + prefetchRequired(getCommunityCache(community)), fetchRankedCursorPage(tag, community, cursor) ]); - if (!communityData) return notFound(); if (archive.entries.length === 0) return redirect(basePath); // stale cursor const archiveEntries = stripAnonEntryCacheInPlace( getQueryClient(), @@ -98,14 +97,14 @@ export default async function CommunityPostsPage({ params, searchParams }: Props ); } + // A missing community is notFound(); a lookup that failed throws a retryable + // lookup-unavailable error instead (prefetchRequired). const [communityData, data, base] = await Promise.all([ - prefetchQuery(getCommunityCache(community)), + prefetchRequired(getCommunityCache(community)), prefetchGetPostsFeedQuery(tag, community), getServerAppBase() ]); - if (!communityData) return notFound(); - if (!data || !Array.isArray(data.pages) || data.pages.length === 0) { return null; } diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/activities/page.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/activities/page.tsx index 915179bfe0..bdafc0c825 100644 --- a/apps/web/src/app/(dynamicPages)/community/[community]/activities/page.tsx +++ b/apps/web/src/app/(dynamicPages)/community/[community]/activities/page.tsx @@ -1,7 +1,6 @@ import { CommunityActivities } from "../_components"; import { getCommunityCache } from "@/core/caches"; -import { notFound } from "next/navigation"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers"; @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function ActivitiesPage({ params }: Props) { const { community } = await params; - const communityData = await prefetchQuery(getCommunityCache(community)); - if (!communityData) { - return notFound(); - } + const communityData = await prefetchRequired(getCommunityCache(community)); return ( diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/error.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/error.tsx new file mode 100644 index 0000000000..337a630ba1 --- /dev/null +++ b/apps/web/src/app/(dynamicPages)/community/[community]/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +// Catches a failed community lookup on client-side navigation (#2000). See +// LookupUnavailableError for why a full page load never reaches it. +export { LookupUnavailableError as default } from "@/features/shared/lookup-unavailable"; diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/page.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/page.tsx index cbc5a395cb..de0dad3f15 100644 --- a/apps/web/src/app/(dynamicPages)/community/[community]/page.tsx +++ b/apps/web/src/app/(dynamicPages)/community/[community]/page.tsx @@ -1,5 +1,4 @@ import { getCommunityCache } from "@/core/caches"; -import { notFound } from "next/navigation"; import { prefetchGetPostsFeedQuery } from "@/api/queries"; import { EntryListContent } from "@/features/shared/entry-list-content"; import { LinearProgress } from "@/features/shared/linear-progress"; @@ -8,7 +7,7 @@ import { ProfileEntriesLayout } from "@/app/(dynamicPages)/profile/[username]/_c import { Entry } from "@/entities"; import { CommunityContentInfiniteList } from "@/app/(dynamicPages)/community/[community]/_components/community-content-infinite-list"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; import { stripAnonEntryCacheInPlace } from "@/core/react-query/strip-active-votes"; @@ -45,10 +44,9 @@ export default async function CommunityPostsPage({ params }: Props) { // rewritten in place so the dehydrated copy and the entries prop share one // reference. const loggedInUser = (await cookies()).get(ACTIVE_USER_COOKIE_NAME)?.value; - const communityData = await prefetchQuery(getCommunityCache(community)); - if (!communityData) { - return notFound(); - } + // A missing community is notFound(); a lookup that failed throws a retryable + // lookup-unavailable error instead (prefetchRequired). + const communityData = await prefetchRequired(getCommunityCache(community)); const fetched = await prefetchGetPostsFeedQuery("created", community); if (!fetched || !fetched.pages || fetched.pages.length === 0) { diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/roles/page.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/roles/page.tsx index afb0fd953e..519296562f 100644 --- a/apps/web/src/app/(dynamicPages)/community/[community]/roles/page.tsx +++ b/apps/web/src/app/(dynamicPages)/community/[community]/roles/page.tsx @@ -1,8 +1,7 @@ import { CommunityRoles } from "../_components"; import { getCommunityCache } from "@/core/caches"; -import { notFound } from "next/navigation"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers"; @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function RolesPage({ params }: Props) { const { community } = await params; - const communityData = await prefetchQuery(getCommunityCache(community)); - if (!communityData) { - return notFound(); - } + const communityData = await prefetchRequired(getCommunityCache(community)); return ( diff --git a/apps/web/src/app/(dynamicPages)/community/[community]/subscribers/page.tsx b/apps/web/src/app/(dynamicPages)/community/[community]/subscribers/page.tsx index 7796334ca9..853d8d4090 100644 --- a/apps/web/src/app/(dynamicPages)/community/[community]/subscribers/page.tsx +++ b/apps/web/src/app/(dynamicPages)/community/[community]/subscribers/page.tsx @@ -1,8 +1,7 @@ import { CommunitySubscribers } from "../_components"; import { getCommunityCache } from "@/core/caches"; -import { notFound } from "next/navigation"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers"; @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function SubscribersPage({ params }: Props) { const { community } = await params; - const communityData = await prefetchQuery(getCommunityCache(community)); - if (!communityData) { - return notFound(); - } + const communityData = await prefetchRequired(getCommunityCache(community)); return ( diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx index 6d66ef8732..7c191afaa4 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx @@ -9,7 +9,12 @@ import { prefetchGetPostsFeedQuery } from "@/api/queries"; import { EcencyEntriesCacheManagement } from "@/core/caches"; import { notFound, redirect } from "next/navigation"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery, fetchInfiniteQuery } from "@/core/react-query"; +import { + getQueryClient, + prefetchQuery, + prefetchRequired, + fetchInfiniteQuery +} from "@/core/react-query"; import { stripActiveVotesFromDehydratedState } from "@/core/react-query/strip-active-votes"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; @@ -35,6 +40,9 @@ import type { SearchResponse } from "@ecency/sdk"; // none of the three. profile-section-page-no-ssr-skeleton.spec.ts pins the // absence here and on every ancestor segment. +/** The feed tabs this segment serves; any other section is a 404. */ +const PROFILE_FEED_SECTIONS = ["", "posts", "comments", "replies", "blog"]; + interface Props { params: Promise<{ username: string; section: string }>; searchParams: Promise>; @@ -58,6 +66,12 @@ export default async function Page({ params, searchParams }: Props) { const username = usernameParam.replace(/%40/g, ""); + // An unknown section is a 404 whatever the account lookup does, so it is + // answered before any lookup (and costs no upstream call). + if (!PROFILE_FEED_SECTIONS.includes(section)) { + return notFound(); + } + // Cursor archive page (/@author/
?before=/): O(1), // one fetch of the 20 posts older than the cursor. Page 1 is the clean, // query-less default view; archiveCursor() returns null there. @@ -65,12 +79,9 @@ export default async function Page({ params, searchParams }: Props) { if (cursor) { const basePath = `/@${username}/${section}`; const [account, archive] = await Promise.all([ - prefetchQuery(getAccountFullQueryOptions(username)), + prefetchRequired(getAccountFullQueryOptions(username)), fetchAuthorCursorPage(username, section, cursor) ]); - if (!account) { - return notFound(); - } // Stale/invalid cursor (nothing older than it): send to the clean first page. if (archive.entries.length === 0) { return redirect(basePath); @@ -90,8 +101,10 @@ export default async function Page({ params, searchParams }: Props) { ); } + // A missing account is notFound(); a lookup that failed throws a retryable + // lookup-unavailable error instead (prefetchRequired). const [account, searchPages, prefetchedFeed] = await Promise.all([ - prefetchQuery(getAccountFullQueryOptions(username)), + prefetchRequired(getAccountFullQueryOptions(username)), searchParam ? fetchInfiniteQuery( getSearchApiInfiniteQueryOptions( @@ -112,7 +125,7 @@ export default async function Page({ params, searchParams }: Props) { // is load-bearing: getPostQueryOptions trims the permlink while BUILDING the // options object, so a non-string `pinned` throws before the first flush and // would take the whole document. - const pinned = pinnedPermlink(account?.profile); + const pinned = pinnedPermlink(account.profile); if (pinned) { await prefetchQuery(EcencyEntriesCacheManagement.getEntryQueryByPath(username, pinned)); } @@ -128,10 +141,6 @@ export default async function Page({ params, searchParams }: Props) { : undefined; const initialFeed = prefetchedFeed as InfiniteData | undefined; - if (!account || !["", "posts", "comments", "replies", "blog"].includes(section)) { - return notFound(); - } - return ( {searchData && searchData.length > 0 ? ( diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/communities/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/communities/page.tsx index 3a0400532d..218be694d2 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/communities/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/communities/page.tsx @@ -1,6 +1,5 @@ -import { notFound } from "next/navigation"; import { ProfileCommunities } from "./_page"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchRequired } from "@/core/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers"; import { getAccountFullQueryOptions } from "@ecency/sdk"; @@ -17,12 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function ProfileCommunitiesPage({ params }: Props) { const { username } = await params; - const query = getAccountFullQueryOptions(username.replace(/%40/g, "")); - const account = await prefetchQuery(query); - - if (!account) { - return notFound(); - } + await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); return ; } diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/error.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/error.tsx new file mode 100644 index 0000000000..6d2be25fe7 --- /dev/null +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +// Catches a failed account lookup on client-side navigation (#2000). See +// LookupUnavailableError for why a full page load never reaches it. +export { LookupUnavailableError as default } from "@/features/shared/lookup-unavailable"; diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/followers/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/followers/page.tsx index a19b0978f0..fc7a470a6e 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/followers/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/followers/page.tsx @@ -1,5 +1,4 @@ -import { notFound } from "next/navigation"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchRequired } from "@/core/react-query"; import { getAccountFullQueryOptions } from "@ecency/sdk"; import { Metadata, ResolvingMetadata } from "next"; import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers"; @@ -16,11 +15,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function FollowersPage({ params }: Props) { const { username } = await params; - const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, ""))); - - if (!account) { - return notFound(); - } + const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); return ; } diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/following/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/following/page.tsx index d577205c36..43f934f4aa 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/following/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/following/page.tsx @@ -1,5 +1,4 @@ -import { notFound } from "next/navigation"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchRequired } from "@/core/react-query"; import { getAccountFullQueryOptions } from "@ecency/sdk"; import { Metadata, ResolvingMetadata } from "next"; import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers"; @@ -16,11 +15,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function FollowingPage({ params }: Props) { const { username } = await params; - const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, ""))); - - if (!account) { - return notFound(); - } + const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); return ; } diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/insights/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/insights/page.tsx index 3a7d7810de..b4c9d07ea4 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/insights/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/insights/page.tsx @@ -1,8 +1,7 @@ import { generateProfileMetadata } from "../_helpers"; import { Metadata, ResolvingMetadata } from "next"; -import { notFound } from "next/navigation"; import { cookies } from "next/headers"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchQuery, prefetchRequired } from "@/core/react-query"; import { getAccountFullQueryOptions, getProMembersQueryOptions } from "@ecency/sdk"; import { isProMember } from "@/features/pro/pro-config"; import { ProfileInsights } from "./_page"; @@ -21,11 +20,7 @@ export default async function InsightsPage({ params }: Props) { const { username } = await params; const { get } = await cookies(); - const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, ""))); - - if (!account) { - return notFound(); - } + const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); // Own insights stay free; viewing anyone else's is an Ecency Pro perk. Non-Pro viewers get a // Go-Pro upsell instead of the data (previously this was a hard owner-only redirect). diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx index ba89cff963..aee8eb7117 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx @@ -1,12 +1,16 @@ import { ProfileEntriesList, ProfileSearchContent } from "./_components"; import { prefetchGetPostsFeedQuery } from "@/api/queries"; -import { prefetchQuery, getQueryClient, fetchInfiniteQuery } from "@/core/react-query"; +import { + prefetchQuery, + prefetchRequired, + getQueryClient, + fetchInfiniteQuery +} from "@/core/react-query"; import { stripActiveVotesFromDehydratedState } from "@/core/react-query/strip-active-votes"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; import { getAccountFullQueryOptions, getSearchApiInfiniteQueryOptions } from "@ecency/sdk"; import { EcencyEntriesCacheManagement } from "@/core/caches"; -import { notFound } from "next/navigation"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { @@ -39,8 +43,10 @@ export default async function Page({ params, searchParams }: Props) { const { query: searchParam } = await searchParams; const loggedInUser = (await cookies()).get(ACTIVE_USER_COOKIE_NAME)?.value; + // A missing account is notFound(); a lookup that failed throws a retryable + // lookup-unavailable error instead (prefetchRequired). const [account, searchPages, prefetchedFeed] = await Promise.all([ - prefetchQuery(getAccountFullQueryOptions(username)), + prefetchRequired(getAccountFullQueryOptions(username)), searchParam ? fetchInfiniteQuery( getSearchApiInfiniteQueryOptions( @@ -60,7 +66,7 @@ export default async function Page({ params, searchParams }: Props) { // options object — before the `enabled` gate and outside the queryFn — so a // non-string pinned value throws here, ahead of the first flush, and now that // this route has no loading.tsx above it that throw is the whole document. - const pinned = pinnedPermlink(account?.profile); + const pinned = pinnedPermlink(account.profile); if (pinned) { await prefetchQuery(EcencyEntriesCacheManagement.getEntryQueryByPath(username, pinned)); } @@ -75,10 +81,6 @@ export default async function Page({ params, searchParams }: Props) { : undefined; const initialFeed = prefetchedFeed as InfiniteData | undefined; - if (!account) { - return notFound(); - } - return ( {searchData && searchData.length > 0 ? ( diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/page.tsx index 2fffef0ede..11b1248ef9 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/page.tsx @@ -1,7 +1,7 @@ -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchRequired } from "@/core/react-query"; import { Metadata, ResolvingMetadata } from "next"; import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers"; import { ProfilePermissions } from "./_components"; @@ -21,11 +21,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function PermissionsPage({ params }: Props) { const { username } = await params; const { get } = await cookies(); - const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, ""))); - - if (!account) { - return notFound(); - } + const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); if (account.name !== get("active_user")?.value) { return redirect(`/@${username.replace(/%40/g, "")}`); diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/referrals/page.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/referrals/page.tsx index 0fdfa81131..d0e5c0da22 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/referrals/page.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/referrals/page.tsx @@ -1,8 +1,7 @@ -import { notFound } from "next/navigation"; import { ProfileReferrals } from "../_components"; import { Redirect } from "@/features/shared/redirect"; import { EcencyConfigManager } from "@/config"; -import { prefetchQuery } from "@/core/react-query"; +import { prefetchRequired } from "@/core/react-query"; import { getAccountFullQueryOptions } from "@ecency/sdk"; import { Metadata, ResolvingMetadata } from "next"; import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers"; @@ -18,11 +17,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata): export default async function ReferralsPage({ params }: Props) { const { username } = await params; - const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, ""))); - - if (!account) { - return notFound(); - } + const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, ""))); return ( diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx index a0010d9bd5..55b2754202 100644 --- a/apps/web/src/app/global-error.tsx +++ b/apps/web/src/app/global-error.tsx @@ -1,7 +1,10 @@ "use client"; // Error boundaries must be Client Components /** - * This is the app's ONLY error module. #1808 asked whether the entry, wave, + * The app-wide error module. The profile and community segments have their own + * (#2000, see the note at the end of this comment); everything else lands here. + * + * #1808 asked whether the entry, wave, * feed, profile and community routes should get an `error.tsx` of their own now * that their loading modules are gone. The answer is no. The reason is worth * recording so the next reader does not re-derive it. @@ -39,6 +42,16 @@ * widget with real logic, a third-party embed), or if a Suspense boundary comes * back for an unrelated reason - with one in place, an `error.tsx` below it * would start earning its chunk. + * + * #2000 is that case for profile, community and wave pages. A failed account, + * community or post lookup now THROWS (prefetchRequired) instead of answering + * notFound(): a lookup failure is not an absence. On the routes without a + * Suspense boundary a full page load is still not caught by a boundary - the + * server answers it as a retryable 503 - but on a CLIENT-side navigation, and + * on the profile sub-pages that keep a loading.tsx, the error reaches the + * client and would land here, a full-page 500 with no retry. The segments' + * `error.tsx` (features/shared/lookup-unavailable) keep the layout around a + * retry instead. */ import { Feedback } from "@/features/shared/feedback"; diff --git a/apps/web/src/app/waves/[author]/[permlink]/error.tsx b/apps/web/src/app/waves/[author]/[permlink]/error.tsx new file mode 100644 index 0000000000..60b8272d7f --- /dev/null +++ b/apps/web/src/app/waves/[author]/[permlink]/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +// Catches a failed wave lookup on client-side navigation (#2000). See +// LookupUnavailableError for when it renders. +export { LookupUnavailableError as default } from "@/features/shared/lookup-unavailable"; diff --git a/apps/web/src/app/waves/[author]/[permlink]/page.tsx b/apps/web/src/app/waves/[author]/[permlink]/page.tsx index b056a0d6ca..86489ec9b9 100644 --- a/apps/web/src/app/waves/[author]/[permlink]/page.tsx +++ b/apps/web/src/app/waves/[author]/[permlink]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { WaveViewDetails, WaveViewDiscussion } from "@/app/waves/[author]/[permlink]/_components"; import { WaveEntry } from "@/entities"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getQueryClient, prefetchQuery } from "@/core/react-query"; +import { getQueryClient, prefetchRequired } from "@/core/react-query"; import { cookies } from "next/headers"; import { ACTIVE_USER_COOKIE_NAME } from "@/consts"; import { stripAnonEntryCacheInPlace } from "@/core/react-query/strip-active-votes"; @@ -43,16 +43,16 @@ export default async function WaveViewPage({ params }: Props) { // Beside the entry fetch, not after it: the entry comes from the chain and // carries no image-check verdict, so a wave opened by its link would render // its media uncovered. Resolves to false when the API cannot answer. + // A missing wave is notFound(); a lookup that failed throws a retryable + // lookup-unavailable error instead (prefetchRequired). const [fetched, nsfwFlagged] = await Promise.all([ - prefetchQuery(EcencyEntriesCacheManagement.getEntryQueryByPath(authorName, permlink)) as Promise, + prefetchRequired( + EcencyEntriesCacheManagement.getEntryQueryByPath(authorName, permlink) + ) as Promise, isWaveFlaggedNsfw(authorName, permlink) ]); const data = stripAnonEntryCacheInPlace(getQueryClient(), fetched, loggedInUser); - if (!data) { - return notFound(); - } - return (
diff --git a/apps/web/src/core/react-query/query-helpers.ts b/apps/web/src/core/react-query/query-helpers.ts index c6841ff678..7079768d0b 100644 --- a/apps/web/src/core/react-query/query-helpers.ts +++ b/apps/web/src/core/react-query/query-helpers.ts @@ -13,6 +13,7 @@ import type { QueryClient, QueryKey } from "@tanstack/query-core"; +import { notFound } from "next/navigation"; import { EcencyConfigManager } from "@/config"; import { isHiveNotFoundError } from "@/utils/hive-not-found-error"; @@ -30,12 +31,15 @@ const SSR_PREFETCH_TIMEOUT_MS = 10_000; * the page needs (`fallback-incomplete`). */ export function markSsrDegraded( - reason: "prefetch-timeout" | "prefetch-error" | "fallback-incomplete" + reason: "prefetch-timeout" | "prefetch-error" | "fallback-incomplete" | "lookup-unavailable", + detail?: string ) { if (!isServer) return; - ( - globalThis as { __ecencySsrDegraded?: { mark(reason: string): void } } - ).__ecencySsrDegraded?.mark(reason); + const degraded = ( + globalThis as { __ecencySsrDegraded?: { mark(reason: string, detail?: string): void } } + ).__ecencySsrDegraded; + if (detail === undefined) degraded?.mark(reason); + else degraded?.mark(reason, detail); } /** @@ -134,6 +138,71 @@ export async function prefetchQuery< return qc.getQueryData(options.queryKey); } +/** + * A record a page needs could not be read: the lookup errored or timed out. + * Distinct from "the record does not exist", which is notFound(). Thrown on + * the server, Next answers 500, which ssr-degraded.js sends as a retryable + * 503 (no-store). Matched by name, not instanceof, wherever it may cross a + * module or serialization boundary (Sentry events carry only the name). + */ +export class SsrLookupUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "SsrLookupUnavailableError"; + } +} + +function describeLookupFailure(error: unknown): string { + if (error instanceof Error) { + // The SDK's node errors name the node that answered last: that is what + // tells a throttling upstream apart from a general outage. + const node = (error as { node?: unknown }).node; + const from = typeof node === "string" && node ? ` (${node})` : ""; + return `${error.name}: ${error.message}${from}`; + } + return error === undefined ? "timed out" : String(error); +} + +/** + * Prefetch a record the page cannot render without (an account, a community) + * and tell its three outcomes apart: + * + * - found: returns it; + * - the lookup succeeded and found nothing (`null`, or a node's not-found + * assert): notFound(), a real 404; + * - the lookup failed or timed out: marks the response `lookup-unavailable` + * and throws SsrLookupUnavailableError. A failure is not an absence, and a + * 404 for a live account tells readers and crawlers it is gone. + * + * Same React Query fetch as prefetchQuery, so a layout and a page asking for + * the same key during one request still share a single upstream call. + */ +export async function prefetchRequired( + options: FetchQueryOptions +): Promise> { + const qc = getQueryClient(); + // This helper marks the outcome itself: a not-found answer is not degraded, + // a failure is marked once, under its own reason. + await withSsrTimeout(qc.prefetchQuery(options), options.queryKey, false); + const state = qc.getQueryState(options.queryKey); + + // Data first, whatever the status: a record read earlier and refetched into + // an error keeps its data (status "error"), and is still the record. + if (state?.data != null) return state.data as NonNullable; + if (state?.status === "success") notFound(); + if (state?.status === "error" && isHiveNotFoundError(state.error)) { + notFound(); + } + + // Anything else is a lookup that did not answer: an error, or a timeout that + // cancelled the query back to pending. + const detail = describeLookupFailure(state?.status === "error" ? state.error : undefined); + markSsrDegraded("lookup-unavailable", detail); + throw new SsrLookupUnavailableError( + `${String(options.queryKey[0])} lookup unavailable: ${detail}` + ); +} + /** * Prefetch an infinite query on the server and return cached data. * Replaces the old `.prefetch()` method for infinite queries. diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index f0075cbc1c..e64316f56c 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -3536,6 +3536,11 @@ "global-error": { "description": "Please, try again or report an issue..." }, + "lookup-unavailable": { + "title": "This page could not be loaded", + "description": "The data it needs did not arrive in time. It is usually back in a moment.", + "retry": "Try again" + }, "issue-reporter": { "report": "Report", "report-issue": "Report an issue", diff --git a/apps/web/src/features/shared/lookup-unavailable/index.ts b/apps/web/src/features/shared/lookup-unavailable/index.ts new file mode 100644 index 0000000000..2fc2e7f2dd --- /dev/null +++ b/apps/web/src/features/shared/lookup-unavailable/index.ts @@ -0,0 +1 @@ +export * from "./lookup-unavailable-error"; diff --git a/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx b/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx new file mode 100644 index 0000000000..ccece45075 --- /dev/null +++ b/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { startTransition, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import i18next from "i18next"; +// The lazy facade, never @sentry/nextjs: this module ships with every profile +// and community page, and a static SDK import would put it back on their eager +// bundle (see lazy-sentry.ts). +import { sentry } from "@/core/sentry/lazy-sentry"; +import { Button } from "@ui/button"; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +/** + * Error module body for the profile, community and wave segments (#2000). + * + * Those pages throw when their account, community or post lookup fails + * upstream, so a failure is not answered as "does not exist". Where it renders: + * + * - Routes with no Suspense above the page (profile index and feed tabs, + * community pages, the wave page): a full page load never reaches it. The + * throw is a shell error, Next answers 500 and the server sends a retryable + * 503 (React's server renderer does not run error boundaries, see + * global-error.tsx). + * - Profile sub-pages with their own loading.tsx (followers, following, ...): + * the shell has already streamed with status 200 (no-store, the layout's + * lookup marks it), React client-renders the failed boundary and this shows. + * - Any CLIENT-side navigation: the error arrives in the RSC payload. + * + * Either way it keeps the layout (profile card, menu, community header) around + * a retry instead of the full-page global 500. + * + * A server-render error carries a `digest` and was already seen server-side, + * so only an error thrown by the client render is reported from here. + */ +export function LookupUnavailableError({ error, reset }: Props) { + const router = useRouter(); + + useEffect(() => { + if (!error.digest) sentry.captureException(error); + }, [error]); + + const retry = () => { + // Refetch the server components, then re-render the segment with them. + startTransition(() => { + router.refresh(); + reset(); + }); + }; + + return ( +
+

{i18next.t("lookup-unavailable.title")}

+

+ {i18next.t("lookup-unavailable.description")} +

+ +
+ ); +} diff --git a/apps/web/src/specs/app/lookup-unavailable-error-boundary.spec.tsx b/apps/web/src/specs/app/lookup-unavailable-error-boundary.spec.tsx new file mode 100644 index 0000000000..e8c61e214a --- /dev/null +++ b/apps/web/src/specs/app/lookup-unavailable-error-boundary.spec.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +// Issue #2000: a failed record lookup on the profile and community routes now +// throws instead of answering notFound(). A full page load is answered with a +// retryable 503 by the server; a CLIENT-side navigation receives the error in +// the RSC payload instead. Without a segment error module it would reach +// global-error (a full-page "500" with no retry, reported to Sentry from the +// browser). The segment boundary keeps the page chrome and offers a retry. + +const { refresh, captureException } = vi.hoisted(() => ({ + refresh: vi.fn(), + captureException: vi.fn() +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh, push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }) +})); +vi.mock("@/core/sentry/lazy-sentry", () => ({ sentry: { captureException } })); + +import { LookupUnavailableError } from "@/features/shared/lookup-unavailable"; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("LookupUnavailableError (profile and community segment error boundary)", () => { + it("offers a retry that refreshes the route and resets the boundary", () => { + const reset = vi.fn(); + const error = Object.assign(new Error("server render failed"), { digest: "123" }); + render(); + + expect(screen.getByText("lookup-unavailable.title")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "lookup-unavailable.retry" })); + expect(refresh).toHaveBeenCalledTimes(1); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("does not report an error that came from the server render", () => { + const error = Object.assign(new Error("server render failed"), { digest: "123" }); + render(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("reports an error thrown by the client render", () => { + const error = new Error("client render failed"); + render(); + expect(captureException).toHaveBeenCalledWith(error); + }); + + it.each([ + "(dynamicPages)/profile/[username]", + "(dynamicPages)/community/[community]", + "waves/[author]/[permlink]" + ])( + "is the error module of the %s segment", + (segment) => { + const file = join(process.cwd(), "src/app", segment, "error.tsx"); + expect(existsSync(file)).toBe(true); + const source = readFileSync(file, "utf8"); + expect(source).toMatch(/^"use client";/); + expect(source).toContain("@/features/shared/lookup-unavailable"); + } + ); + + // The module ships with every profile and community page: the Sentry SDK + // must stay off their eager bundle. + it("reaches Sentry only through the lazy facade", () => { + const source = readFileSync( + join(process.cwd(), "src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx"), + "utf8" + ); + expect(source).not.toMatch(/from "@sentry\//); + expect(source).toContain("@/core/sentry/lazy-sentry"); + }); +}); diff --git a/apps/web/src/specs/app/profile-community-lookup-unavailable.spec.ts b/apps/web/src/specs/app/profile-community-lookup-unavailable.spec.ts new file mode 100644 index 0000000000..670a2e5c54 --- /dev/null +++ b/apps/web/src/specs/app/profile-community-lookup-unavailable.spec.ts @@ -0,0 +1,178 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Issue #2000: every profile and community page needs its account or community +// record to render. A lookup that FAILED (RPC error, rate limit, timeout) must +// not be answered as "does not exist": the page throws a lookup-unavailable +// error, which goes out as a retryable 503, instead of calling notFound(). +// A lookup that SUCCEEDED and found nothing still answers notFound(). +// +// The pages run through the real prefetchRequired and a real query client +// (node environment: server retry is off, as in production). Only the SDK +// query builders are driven per test. + +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: () => undefined }), + headers: async () => new Headers({ host: "ecency.com" }) +})); + +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + redirect: (url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }, + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/", + useParams: () => ({}) +})); + +vi.mock("next/dynamic", () => ({ default: () => () => null })); +vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() })); +vi.mock("@/features/i18n", () => ({ initI18next: async () => undefined })); + +vi.mock("@/utils", async () => ({ + ...(await vi.importActual("@/utils")), + random: vi.fn(), + getAccessToken: vi.fn(() => "mock-token") +})); + +// The first page import compiles the whole route tree. +vi.setConfig({ testTimeout: 60_000 }); + +import { getAccountFullQueryOptions, getCommunityQueryOptions } from "@ecency/sdk"; +import { SsrLookupUnavailableError } from "@/core/react-query"; + +type Outcome = "fails" | "missing" | "not-found-assert"; + +function lookup(outcome: Outcome) { + return async () => { + if (outcome === "fails") throw new Error("HTTP 429 rate limited"); + if (outcome === "not-found-assert") { + throw new Error("Assert Exception:given community name is not valid"); + } + return null; + }; +} + +let accountOutcome: Outcome = "fails"; +let communityOutcome: Outcome = "fails"; + +beforeEach(() => { + vi.mocked(getAccountFullQueryOptions).mockImplementation( + (username?: string) => + ({ queryKey: ["accounts", "full", username], queryFn: lookup(accountOutcome) }) as any + ); + vi.mocked(getCommunityQueryOptions).mockImplementation( + (name?: string) => + ({ queryKey: ["communities", "single", name], queryFn: lookup(communityOutcome) }) as any + ); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +const user = { username: "alice" }; +const none = Promise.resolve({}); + +// Each entry renders one route branch; `before` selects the archive branch. +const PROFILE_PAGES: Array<[string, () => Promise, Record]> = [ + ["/@alice", () => import("@/app/(dynamicPages)/profile/[username]/page"), {}], + ["/@alice/posts", () => import("@/app/(dynamicPages)/profile/[username]/[section]/page"), { section: "posts" }], + ["/@alice/posts?before=", () => import("@/app/(dynamicPages)/profile/[username]/[section]/page"), { section: "posts", before: "alice/older" }], + ["/@alice/followers", () => import("@/app/(dynamicPages)/profile/[username]/followers/page"), {}], + ["/@alice/following", () => import("@/app/(dynamicPages)/profile/[username]/following/page"), {}], + ["/@alice/trail", () => import("@/app/(dynamicPages)/profile/[username]/trail/page"), {}], + ["/@alice/communities", () => import("@/app/(dynamicPages)/profile/[username]/communities/page"), {}], + ["/@alice/permissions", () => import("@/app/(dynamicPages)/profile/[username]/permissions/page"), {}], + ["/@alice/settings", () => import("@/app/(dynamicPages)/profile/[username]/settings/page"), {}], + ["/@alice/referrals", () => import("@/app/(dynamicPages)/profile/[username]/referrals/page"), {}], + ["/@alice/insights", () => import("@/app/(dynamicPages)/profile/[username]/insights/page"), {}] +]; + +const COMMUNITY_PAGES: Array<[string, () => Promise, Record]> = [ + ["/created/hive-125125", () => import("@/app/(dynamicPages)/community/[community]/page"), {}], + ["/trending/hive-125125", () => import("@/app/(dynamicPages)/community/[community]/[tag]/page"), { tag: "trending" }], + ["/trending/hive-125125?before=", () => import("@/app/(dynamicPages)/community/[community]/[tag]/page"), { tag: "trending", before: "alice/older" }], + ["/hive-125125/activities", () => import("@/app/(dynamicPages)/community/[community]/activities/page"), {}], + ["/hive-125125/subscribers", () => import("@/app/(dynamicPages)/community/[community]/subscribers/page"), {}], + ["/hive-125125/roles", () => import("@/app/(dynamicPages)/community/[community]/roles/page"), {}] +]; + +function render(load: () => Promise, params: Record, base: Record) { + const { before, ...rest } = params; + return load().then((mod) => + mod.default({ + params: Promise.resolve({ ...base, ...rest }), + searchParams: before ? Promise.resolve({ before }) : none + }) + ); +} + +describe("profile pages when the account lookup fails or finds nothing", () => { + it.each(PROFILE_PAGES)("%s throws lookup-unavailable when the lookup failed", async (_, load, params) => { + accountOutcome = "fails"; + await expect(render(load, params, user)).rejects.toBeInstanceOf(SsrLookupUnavailableError); + }); + + it.each(PROFILE_PAGES)("%s answers not found when the account does not exist", async (_, load, params) => { + accountOutcome = "missing"; + await expect(render(load, params, user)).rejects.toThrow("NEXT_NOT_FOUND"); + }); +}); + +describe("community pages when the community lookup fails or finds nothing", () => { + const base = { community: "hive-125125" }; + + it.each(COMMUNITY_PAGES)("%s throws lookup-unavailable when the lookup failed", async (_, load, params) => { + communityOutcome = "fails"; + await expect(render(load, params, base)).rejects.toBeInstanceOf(SsrLookupUnavailableError); + }); + + it.each(COMMUNITY_PAGES)("%s answers not found when a node asserts it does not exist", async (_, load, params) => { + communityOutcome = "not-found-assert"; + await expect(render(load, params, base)).rejects.toThrow("NEXT_NOT_FOUND"); + }); +}); + +// Wave posts and replies (`wave-*` permlinks redirect here from the post +// route) had the same pattern: a failed post lookup answered notFound(). +describe("the wave page when the post lookup fails or finds nothing", () => { + const params = { author: "alice", permlink: "wave-abc" }; + const load = () => import("@/app/waves/[author]/[permlink]/page"); + + let postLookup: () => Promise; + beforeEach(async () => { + const { EcencyEntriesCacheManagement } = await import("@/core/caches"); + vi.spyOn(EcencyEntriesCacheManagement, "getEntryQueryByPath").mockImplementation( + (author?: string, permlink?: string) => + ({ queryKey: ["entry", author, permlink], queryFn: () => postLookup() }) as any + ); + }); + + it("throws lookup-unavailable when the lookup failed", async () => { + postLookup = async () => { + throw new Error("HTTP 429 rate limited"); + }; + await expect(render(load, {}, params)).rejects.toBeInstanceOf(SsrLookupUnavailableError); + }); + + it("answers not found for an author no account can have", async () => { + postLookup = async () => { + throw new Error("Assert Exception:invalid account char"); + }; + await expect(render(load, {}, { author: "Alice", permlink: "wave-abc" })).rejects.toThrow( + "NEXT_NOT_FOUND" + ); + }); + + it("answers not found when a node asserts the post does not exist", async () => { + postLookup = async () => { + throw new Error("Assert Exception:Post alice/wave-abc does not exist"); + }; + await expect(render(load, {}, params)).rejects.toThrow("NEXT_NOT_FOUND"); + }); +}); diff --git a/apps/web/src/specs/app/profile-page-ssr-stream.spec.tsx b/apps/web/src/specs/app/profile-page-ssr-stream.spec.tsx index 0c8a28ee09..c313b5f3ce 100644 --- a/apps/web/src/specs/app/profile-page-ssr-stream.spec.tsx +++ b/apps/web/src/specs/app/profile-page-ssr-stream.spec.tsx @@ -113,15 +113,21 @@ vi.mock("@/core/react-query", async () => { ] }; + const prefetchFixture = async (options: { queryKey: readonly unknown[] }) => { + const data = JSON.stringify(options.queryKey).includes("account") + ? fixtures.account + : fixtures.pinnedEntry; + actual.getQueryClient().setQueryData(options.queryKey, data); + return data; + }; + + // prefetchRequired is the account lookup (found here, so it returns the + // record); its failure and not-found paths are pinned in + // profile-community-lookup-unavailable.spec.ts. return { ...actual, - prefetchQuery: async (options: { queryKey: readonly unknown[] }) => { - const data = JSON.stringify(options.queryKey).includes("account") - ? fixtures.account - : fixtures.pinnedEntry; - actual.getQueryClient().setQueryData(options.queryKey, data); - return data; - } + prefetchQuery: prefetchFixture, + prefetchRequired: prefetchFixture }; }); diff --git a/apps/web/src/specs/app/profile-section-page-ssr-stream.spec.tsx b/apps/web/src/specs/app/profile-section-page-ssr-stream.spec.tsx index 47aa98cc50..0e07dbebb4 100644 --- a/apps/web/src/specs/app/profile-section-page-ssr-stream.spec.tsx +++ b/apps/web/src/specs/app/profile-section-page-ssr-stream.spec.tsx @@ -141,15 +141,21 @@ vi.mock("@/core/react-query", async () => { }) ); + const prefetchFixture = async (options: { queryKey: readonly unknown[] }) => { + const data = JSON.stringify(options.queryKey).includes("account") + ? fixtures.account + : fixtures.pinnedEntry; + actual.getQueryClient().setQueryData(options.queryKey, data); + return data; + }; + + // prefetchRequired is the account lookup (found here, so it returns the + // record); its failure and not-found paths are pinned in + // profile-community-lookup-unavailable.spec.ts. return { ...actual, - prefetchQuery: async (options: { queryKey: readonly unknown[] }) => { - const data = JSON.stringify(options.queryKey).includes("account") - ? fixtures.account - : fixtures.pinnedEntry; - actual.getQueryClient().setQueryData(options.queryKey, data); - return data; - } + prefetchQuery: prefetchFixture, + prefetchRequired: prefetchFixture }; }); diff --git a/apps/web/src/specs/app/wave-page-ssr-stream.spec.tsx b/apps/web/src/specs/app/wave-page-ssr-stream.spec.tsx index 11de8087a7..678377a8fa 100644 --- a/apps/web/src/specs/app/wave-page-ssr-stream.spec.tsx +++ b/apps/web/src/specs/app/wave-page-ssr-stream.spec.tsx @@ -87,12 +87,16 @@ vi.mock("@/core/react-query", async () => { body: "Wave body paragraph that has to reach the browser in the shell.\n\n".repeat(20), json_metadata: { tags: ["waves"], app: "ecency/waves" } }); + const prefetchFixture = async (options: { queryKey: readonly unknown[] }) => { + actual.getQueryClient().setQueryData(options.queryKey, fixtures.entry); + return fixtures.entry; + }; + // prefetchRequired is the wave lookup (found here); its failure and + // not-found paths are pinned in profile-community-lookup-unavailable.spec.ts. return { ...actual, - prefetchQuery: async (options: { queryKey: readonly unknown[] }) => { - actual.getQueryClient().setQueryData(options.queryKey, fixtures.entry); - return fixtures.entry; - } + prefetchQuery: prefetchFixture, + prefetchRequired: prefetchFixture }; }); diff --git a/apps/web/src/specs/core/react-query/prefetch-required.spec.ts b/apps/web/src/specs/core/react-query/prefetch-required.spec.ts new file mode 100644 index 0000000000..042399ecd1 --- /dev/null +++ b/apps/web/src/specs/core/react-query/prefetch-required.spec.ts @@ -0,0 +1,120 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +// Node environment: isServer is true, so the SSR timeout is armed. The query +// client is a real one, so the error, timeout and empty states below are the +// states React Query really leaves behind, not hand-written stand-ins. +vi.mock("../../../core/react-query/index", () => ({ getQueryClient: vi.fn() })); +vi.mock("../../../config", () => ({ EcencyConfigManager: { CONFIG: {} } })); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + } +})); + +import { + prefetchRequired, + SsrLookupUnavailableError +} from "../../../core/react-query/query-helpers"; +import { getQueryClient } from "../../../core/react-query/index"; + +type DegradedGlobal = { __ecencySsrDegraded?: { mark: (reason: string, detail?: string) => void } }; + +const KEY = ["accounts", "full", "alice"]; + +describe("prefetchRequired", () => { + const mark = vi.fn(); + let client: QueryClient; + + beforeEach(() => { + (globalThis as DegradedGlobal).__ecencySsrDegraded = { mark }; + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.mocked(getQueryClient).mockReturnValue(client); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (globalThis as DegradedGlobal).__ecencySsrDegraded; + vi.clearAllMocks(); + }); + + it("returns the record when the lookup succeeded", async () => { + const account = { name: "alice" }; + await expect( + prefetchRequired({ queryKey: KEY, queryFn: async () => account }) + ).resolves.toEqual(account); + expect(mark).not.toHaveBeenCalled(); + }); + + it("answers not found when the lookup succeeded and found nothing", async () => { + await expect(prefetchRequired({ queryKey: KEY, queryFn: async () => null })).rejects.toThrow( + "NEXT_NOT_FOUND" + ); + expect(mark).not.toHaveBeenCalled(); + }); + + it("answers not found when a node asserted the record does not exist", async () => { + const queryFn = async () => { + throw new Error("Assert Exception:given community name is not valid"); + }; + await expect(prefetchRequired({ queryKey: KEY, queryFn })).rejects.toThrow("NEXT_NOT_FOUND"); + expect(mark).not.toHaveBeenCalled(); + }); + + it("throws a lookup-unavailable error, not not-found, when the lookup failed", async () => { + const queryFn = async () => { + throw new Error("HTTP 429 rate limited"); + }; + const result = prefetchRequired({ queryKey: KEY, queryFn }); + await expect(result).rejects.toBeInstanceOf(SsrLookupUnavailableError); + await expect(result).rejects.not.toThrow("NEXT_NOT_FOUND"); + expect(mark).toHaveBeenCalledWith( + "lookup-unavailable", + expect.stringContaining("HTTP 429 rate limited") + ); + }); + + it("returns a record read earlier even when a later refetch errored", async () => { + const account = { name: "alice" }; + await client.prefetchQuery({ queryKey: KEY, queryFn: async () => account }); + await client.prefetchQuery({ + queryKey: KEY, + staleTime: 0, + queryFn: async () => { + throw new Error("HTTP 429 rate limited"); + } + }); + expect(client.getQueryState(KEY)?.status).toBe("error"); + await expect( + prefetchRequired({ queryKey: KEY, queryFn: async () => account }) + ).resolves.toEqual(account); + expect(mark).not.toHaveBeenCalled(); + }); + + it("names the node an SDK node error came from in the marked detail", async () => { + const queryFn = async () => { + throw Object.assign(new Error("HTTP 429 Rate Limited"), { node: "https://node.example" }); + }; + await expect(prefetchRequired({ queryKey: KEY, queryFn })).rejects.toBeInstanceOf( + SsrLookupUnavailableError + ); + expect(mark).toHaveBeenCalledWith( + "lookup-unavailable", + "Error: HTTP 429 Rate Limited (https://node.example)" + ); + }); + + it("throws a lookup-unavailable error when the lookup timed out", async () => { + vi.useFakeTimers(); + const result = prefetchRequired({ queryKey: KEY, queryFn: () => new Promise(() => {}) }); + const settled = expect(result).rejects.toBeInstanceOf(SsrLookupUnavailableError); + await vi.advanceTimersByTimeAsync(10_000); + await settled; + expect(mark).toHaveBeenCalledWith("lookup-unavailable", expect.stringContaining("timed out")); + }); + + it("keeps the lookup-unavailable error recognisable by name across module copies", () => { + expect(new SsrLookupUnavailableError("x").name).toBe("SsrLookupUnavailableError"); + }); +}); diff --git a/apps/web/src/specs/setup-any-spec.ts b/apps/web/src/specs/setup-any-spec.ts index 6021de7986..75034eb47d 100644 --- a/apps/web/src/specs/setup-any-spec.ts +++ b/apps/web/src/specs/setup-any-spec.ts @@ -92,6 +92,28 @@ vi.mock("@ecency/sdk", async () => ({ }, getBookmarksQueryOptions: vi.fn(), getAccountFullQueryOptions: vi.fn(() => ({ queryKey: ["account"], queryFn: vi.fn() })), + // Profile section feeds (page 1 and the cursor archive): empty by default. + getAccountPostsQueryOptions: vi.fn((...key: unknown[]) => ({ + queryKey: ["account-posts", ...key], + queryFn: vi.fn(async () => []) + })), + getAccountPostsInfiniteQueryOptions: vi.fn((...key: unknown[]) => ({ + queryKey: ["account-posts-infinite", ...key], + queryFn: vi.fn(async () => []), + initialPageParam: undefined, + getNextPageParam: () => undefined + })), + // Ranked tag/community feeds (page 1 and the cursor archive): empty by default. + getPostsRankedQueryOptions: vi.fn((...key: unknown[]) => ({ + queryKey: ["posts-ranked", ...key], + queryFn: vi.fn(async () => []) + })), + getPostsRankedInfiniteQueryOptions: vi.fn((...key: unknown[]) => ({ + queryKey: ["posts-ranked-infinite", ...key], + queryFn: vi.fn(async () => []), + initialPageParam: undefined, + getNextPageParam: () => undefined + })), getAccountDelegationsQueryOptions: vi.fn((username) => ({ queryKey: ["assets", "account-delegations", username], queryFn: vi.fn() diff --git a/apps/web/src/specs/ssr-degraded.spec.ts b/apps/web/src/specs/ssr-degraded.spec.ts index edbeba3938..ef37f5e876 100644 --- a/apps/web/src/specs/ssr-degraded.spec.ts +++ b/apps/web/src/specs/ssr-degraded.spec.ts @@ -48,13 +48,29 @@ const CHILD_SERVER = ` const marked = markAt === null ? Promise.resolve() : new Promise((done) => { setTimeout(() => { Promise.resolve().then(() => setImmediate(() => { - globalThis.__ecencySsrDegraded.mark(url.searchParams.get("reason") || "prefetch-timeout"); + globalThis.__ecencySsrDegraded.mark( + url.searchParams.get("reason") || "prefetch-timeout", + url.searchParams.get("detail") || undefined + ); done(); })); }, Number(markAt)); }); await marked; await wait(Number(url.searchParams.get("end") || 0)); + // ?failed=500 sets the status after the mark, as Next does once the page + // has thrown; ?head=500 writes it with an explicit writeHead(status). + if (url.searchParams.get("failed")) res.statusCode = Number(url.searchParams.get("failed")); + // ?headobj=1 passes the route's own Cache-Control in writeHead's headers + // object, which Node applies over anything set with setHeader. + if (url.searchParams.get("head")) { + const status = Number(url.searchParams.get("head")); + if (url.searchParams.get("headobj")) { + res.writeHead(status, { "Cache-Control": "public, s-maxage=300", "Retry-After": "999" }); + } else { + res.writeHead(status); + } + } res.end("body"); }); server.listen(0, "127.0.0.1", () => process.stdout.write(String(server.address().port) + "\\n")); @@ -168,6 +184,60 @@ describe("ssr-degraded preload", () => { expect(await state(port)).toEqual({ sent: { "prefetch-error": 1 }, late: {}, abandoned: {} }); }); + // Issue #2000: a record lookup that failed throws, and Next answers 500. + // Sent as a 503 with Retry-After it reads as temporary to crawlers and lets + // stale-serving caches and retrying proxies in front of the app step in. + it("sends a failed record lookup that ended in a server error as a retryable 503", async () => { + const { port } = await boot(); + const reply = await get(port, "/@alice/posts?mark=10&reason=lookup-unavailable&failed=500"); + expect(reply.status).toBe(503); + expect(reply.headers["retry-after"]).toBe("30"); + expect(reply.headers["cache-control"]).toBe("private, no-store"); + expect(reply.headers["x-cache-tier"]).toBe("profile-degraded"); + expect(await state(port)).toEqual({ sent: { "lookup-unavailable": 1 }, late: {}, abandoned: {} }); + }); + + it("rewrites an explicit writeHead(500) the same way", async () => { + const { port } = await boot(); + const reply = await get(port, "/@alice?mark=10&reason=lookup-unavailable&head=500"); + expect(reply.status).toBe(503); + expect(reply.headers["retry-after"]).toBe("30"); + }); + + it("keeps a marked response uncacheable when writeHead carries its own Cache-Control", async () => { + const { port } = await boot(); + const failed = await get(port, "/@alice?mark=10&reason=lookup-unavailable&head=500&headobj=1"); + expect(failed.status).toBe(503); + expect(failed.headers["cache-control"]).toBe("private, no-store"); + expect(failed.headers["retry-after"]).toBe("30"); + const degraded = await get(port, "/@alice?mark=10&reason=prefetch-error&head=200&headobj=1"); + expect(degraded.status).toBe(200); + expect(degraded.headers["cache-control"]).toBe("private, no-store"); + }); + + it("leaves a marked lookup that answered 404 or 200 at its own status", async () => { + const { port } = await boot(); + const notFound = await get(port, "/@gone?mark=10&reason=lookup-unavailable&failed=404"); + expect(notFound.status).toBe(404); + expect(notFound.headers["retry-after"]).toBeUndefined(); + const ok = await get(port, "/@alice?mark=10&reason=lookup-unavailable"); + expect(ok.status).toBe(200); + expect(ok.headers["cache-control"]).toBe("private, no-store"); + }); + + it("does not turn a 500 from any other degraded reason into a 503", async () => { + const { port } = await boot(); + const reply = await get(port, "/@alice?mark=10&reason=prefetch-error&failed=500"); + expect(reply.status).toBe(500); + expect(reply.headers["retry-after"]).toBeUndefined(); + }); + + it("still sends a 503 when another reason marked the response first", async () => { + const { port } = await boot(); + const reply = await get(port, "/@alice?premark=1&mark=10&reason=lookup-unavailable&failed=500"); + expect(reply.status).toBe(503); + }); + it("does not count a marked response whose client left before any head as sent", async () => { const { port } = await boot(); await new Promise((resolve) => { @@ -222,6 +292,25 @@ describe("ssr-degraded preload", () => { ).toHaveLength(1); }); + // Issue #2000: without the error, the line says a lookup failed but not why. + it("adds a short sample of the distinct errors the window saw", async () => { + const { port, stderr } = await boot(); + const long = "x".repeat(300); + await get(port, "/@a?mark=10&reason=lookup-unavailable&failed=500&detail=HTTP%20429%20rate%20limited"); + await get(port, "/@b?mark=10&reason=lookup-unavailable&failed=500&detail=HTTP%20429%20rate%20limited"); + await get(port, `/@c?mark=10&reason=lookup-unavailable&failed=500&detail=${long}`); + await get(port, "/@d?mark=10&reason=lookup-unavailable&failed=500&detail=timed%20out"); + await get(port, "/@e?mark=10&reason=lookup-unavailable&failed=500&detail=fourth"); + await get(port, "/api/flush"); + await new Promise((r) => setTimeout(r, 50)); + const lines = stderr() + .split("\n") + .filter((l) => l.startsWith("[ssr-degraded]")); + expect(lines).toEqual([ + `[ssr-degraded] last 60s: sent lookup-unavailable=5 [/@a /@b /@c /@d /@e]; errors [HTTP 429 rate limited | ${"x".repeat(120)} | timed out]` + ]); + }); + it("is a no-op outside any request", async () => { const { port } = await boot(); // The child calls mark() at startup and from a module-level timer, with diff --git a/apps/web/src/specs/utils/hive-not-found-error.spec.ts b/apps/web/src/specs/utils/hive-not-found-error.spec.ts index 98d18a9256..501bd31615 100644 --- a/apps/web/src/specs/utils/hive-not-found-error.spec.ts +++ b/apps/web/src/specs/utils/hive-not-found-error.spec.ts @@ -8,7 +8,12 @@ const NOT_FOUND = [ 'Assert Exception:[{"nosuchaccount": "account does not exist"}]', "Assert Exception:Tag nosuchtag does not exist", "Assert Exception:invalid tag `Foo`", - "Assert Exception:given community name is not valid" + "Assert Exception:given community name is not valid", + // An account name longer than any account can be (#2000): no such account. + "Assert Exception:in_len <= sizeof(data): Input too large: `averyveryverylongname` (21) for fixed size string: (16)", + // Hivemind's answer for an author no account can have (bridge.get_post). + "Assert Exception:invalid account char", + "Assert Exception:invalid account name length: `averyveryverylongname`" ]; const FAILURES = [ diff --git a/apps/web/src/specs/utils/sentry-config-wiring.spec.ts b/apps/web/src/specs/utils/sentry-config-wiring.spec.ts index 3f420809e0..d9a7154495 100644 --- a/apps/web/src/specs/utils/sentry-config-wiring.spec.ts +++ b/apps/web/src/specs/utils/sentry-config-wiring.spec.ts @@ -59,4 +59,18 @@ describe("Sentry config wiring to the URL scrubber", () => { expect(tx.spans[0].data["url.full"]).toBe(SCRUBBED); expect(crumbUrl(opts)).toBe(SCRUBBED); }); + + // Issue #2000: a record lookup that failed upstream is answered with a + // retryable 503 and counted in the ssr-degraded log line. It is an expected + // upstream condition, not a code defect, so it must not reach Sentry. + it("server: drops a lookup-unavailable error and keeps every other error", async () => { + await import("../../../sentry.server.config"); + const opts = init.mock.calls[0][0] as Hooks; + const lookup = { + exception: { values: [{ type: "SsrLookupUnavailableError", value: "account lookup failed" }] } + }; + expect(opts.beforeSend!(lookup)).toBeNull(); + const other = { exception: { values: [{ type: "TypeError", value: "x is undefined" }] } }; + expect(opts.beforeSend!(other)).not.toBeNull(); + }); }); diff --git a/apps/web/src/utils/hive-not-found-error.ts b/apps/web/src/utils/hive-not-found-error.ts index 51c479da58..242a399f2b 100644 --- a/apps/web/src/utils/hive-not-found-error.ts +++ b/apps/web/src/utils/hive-not-found-error.ts @@ -7,10 +7,16 @@ // Assert Exception:Tag t does not exist (bridge.get_ranked_posts) // Assert Exception:invalid tag `Foo` (a tag that breaks the format rules) // Assert Exception:given community name is not valid (bridge.get_community) +// Assert Exception:in_len <= sizeof(data): Input too large: `name` (21) for fixed size string: (16) +// (an account name longer than any account can be) +// Assert Exception:invalid account char (bridge.get_post, an author no account can have) +// Assert Exception:invalid account name length: `a` (same, length) const NOT_FOUND_ASSERTS = [ /Assert Exception:.*\b(Post|Account|Category|Tag)\b.*does not exist/i, /Assert Exception:\s*invalid (tag|category)/i, - /Assert Exception:\s*given community name is not valid/i + /Assert Exception:\s*given community name is not valid/i, + /Assert Exception:\s*in_len <= sizeof\(data\): Input too large/i, + /Assert Exception:\s*invalid account (char|name length)/i ]; export function isHiveNotFoundError(e: unknown): boolean { diff --git a/apps/web/ssr-degraded.js b/apps/web/ssr-degraded.js index 8cb1530699..908133fb84 100644 --- a/apps/web/ssr-degraded.js +++ b/apps/web/ssr-degraded.js @@ -17,8 +17,12 @@ // response goes out as `private, no-store`, with `-degraded` appended to // x-cache-tier. No layer stores it, and an expired good copy stays in place to // be served stale while the next request renders again. The status is left -// alone: a page that answers notFound() after a failed lookup still sends its -// 404, just uncached. A render streamed past its first flush cannot be changed +// alone, with one exception: a page whose required record lookup failed +// (`lookup-unavailable`, prefetchRequired in query-helpers.ts) throws, and +// Next answers 500. That response goes out as a 503 with Retry-After, which +// tells crawlers the page is temporarily unavailable rather than broken, and +// is the status that stale-serving caches and retrying proxies in front of +// the app act on (#2000). A render streamed past its first flush cannot be changed // any more; those are counted as `late`, and their paths name the streamed // routes that still cache a degraded document. // @@ -27,7 +31,8 @@ // Each outcome is counted by reason with its own sample paths: `sent` (head // written as no-store), `late` (head already sent, response unchanged) and // `abandoned` (marked, but the client went away before any head was written, -// so nothing was sent at all). +// so nothing was sent at all). A mark may carry a detail (the failing error); +// the line ends with a few distinct ones, so it says why, not only how often. // // Loaded with `node --require ./ssr-degraded.js` (see the Dockerfile CMD). // Runs before Next, so it must stay dependency-free. @@ -36,7 +41,13 @@ const { AsyncLocalStorage } = require("async_hooks"); const DEGRADED_CACHE_CONTROL = "private, no-store"; const MAX_SAMPLE_PATHS = 5; +const MAX_SAMPLE_DETAILS = 3; +const MAX_DETAIL_LENGTH = 120; const OUTCOMES = ["sent", "late", "abandoned"]; +// A required record could not be read (see the header). Its server error is +// sent as a retryable 503. +const LOOKUP_UNAVAILABLE = "lookup-unavailable"; +const RETRY_AFTER_SECONDS = "30"; const storage = new AsyncLocalStorage(); @@ -49,6 +60,13 @@ function emptyTally() { // Lifetime counts, read by specs; the window is what the next log line reports. const totals = { sent: {}, late: {}, abandoned: {} }; let window = emptyTally(); +// A few distinct mark details (failing errors) for the next log line. +let details = new Set(); + +function sampleDetail(detail) { + if (detail === undefined || detail === null || details.size >= MAX_SAMPLE_DETAILS) return; + details.add(String(detail).replace(/\s+/g, " ").slice(0, MAX_DETAIL_LENGTH)); +} function pathOf(req) { const url = (req && req.url) || "/"; @@ -63,10 +81,14 @@ function record(outcome, reason, req) { if (entry.paths.size < MAX_SAMPLE_PATHS) entry.paths.add(pathOf(req)); } -function mark(reason) { +function mark(reason, detail) { const ctx = storage.getStore(); if (!ctx) return; const why = String(reason || "degraded"); + sampleDetail(detail); + // Kept apart from ctx.reason: an earlier mark under another reason must not + // stop a failed record lookup from going out as a 503. + if (why === LOOKUP_UNAVAILABLE && !ctx.res.headersSent) ctx.unavailable = true; if (ctx.res.headersSent) { // Once per request: the head is gone and nothing can be changed. A request // marked before its head already went out uncacheable, so it is not late. @@ -88,11 +110,37 @@ function flush() { if (counts.length === 0) continue; parts.push(`${outcome} ${counts.join(",")} [${Array.from(paths).join(" ")}]`); } + if (parts.length > 0 && details.size > 0) { + parts.push(`errors [${Array.from(details).join(" | ")}]`); + } window = emptyTally(); + details = new Set(); if (parts.length === 0) return; process.stderr.write(`[ssr-degraded] last 60s: ${parts.join("; ")}\n`); } +// Headers handed to writeHead (an object, or a flat [name, value, ...] array) +// are applied over setHeader values, so a degraded response's own headers are +// removed from them, case-insensitively, for the preload's to stand. +function dropHeaders(args, names) { + const index = args.findIndex((a, i) => i > 0 && a !== null && typeof a === "object"); + if (index === -1) return; + const headers = args[index]; + if (Array.isArray(headers)) { + const kept = []; + for (let i = 0; i + 1 < headers.length; i += 2) { + if (!names.includes(String(headers[i]).toLowerCase())) kept.push(headers[i], headers[i + 1]); + } + args[index] = kept; + return; + } + const kept = {}; + for (const key of Object.keys(headers)) { + if (!names.includes(key.toLowerCase())) kept[key] = headers[key]; + } + args[index] = kept; +} + // Read by specs; the app only calls mark(). globalThis.__ecencySsrDegraded = { mark, totals, flush }; @@ -101,18 +149,37 @@ http.Server.prototype.emit = function emit(event, req, res) { if (event !== "request" || !req || !res) { return originalEmit.apply(this, arguments); } - const ctx = { req, res, reason: null, late: false, closed: false }; + const ctx = { req, res, reason: null, late: false, closed: false, unavailable: false }; // Every path to the wire goes through writeHead: an explicit call, or // _implicitHeader() on the first write()/end()/flushHeaders(). const originalWriteHead = res.writeHead; res.writeHead = function writeHead() { + const args = Array.from(arguments); if (ctx.reason) { this.setHeader("Cache-Control", DEGRADED_CACHE_CONTROL); const tier = this.getHeader("x-cache-tier"); if (tier) this.setHeader("x-cache-tier", `${tier}-degraded`); record("sent", ctx.reason, req); } - return originalWriteHead.apply(this, arguments); + // Only a server error is rewritten: notFound() keeps its 404, a page that + // rendered anyway keeps its 200. + const status = typeof args[0] === "number" ? args[0] : this.statusCode; + if (ctx.unavailable && status >= 500) { + if (typeof args[0] === "number") { + args[0] = 503; + // writeHead(status, statusMessage, headers): drop a stale message. + if (typeof args[1] === "string") args.splice(1, 1); + } else { + this.statusCode = 503; + } + this.statusMessage = "Service Unavailable"; + this.setHeader("Cache-Control", DEGRADED_CACHE_CONTROL); + this.setHeader("Retry-After", RETRY_AFTER_SECONDS); + dropHeaders(args, ["cache-control", "retry-after"]); + } else if (ctx.reason) { + dropHeaders(args, ["cache-control"]); + } + return originalWriteHead.apply(this, args); }; res.once("close", () => { ctx.closed = true; From 15f803c26cc2b2810e80dd94c291c3f809bcf34d Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 20:02:05 +0000 Subject: [PATCH 2/3] fix(web): neutral error copy, specific lookup reason, clean log details - The segment error module catches any error in its routes, so its copy names no cause. - A failed record lookup is counted as lookup-unavailable even when a layout's optional prefetch marked the request first. - Control characters in an upstream error message are removed before the sample reaches the log line. --- apps/web/src/features/i18n/locales/en-US.json | 2 +- .../lookup-unavailable-error.tsx | 4 ++++ apps/web/src/specs/ssr-degraded.spec.ts | 22 +++++++++++++++++++ apps/web/ssr-degraded.js | 19 ++++++++++++---- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index e64316f56c..a1cfbb50b2 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -3538,7 +3538,7 @@ }, "lookup-unavailable": { "title": "This page could not be loaded", - "description": "The data it needs did not arrive in time. It is usually back in a moment.", + "description": "Something went wrong while loading it. Please try again. If it keeps happening, let us know.", "retry": "Try again" }, "issue-reporter": { diff --git a/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx b/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx index ccece45075..9a48746922 100644 --- a/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx +++ b/apps/web/src/features/shared/lookup-unavailable/lookup-unavailable-error.tsx @@ -33,6 +33,10 @@ interface Props { * Either way it keeps the layout (profile card, menu, community header) around * a retry instead of the full-page global 500. * + * As a segment error module it also catches any other error in these routes, + * a real bug included, and in production a server error reaches the client + * with its message hidden. So the copy names no cause. + * * A server-render error carries a `digest` and was already seen server-side, * so only an error thrown by the client render is reported from here. */ diff --git a/apps/web/src/specs/ssr-degraded.spec.ts b/apps/web/src/specs/ssr-degraded.spec.ts index ef37f5e876..ee9bc22615 100644 --- a/apps/web/src/specs/ssr-degraded.spec.ts +++ b/apps/web/src/specs/ssr-degraded.spec.ts @@ -238,6 +238,28 @@ describe("ssr-degraded preload", () => { expect(reply.status).toBe(503); }); + // A page's layout marks a failed optional prefetch first; the required + // lookup's failure is the more specific reason, so the count says that. + it("counts the response under lookup-unavailable when it was marked after another reason", async () => { + const { port } = await boot(); + await get(port, "/@alice?premark=1&mark=10&reason=lookup-unavailable&failed=500"); + expect(await state(port)).toEqual({ sent: { "lookup-unavailable": 1 }, late: {}, abandoned: {} }); + }); + + it("keeps terminal control characters from an upstream error out of the log line", async () => { + const { port, stderr } = await boot(); + const detail = encodeURIComponent("bad\u001b[31m red\u0007 node\u009b"); + await get(port, `/@a?mark=10&reason=lookup-unavailable&failed=500&detail=${detail}`); + await get(port, "/api/flush"); + await new Promise((r) => setTimeout(r, 50)); + const line = stderr() + .split("\n") + .find((l) => l.startsWith("[ssr-degraded]")); + expect(line).toBeDefined(); + expect(line).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); + expect(line).toContain("errors [bad [31m red node]"); + }); + it("does not count a marked response whose client left before any head as sent", async () => { const { port } = await boot(); await new Promise((resolve) => { diff --git a/apps/web/ssr-degraded.js b/apps/web/ssr-degraded.js index 908133fb84..4ceaaebf60 100644 --- a/apps/web/ssr-degraded.js +++ b/apps/web/ssr-degraded.js @@ -65,7 +65,14 @@ let details = new Set(); function sampleDetail(detail) { if (detail === undefined || detail === null || details.size >= MAX_SAMPLE_DETAILS) return; - details.add(String(detail).replace(/\s+/g, " ").slice(0, MAX_DETAIL_LENGTH)); + // The detail is upstream text (an RPC node's error message): control + // characters, terminal escapes included, never reach the log. + const clean = String(detail) + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + .replace(/\s+/g, " ") + .trim(); + details.add(clean.slice(0, MAX_DETAIL_LENGTH)); } function pathOf(req) { @@ -86,9 +93,13 @@ function mark(reason, detail) { if (!ctx) return; const why = String(reason || "degraded"); sampleDetail(detail); - // Kept apart from ctx.reason: an earlier mark under another reason must not - // stop a failed record lookup from going out as a 503. - if (why === LOOKUP_UNAVAILABLE && !ctx.res.headersSent) ctx.unavailable = true; + // A failed record lookup is the more specific reason: it must go out as a + // 503 and be counted as itself even when an optional prefetch (a layout's) + // marked the request first. + if (why === LOOKUP_UNAVAILABLE && !ctx.res.headersSent) { + ctx.unavailable = true; + if (ctx.reason && !ctx.closed) ctx.reason = why; + } if (ctx.res.headersSent) { // Once per request: the head is gone and nothing can be changed. A request // marked before its head already went out uncacheable, so it is not late. From 2465b3fde8a2289e6437dcc04df05b264dd04012 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 30 Oct 2026 20:07:25 +0000 Subject: [PATCH 3/3] fix(web): skip empty log samples and cut them by code point --- apps/web/src/specs/ssr-degraded.spec.ts | 12 ++++++++++++ apps/web/ssr-degraded.js | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/specs/ssr-degraded.spec.ts b/apps/web/src/specs/ssr-degraded.spec.ts index ee9bc22615..5b18aaa37b 100644 --- a/apps/web/src/specs/ssr-degraded.spec.ts +++ b/apps/web/src/specs/ssr-degraded.spec.ts @@ -260,6 +260,18 @@ describe("ssr-degraded preload", () => { expect(line).toContain("errors [bad [31m red node]"); }); + it("skips a detail that is nothing but control characters, keeping its slot", async () => { + const { port, stderr } = await boot(); + await get(port, `/@a?mark=10&reason=lookup-unavailable&failed=500&detail=${encodeURIComponent("\u0007\u001b\n")}`); + await get(port, "/@b?mark=10&reason=lookup-unavailable&failed=500&detail=real"); + await get(port, "/api/flush"); + await new Promise((r) => setTimeout(r, 50)); + const line = stderr() + .split("\n") + .find((l) => l.startsWith("[ssr-degraded]")); + expect(line).toContain("errors [real]"); + }); + it("does not count a marked response whose client left before any head as sent", async () => { const { port } = await boot(); await new Promise((resolve) => { diff --git a/apps/web/ssr-degraded.js b/apps/web/ssr-degraded.js index 4ceaaebf60..3171a8a077 100644 --- a/apps/web/ssr-degraded.js +++ b/apps/web/ssr-degraded.js @@ -72,7 +72,10 @@ function sampleDetail(detail) { .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") .replace(/\s+/g, " ") .trim(); - details.add(clean.slice(0, MAX_DETAIL_LENGTH)); + // Nothing left to say: keep the slot for a detail that has something. + if (!clean) return; + // By code point, so the cut never splits a surrogate pair. + details.add(Array.from(clean).slice(0, MAX_DETAIL_LENGTH).join("")); } function pathOf(req) {