Skip to content

Commit eeb5659

Browse files
committed
fix(profile): /u/[username] 抓取加重试 + 添加 error 边界
线上偶发"Application error: a server-side exception has occurred"报错(digest 3734429282)。 Vercel runtime logs 定位到 GET /api/user-center/profile/{id} 收到 Cloudflare 403(直连后端 200), 疑似 Vercel SSR 出口被 CF Bot/Managed Challenge 偶发拦截。单次 403 直接冒泡到 Next 默认错误页, 用户体验就是白屏 + digest。 两处改动: 1) app/u/[username]/page.tsx — fetchProfile 加重试 + 诊断日志 - 首次走 ISR(300s),失败后两次 cache:"no-store" 重试(300ms/800ms 退避),绕开 Data Cache 把 403 粘住 5 分钟的问题 - 显式带 UA / Accept header,降低被 CF 判 bot 概率 - 每次非 2xx 都记录 status / cf-ray / cf-mitigated / content-type / 响应体前 300 字符, 下次再发生时 Vercel 日志里直接有证据(原先只在 dev 打 warn) 2) app/u/[username]/error.tsx — 新增路由级错误边界 - 取代 Next 默认的白屏错误页,给出"重试 / 返回首页 / 排行榜"三个出口 - 展示 digest 让用户可以回贴排查
1 parent ce84c11 commit eeb5659

2 files changed

Lines changed: 172 additions & 25 deletions

File tree

app/u/[username]/error.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use client";
2+
3+
// 个人主页路由的错误边界。
4+
//
5+
// 为什么要单独加这个文件:
6+
// 原先 /u/[username] 的 SSR 抓取如果命中 Cloudflare 偶发 403 / 后端 5xx,
7+
// 会直接冒泡到 Next 默认的全局错误页("Application error: a server-side exception
8+
// has occurred while loading involutionhell.com"),用户看到的是一堆 digest 没有
9+
// 任何可操作的信息。这里给一个本地化的、带"重试"按钮的降级界面。
10+
//
11+
// 注意:
12+
// - 必须是 client component("use client"),因为需要 useEffect / onClick。
13+
// - 不要依赖任何服务端 state,error 本身就是 SSR 失败的产物。
14+
// - reset() 来自 Next,会尝试重新渲染该路由段(会重新触发 SSR fetch),适合瞬时抖动场景。
15+
16+
import Link from "next/link";
17+
import { useEffect } from "react";
18+
19+
export default function ProfileError({
20+
error,
21+
reset,
22+
}: {
23+
error: Error & { digest?: string };
24+
reset: () => void;
25+
}) {
26+
useEffect(() => {
27+
// 把 digest 和 message 打到浏览器控制台,方便用户把 digest 回贴给我们排查。
28+
// 服务端那份 stack 在 Vercel runtime logs 里(fetchProfile 里也已经记录)。
29+
console.error("[UserProfile error boundary]", {
30+
message: error.message,
31+
digest: error.digest,
32+
});
33+
}, [error]);
34+
35+
return (
36+
<main className="min-h-screen bg-[var(--background)] flex items-center justify-center px-6 py-24">
37+
<div className="max-w-xl w-full border border-[var(--foreground)] p-8 lg:p-12 flex flex-col gap-6">
38+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#CC0000]">
39+
Profile · Temporary Failure
40+
</div>
41+
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase tracking-tight text-[var(--foreground)]">
42+
个人主页暂时加载失败
43+
</h1>
44+
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
45+
服务端在拉取这个用户的资料时遇到了一次瞬时错误(可能是上游 CDN
46+
拦截或后端抖动)。 通常重试一次就能恢复。
47+
</p>
48+
{error.digest ? (
49+
<p className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
50+
Error digest: {error.digest}
51+
</p>
52+
) : null}
53+
<div className="flex flex-wrap gap-3 pt-2">
54+
<button
55+
type="button"
56+
onClick={() => reset()}
57+
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000] hover:border-[#CC0000] transition-colors"
58+
>
59+
重试
60+
</button>
61+
<Link
62+
href="/"
63+
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
64+
>
65+
返回首页
66+
</Link>
67+
<Link
68+
href="/rank"
69+
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
70+
>
71+
查看排行榜
72+
</Link>
73+
</div>
74+
</div>
75+
</main>
76+
);
77+
}

