const NOTION_API_BASE = "https://api.notion.com/v1"; const NOTION_VERSION = "2026-03-11"; const MAX_RETRIES = 3; const TIMEOUT_MS = 55000; export class NotionAuthError extends Error { constructor(msg: string) { super(msg); this.name = "NotionAuthError"; } } export class NotionNotFoundError extends Error { constructor(msg: string) { super(msg); this.name = "NotionNotFoundError"; } } export class NotionRateLimitError extends Error { retryAfter: number; constructor(msg: string, retryAfter: number) { super(msg); this.name = "NotionRateLimitError"; this.retryAfter = retryAfter; } } export class NotionValidationError extends Error { constructor(msg: string) { super(msg); this.name = "NotionValidationError"; } } export class NotionServerError extends Error { constructor(msg: string) { super(msg); this.name = "NotionServerError"; } } export class NotionTimeoutError extends Error { constructor(msg: string) { super(msg); this.name = "NotionTimeoutError"; } } type NotionErrorBody = { object: "error"; status: number; code: string; message: string; }; function classifyNotionError(status: number, code: string, message: string): Error { switch (status) { case 401: return new NotionAuthError(message); case 403: return new NotionAuthError(`Access denied: ${message}`); case 404: return new NotionNotFoundError(message); case 409: return new NotionValidationError(`Conflict: ${message}`); case 429: { const retryAfter = 1; const match = message.match(/retry after (\d+)/i) ?? message.match(/(\d+)/); const parsed = match ? parseInt(match[1], 10) : 1; return new NotionRateLimitError(message, Math.max(parsed, retryAfter)); } case 400: return new NotionValidationError(message); default: if (status >= 500) return new NotionServerError(message); return new NotionValidationError(message); } } function sanitize(msg: string): string { return msg.replace(/\s+at\s+\S+/g, "").replace(/\/[\w/.-]+\.[a-z]+\:\d+/g, "").slice(0, 4096); } async function notionFetch( path: string, apiKey: string, options: RequestInit = {} ): Promise { const url = `${NOTION_API_BASE}${path}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); const mergedSignal = options.signal ? combineSignals(options.signal, controller.signal) : controller.signal; let lastError: Error | null = null; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { const response = await fetch(url, { ...options, headers: { Authorization: `Bearer ${apiKey}`, "Notion-Version": NOTION_VERSION, "Content-Type": "application/json", ...(options.headers as Record), }, signal: mergedSignal, }); if (!response.ok) { const body = await response.json().catch(() => ({})) as Record; const errBody = body as Partial; const code = errBody?.code ?? "unknown"; const msg = errBody?.message ?? `HTTP ${response.status}`; const error = classifyNotionError(response.status, code, msg); if (error instanceof NotionRateLimitError) { lastError = error; const waitMs = error.retryAfter * 1000 + Math.pow(2, attempt) * 200; await sleep(waitMs); continue; } if (error instanceof NotionServerError && attempt < MAX_RETRIES - 1) { lastError = error; await sleep(Math.pow(2, attempt) * 500); continue; } throw error; } return response.json(); } catch (err) { if (err instanceof Error && err.name === "AbortError") { clearTimeout(timeout); throw new NotionTimeoutError("Notion API request timed out after 55s"); } if (err instanceof NotionAuthError || err instanceof NotionNotFoundError || err instanceof NotionValidationError) { clearTimeout(timeout); throw err; } if (attempt < MAX_RETRIES - 1) { lastError = err instanceof Error ? err : new NotionServerError(String(err)); await sleep(Math.pow(2, attempt) * 500); continue; } } } clearTimeout(timeout); throw lastError ?? new NotionServerError("Exhausted all retries"); } function combineSignals(...signals: AbortSignal[]): AbortSignal { const controller = new AbortController(); for (const signal of signals) { if (signal.aborted) { controller.abort(signal.reason); return controller.signal; } signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); } return controller.signal; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export function createNotionClient(apiKey: string) { const client = { async searchPagesAndDatabases( query: string, startCursor?: string, pageSize = 20 ): Promise { const body: Record = { query, page_size: Math.min(pageSize, 100), filter: { value: "page", property: "object" }, }; if (startCursor) body.start_cursor = startCursor; return notionFetch("/search", apiKey, { method: "POST", body: JSON.stringify(body), }); }, async getPage(pageId: string): Promise { return notionFetch(`/pages/${pageId}`, apiKey); }, async listBlockChildren( blockId: string, startCursor?: string, pageSize = 50 ): Promise { const params = new URLSearchParams(); params.set("page_size", String(Math.min(pageSize, 100))); if (startCursor) params.set("start_cursor", startCursor); return notionFetch(`/blocks/${blockId}/children?${params}`, apiKey); }, async queryDatabase( databaseId: string, filter?: unknown, sorts?: unknown[], startCursor?: string, pageSize = 50 ): Promise { const body: Record = { page_size: Math.min(pageSize, 100), }; if (filter) body.filter = filter; if (sorts) body.sorts = sorts; if (startCursor) body.start_cursor = startCursor; return notionFetch(`/databases/${databaseId}/query`, apiKey, { method: "POST", body: JSON.stringify(body), }); }, async getDatabase(databaseId: string): Promise { return notionFetch(`/databases/${databaseId}`, apiKey); }, async appendBlocks( blockId: string, children: unknown[], after?: string ): Promise { const body: Record = { children: children.slice(0, 100), }; if (after) body.after = after; return notionFetch(`/blocks/${blockId}/children`, apiKey, { method: "PATCH", body: JSON.stringify(body), }); }, }; return client; } export type NotionClient = ReturnType;