-
Notifications
You must be signed in to change notification settings - Fork 0
⚡ Prevent cache stampede in OG image fetcher #480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,12 +12,18 @@ | |||||||||||||||||||||||||||||||||||||||||
| const ONE_DAY_IN_SECONDS = 24 * ONE_HOUR_IN_SECONDS; | ||||||||||||||||||||||||||||||||||||||||||
| const OG_CACHE_CONTROL = `public, max-age=${ONE_HOUR_IN_SECONDS}, s-maxage=${ONE_DAY_IN_SECONDS}, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // In-flight requests cache to prevent cache stampedes (thundering herd) | ||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||||||||||||||||||||||||||||||||||||||
| const inFlightRequests = new Map<string, Promise<any>>(); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export async function GET( | ||||||||||||||||||||||||||||||||||||||||||
| request: NextRequest, | ||||||||||||||||||||||||||||||||||||||||||
| { params }: { params: Promise<{ username: string }> } | ||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||
| const { username } = await params; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const ip = getClientIp(request); | ||||||||||||||||||||||||||||||||||||||||||
| const rateLimitResult = await rateLimiter.check(ip); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -41,22 +47,34 @@ | |||||||||||||||||||||||||||||||||||||||||
| let followers = 0; | ||||||||||||||||||||||||||||||||||||||||||
| let publicRepos = 0; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, { | ||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||
| Accept: "application/vnd.github.v3+json", | ||||||||||||||||||||||||||||||||||||||||||
| "User-Agent": "github-user-summary", | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| next: { revalidate: ONE_DAY_IN_SECONDS }, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| if (res.ok) { | ||||||||||||||||||||||||||||||||||||||||||
| const data = await res.json(); | ||||||||||||||||||||||||||||||||||||||||||
| name = data.name ?? username; | ||||||||||||||||||||||||||||||||||||||||||
| bio = data.bio ?? ""; | ||||||||||||||||||||||||||||||||||||||||||
| avatarUrl = data.avatar_url ?? ""; | ||||||||||||||||||||||||||||||||||||||||||
| followers = data.followers ?? 0; | ||||||||||||||||||||||||||||||||||||||||||
| publicRepos = data.public_repos ?? 0; | ||||||||||||||||||||||||||||||||||||||||||
| let dataPromise = inFlightRequests.get(username); | ||||||||||||||||||||||||||||||||||||||||||
| if (!dataPromise) { | ||||||||||||||||||||||||||||||||||||||||||
| dataPromise = fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, { | ||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||
| Accept: "application/vnd.github.v3+json", | ||||||||||||||||||||||||||||||||||||||||||
| "User-Agent": "github-user-summary", | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| next: { revalidate: ONE_DAY_IN_SECONDS }, | ||||||||||||||||||||||||||||||||||||||||||
| }).then(async (res) => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`GitHub API responded with ${res.status}`); | ||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/app/api/og/[username]/route.tsx
Line: 62
Comment:
**想定内レスポンスのエラーログ化**
`!res.ok` を例外にしたため、存在しないユーザーの 404 や GitHub の通常の rate limit 403 でも `catch` 側の `logger.error` が走るようになります。以前は非 2xx では静かにデフォルト表示へフォールバックしていたので、typo やスキャン、rate limit のような通常運用の入力で error ログが増え、監視ノイズになります。
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| return res.json(); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+54
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同じ username への同時リクエスト中に、先頭の GitHub fetch が一時的な 5xx/403 や JSON パース失敗になると、全 waiter が同じ rejection を受けてデフォルト値の OG 画像を返します。以前は各リクエストが独立していたため成功するリクエストが残る余地がありましたが、この変更後は 1 回の上流失敗で同時リクエスト全体が空の画像に寄り、成功時と同じ長いキャッシュ設定で配信されます。 Prompt To Fix With AIThis is a comment left during a code review.
Path: src/app/api/og/[username]/route.tsx
Line: 54-65
Comment:
**共有 Promise の失敗伝播**
同じ username への同時リクエスト中に、先頭の GitHub fetch が一時的な 5xx/403 や JSON パース失敗になると、全 waiter が同じ rejection を受けてデフォルト値の OG 画像を返します。以前は各リクエストが独立していたため成功するリクエストが残る余地がありましたが、この変更後は 1 回の上流失敗で同時リクエスト全体が空の画像に寄り、成功時と同じ長いキャッシュ設定で配信されます。
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||
| inFlightRequests.set(username, dataPromise); | ||||||||||||||||||||||||||||||||||||||||||
| dataPromise.catch(() => {}).finally(() => { | ||||||||||||||||||||||||||||||||||||||||||
| inFlightRequests.delete(username); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
References
|
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const data = await dataPromise; | ||||||||||||||||||||||||||||||||||||||||||
| name = data.name ?? username; | ||||||||||||||||||||||||||||||||||||||||||
| bio = data.bio ?? ""; | ||||||||||||||||||||||||||||||||||||||||||
| avatarUrl = data.avatar_url ?? ""; | ||||||||||||||||||||||||||||||||||||||||||
| followers = data.followers ?? 0; | ||||||||||||||||||||||||||||||||||||||||||
| publicRepos = data.public_repos ?? 0; | ||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||
| logger.error(`Failed to fetch GitHub profile for OG image: ${username}`, error); | ||||||||||||||||||||||||||||||||||||||||||
| // fallback to defaults | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -84,7 +102,7 @@ | |||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||
| {avatarUrl && ( | ||||||||||||||||||||||||||||||||||||||||||
| <img | ||||||||||||||||||||||||||||||||||||||||||
|
Check warning on line 105 in src/app/api/og/[username]/route.tsx
|
||||||||||||||||||||||||||||||||||||||||||
| src={sanitizeUrl(avatarUrl)} | ||||||||||||||||||||||||||||||||||||||||||
| alt="" | ||||||||||||||||||||||||||||||||||||||||||
| width={120} | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of using
anyand disabling the ESLint rule, we should define a strongly-typed interface for the GitHub profile data. This improves type safety, readability, and eliminates the need for theeslint-disable-next-linecomment.