app/u/[username]/page.tsx

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -113,42 +113,112 @@ interface ProfileResponse {
113113
* 让 Next error boundary 兜底,避免把"后端故障"伪装成"用户不存在"。
114114
*/
115115
function warnFetchProfile(message: string, details?: Record<string, unknown>) {
116-
if (process.env.NODE_ENV !== "production") {
117-
console.warn(`[fetchProfile] ${message}`, details ?? {});
118-
}
116+
// 生产环境也打印:在 Vercel runtime logs 排查偶发 403/5xx 需要看到上下文
117+
// (原先只在开发输出,导致线上 Cloudflare 拦截时只看到 Next 抛出的 Error 行,
118+
// 缺少 res 状态/cf-ray/响应体片段,排查成本高)
119+
console.warn(`[fetchProfile] ${message}`, details ?? {});
119120
}
120121

122+
/**
123+
* 带重试的 profile 抓取。
124+
* 背景:Cloudflare 偶发会对 Vercel SSR 出口返回 403(疑似 Bot 挑战/Managed Challenge),
125+
* 单次失败就 500 会让正常访问的用户直接看到 Next 默认 "Application error" 黑屏。
126+
*
127+
* 策略:
128+
* - 404 / 200 直接返回(用户不存在或成功)
129+
* - 其他非 2xx(403/5xx/网关异常)做最多 2 次重试,退避 300ms / 800ms
130+
* - 每次失败都 console.warn,记录 status / cf-ray / 响应体前 300 字符,便于 Vercel 日志定位
131+
* - 重试全败才抛,让 error.tsx 兜底(不再是裸露的 Application error 页)
132+
*
133+
* 重试走 cache: "no-store",避免把上次的 403 命中 Next Data Cache 导致 5 分钟内
134+
* 所有访问都拿到同一份错误。
135+
*/
121136
async function fetchProfile(identifier: string): Promise<ProfileData | null> {
122137
const backendUrl = process.env.BACKEND_URL;
123138
if (!backendUrl) {
124139
// 关键配置缺失不能静默 notFound,给个可见错误
125140
throw new Error("BACKEND_URL is not configured");
126141
}
127-
const res = await fetch(
128-
`${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`,
129-
{ next: { revalidate: 300 } },
130-
);
131-
// 404:用户确实不存在 → notFound
132-
if (res.status === 404) {
133-
warnFetchProfile("backend 404", { identifier });
134-
return null;
135-
}
136-
// 其他非 2xx 都抛,进 Next error boundary
137-
if (!res.ok) {
138-
throw new Error(
139-
`profile backend ${res.status} ${res.statusText} for ${identifier}`,
140-
);
141-
}
142-
const json = (await res.json()) as ProfileResponse;
143-
// 后端用 {success:false, message:"用户不存在"} 表示软 404
144-
if (!json.success || !json.data) {
145-
warnFetchProfile("backend success=false", {
142+
const url = `${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`;
143+
const attempts: Array<{ revalidate: number } | { noStore: true }> = [
144+
{ revalidate: 300 }, // 首次命中:走 Next Data Cache(5min ISR),命中快
145+
{ noStore: true }, // 第一次重试:绕过缓存
146+
{ noStore: true }, // 第二次重试:再绕一次,防瞬时抖动
147+
];
148+
149+
for (let i = 0; i < attempts.length; i++) {
150+
const attempt = attempts[i];
151+
const init: RequestInit & { next?: { revalidate: number } } =
152+
"noStore" in attempt
153+
? { cache: "no-store" }
154+
: { next: { revalidate: attempt.revalidate } };
155+
// 显式设置 UA / Accept,降低被 Cloudflare 误判 bot 的概率
156+
// (Node 原生 fetch 默认 UA 在某些 CF 规则下会被挑战)
157+
init.headers = {
158+
accept: "application/json",
159+
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
160+
};
161+
162+
let res: Response;
163+
try {
164+
res = await fetch(url, init);
165+
} catch (networkErr) {
166+
warnFetchProfile("fetch network error", {
167+
identifier,
168+
attempt: i,
169+
error: String(networkErr),
170+
});
171+
if (i === attempts.length - 1) throw networkErr;
172+
await sleep(i === 0 ? 300 : 800);
173+
continue;
174+
}
175+
176+
// 404:用户确实不存在 → notFound(不重试)
177+
if (res.status === 404) {
178+
warnFetchProfile("backend 404", { identifier });
179+
return null;
180+
}
181+
if (res.ok) {
182+
const json = (await res.json()) as ProfileResponse;
183+
// 后端用 {success:false, message:"用户不存在"} 表示软 404
184+
if (!json.success || !json.data) {
185+
warnFetchProfile("backend success=false", {
186+
identifier,
187+
message: json.message,
188+
});
189+
return null;
190+
}
191+
return json.data;
192+
}
193+
194+
// 非 2xx:记录诊断信息,准备重试或最终抛错
195+
const bodySnippet = await res
196+
.text()
197+
.then((t) => t.slice(0, 300))
198+
.catch(() => "<read body failed>");
199+
warnFetchProfile("backend non-2xx", {
146200
identifier,
147-
message: json.message,
201+
attempt: i,
202+
status: res.status,
203+
statusText: res.statusText,
204+
cfRay: res.headers.get("cf-ray"),
205+
cfMitigated: res.headers.get("cf-mitigated"),
206+
contentType: res.headers.get("content-type"),
207+
bodySnippet,
148208
});
149-
return null;
209+
if (i === attempts.length - 1) {
210+
throw new Error(
211+
`profile backend ${res.status} ${res.statusText} for ${identifier} (cf-ray=${res.headers.get("cf-ray") ?? "none"})`,
212+
);
213+
}
214+
await sleep(i === 0 ? 300 : 800);
150215
}
151-
return json.data;
216+
// 理论上不会走到:上面循环要么 return,要么 throw
217+
throw new Error("profile fetch exhausted without resolution");
218+
}
219+
220+
function sleep(ms: number): Promise<void> {
221+
return new Promise((r) => setTimeout(r, ms));
152222
}
153223

154224
/**

0 commit comments

Comments
 (0)