Skip to content

Commit 4f0ff43

Browse files
longsizhuoCopilot
andauthored
fix(profile): /u/[username] SSR 抓取加重试 + 添加 error 边界 (#286)
* 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 让用户可以回贴排查 * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ce84c11 commit 4f0ff43

2 files changed

Lines changed: 181 additions & 24 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: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -113,42 +113,122 @@ 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 ?? {});
116+
const isProduction = process.env.NODE_ENV === "production";
117+
const status = typeof details?.status === "number" ? details.status : undefined;
118+
const success = typeof details?.success === "boolean" ? details.success : undefined;
119+
const isExpectedNotFound = status === 404 || success === false;
120+
121+
// 生产环境仅记录需要诊断的异常场景;404 / success=false 属于预期控制流,
122+
// 否则像爬虫扫描随机 /u/* 会产生大量无意义 warn 日志。
123+
if (isProduction && isExpectedNotFound) {
124+
return;
118125
}
126+
127+
// 对异常/需诊断场景仍然打印:例如 403/5xx、网关异常、解析失败等,
128+
// 便于在 Vercel runtime logs 中查看状态码、cf-ray、响应体片段等上下文。
129+
console.warn(`[fetchProfile] ${message}`, details ?? {});
119130
}
120131

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

154234
/**

0 commit comments

Comments
 (0)