diff --git a/app/blog/post/[id]/resolve.ts b/app/blog/post/[id]/resolve.ts index 65a7daa..5917786 100644 --- a/app/blog/post/[id]/resolve.ts +++ b/app/blog/post/[id]/resolve.ts @@ -3,8 +3,8 @@ import { getBlogPostMeta, getBlogPostMetaBySlug, getBlogPosts, - getPageContent, } from "@/lib/notion/blog"; +import { pageToMarkdown } from "@/lib/notion/client"; import type { BlogPost } from "@/lib/notion/types"; import { UUID_RE } from "@/lib/site"; @@ -16,8 +16,10 @@ export const resolvePost = cache(async (param: string) => UUID_RE.test(param) ? getBlogPostMeta(param) : getBlogPostMetaBySlug(param) ); +// 실패를 excerpt로 대체하지 않는다 — 빌드가 본문 없는 글을 조용히 배포하던 원인. +// ISR 재생성 중 실패면 Next가 직전 페이지를 계속 서빙한다 export const resolveContent = cache(async (post: BlogPost) => - getPageContent(post.id, post.excerpt) + pageToMarkdown(post.id) ); export const getPosts = cache(getBlogPosts); diff --git a/lib/notion/blog.ts b/lib/notion/blog.ts index a752dda..1ac80df 100644 --- a/lib/notion/blog.ts +++ b/lib/notion/blog.ts @@ -1,7 +1,18 @@ -import { notion, n2m, queryDatabase } from "./client"; +import { + NotionHttpError, + notion, + queryDatabase, + withNotionRetry, +} from "./client"; import { getColorGradient } from "./colors"; import type { BlogPost } from "./types"; +/** Notion이 "그런 페이지 없다"고 답한 경우 — 일시적 실패와 구분해야 한다 */ +function isMissingPage(error: unknown): boolean { + const e = error as { code?: string; status?: number }; + return e?.code === "object_not_found" || e?.status === 404; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function mapPageToBlogPost(page: any): BlogPost { const props = page.properties; @@ -24,20 +35,6 @@ function mapPageToBlogPost(page: any): BlogPost { }; } -// 페이지의 블록 내용을 마크다운으로 변환 (실패 시 fallback 사용) -export async function getPageContent( - pageId: string, - fallback = "" -): Promise { - try { - const mdblocks = await n2m.pageToMarkdown(pageId); - return n2m.toMarkdownString(mdblocks).parent; - } catch (error) { - console.error(`Failed to fetch blocks for page ${pageId}:`, error); - return fallback; - } -} - // 목록용 조회 — 본문(content)은 상세 페이지에서만 필요하므로 // 글마다 블록을 변환하는 N+1 호출을 하지 않는다 export async function getBlogPosts(): Promise { @@ -67,18 +64,24 @@ export async function getBlogPosts(): Promise { // 본문 없이 메타데이터만 — OG 이미지 생성 등 가벼운 소비처용 export async function getBlogPostMeta(id: string): Promise { + let page; try { - const page = await notion.pages.retrieve({ page_id: id }); - const post = mapPageToBlogPost(page); - - // 미공개 글은 노출하지 않는다 - if (!post.releasable) return null; - - return post; + page = await withNotionRetry(`pages.retrieve(${id})`, () => + notion.pages.retrieve({ page_id: id }) + ); } catch (error) { - console.error("Failed to fetch blog post:", error); - return null; + // 없는 페이지만 null — 일시적 실패까지 '글 없음'으로 만들면 + // layout이 멀쩡한 글을 404로 굳혀 버린다 + if (isMissingPage(error)) return null; + throw error; } + + const post = mapPageToBlogPost(page); + + // 미공개 글은 노출하지 않는다 + if (!post.releasable) return null; + + return post; } // 슬러그로 조회 (본문 제외) — Notion DB에 Slug 속성이 없으면 쿼리가 400이므로 null 처리 @@ -98,7 +101,12 @@ export async function getBlogPostMetaBySlug( const page = response.results[0]; return page ? mapPageToBlogPost(page) : null; } catch (error) { - console.error("Failed to fetch post by slug:", error); - return null; + // DB에 Slug 속성이 없으면 400 — 이때만 '슬러그로 찾을 수 없음'으로 본다. + // 429·5xx까지 null로 삼키면 일시적 장애가 404로 굳는다 + if (error instanceof NotionHttpError && error.status === 400) { + console.error("Slug 속성으로 조회할 수 없습니다:", error.body); + return null; + } + throw error; } } diff --git a/lib/notion/client.ts b/lib/notion/client.ts index 4f9fc6e..9ae90e5 100644 --- a/lib/notion/client.ts +++ b/lib/notion/client.ts @@ -5,12 +5,133 @@ import { NotionToMarkdown } from "notion-to-md"; // 키 없는 로컬 환경에서 fallback UI조차 뜨지 못하고 페이지 전체가 500이 된다 const apiKey = process.env.NOTION_API_KEY; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ── 요청 간격 조절 ──────────────────────────────────────────────── +// Notion 공개 API는 통합당 초당 평균 3요청이다. 빌드는 글 수만큼 페이지를 +// 동시에 만들고 글 하나가 블록 요청을 여러 번 보내므로, 조절하지 않으면 +// 수백 개가 한꺼번에 나가 429를 맞는다. 요청을 한 줄로 세워 간격을 둔다. +const MIN_REQUEST_GAP_MS = Number(process.env.NOTION_MIN_REQUEST_GAP_MS ?? 350); + +let gate: Promise = Promise.resolve(); + +function takeTurn(): Promise { + const mine = gate.then(() => sleep(MIN_REQUEST_GAP_MS)); + // 대기열이 끊기지 않도록 실패해도 다음 차례는 진행시킨다 + gate = mine.catch(() => {}); + return mine; +} + +// SDK의 모든 요청도 같은 게이트를 지나게 한다 — notion-to-md는 페이지 하나에 +// 블록 요청을 여러 번 보내므로 호출부만 감싸서는 조절되지 않는다 +const throttledFetch = async ( + input: Parameters[0], + init?: Parameters[1] +) => { + await takeTurn(); + return fetch(input, init); +}; + export const notion = new Client({ auth: apiKey, + fetch: throttledFetch, }); export const n2m = new NotionToMarkdown({ notionClient: notion }); +// ── 실패 처리 ───────────────────────────────────────────────────── + +/** fetch 기반 호출의 실패 — 호출부가 상태 코드로 분기할 수 있게 status를 보존한다 */ +export class NotionHttpError extends Error { + status: number; + body: string; + retryAfterSeconds?: number; + + constructor(status: number, body: string, retryAfterSeconds?: number) { + super(`Notion API error: ${status}\n${body}`); + this.name = "NotionHttpError"; + this.status = status; + this.body = body; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +const MAX_ATTEMPTS = 8; +const MAX_BACKOFF_MS = 30_000; + +/** 응답 본문·헤더에 담긴 Notion의 권장 대기 시간(초) */ +function parseRetryAfter( + body: string, + header?: string | null +): number | undefined { + const fromHeader = header ? Number(header) : NaN; + if (Number.isFinite(fromHeader)) return fromHeader; + + try { + const parsed = JSON.parse(body); + const seconds = Number(parsed?.additional_data?.retry_after); + return Number.isFinite(seconds) ? seconds : undefined; + } catch { + return undefined; + } +} + +/** 다시 시도할 가치가 있는 실패인지 — 한도 초과(429)와 서버측 오류(5xx)만 */ +function retryDelayMs(error: unknown, attempt: number): number | null { + const e = error as { + status?: number; + body?: string; + retryAfterSeconds?: number; + }; + const status = e?.status; + const retryable = + status === 429 || (typeof status === "number" && status >= 500); + if (!retryable) return null; + + const advised = + e.retryAfterSeconds ?? + (typeof e.body === "string" ? parseRetryAfter(e.body) : undefined); + if (advised !== undefined) return Math.min(advised * 1000, MAX_BACKOFF_MS); + + // 지수 백오프 + 지터 — 빌드의 여러 워커가 같은 시각에 몰려 다시 429를 맞지 않게 + const base = Math.min(500 * 2 ** (attempt - 1), MAX_BACKOFF_MS); + return base + Math.floor(Math.random() * 250); +} + +/** + * 간격을 둬도 워커가 여러 개면 한도에 닿을 수 있으므로 재시도를 정상 경로로 둔다. + * 한도를 넘겨도 실패하면 호출부로 던진다 — 조용히 빈 값을 돌려주면 + * 본문 없는 글이 그대로 배포된다. + */ +export async function withNotionRetry( + label: string, + fn: () => Promise +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await fn(); + } catch (error) { + const wait = retryDelayMs(error, attempt); + if (wait === null || attempt >= MAX_ATTEMPTS) throw error; + + console.warn( + `[notion] ${label} 실패 — ${wait}ms 후 재시도 (${attempt}/${MAX_ATTEMPTS - 1})` + ); + await sleep(wait); + } + } +} + +// ── 조회 ────────────────────────────────────────────────────────── + +/** 페이지 블록을 마크다운으로 — 블로그 본문과 프로젝트 설명이 함께 쓴다 */ +export async function pageToMarkdown(pageId: string): Promise { + return withNotionRetry(`pageToMarkdown(${pageId})`, async () => { + const mdblocks = await n2m.pageToMarkdown(pageId); + return n2m.toMarkdownString(mdblocks).parent; + }); +} + export async function queryDatabase( database_id: string, filter?: unknown, @@ -20,25 +141,30 @@ export async function queryDatabase( throw new Error("NOTION_API_KEY environment variable is not set"); } - const response = await fetch( - `https://api.notion.com/v1/databases/${database_id}/query`, - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Notion-Version": "2022-06-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ filter, sorts }), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Notion API error: ${response.status} ${response.statusText}\n${errorText}` + return withNotionRetry(`queryDatabase(${database_id})`, async () => { + await takeTurn(); + const response = await fetch( + `https://api.notion.com/v1/databases/${database_id}/query`, + { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ filter, sorts }), + } ); - } - return response.json(); + if (!response.ok) { + const errorText = await response.text(); + throw new NotionHttpError( + response.status, + errorText, + parseRetryAfter(errorText, response.headers.get("retry-after")) + ); + } + + return response.json(); + }); } diff --git a/lib/notion/projects.ts b/lib/notion/projects.ts index a6b2e1c..4926b19 100644 --- a/lib/notion/projects.ts +++ b/lib/notion/projects.ts @@ -1,4 +1,4 @@ -import { queryDatabase, n2m } from "./client"; +import { queryDatabase, pageToMarkdown } from "./client"; import { getColorGradient } from "./colors"; import type { Project } from "./types"; @@ -15,13 +15,7 @@ export async function getProjects(): Promise { const props = page.properties; // 페이지의 블록 내용을 마크다운으로 변환 (모달에서 사용) - let content = ""; - try { - const mdblocks = await n2m.pageToMarkdown(page.id); - content = n2m.toMarkdownString(mdblocks).parent; - } catch (error) { - console.error(`Failed to fetch blocks for project ${page.id}:`, error); - } + const content = await pageToMarkdown(page.id); return { id: page.id,