import { log } from 'apify';
import type { NotionSearchResponse, NotionPage, NotionPageSummary, NotionErrorResponse } from '../types/notion-api.js';
export interface NotionClientConfig {
apiKey: string
apiVersion?: string
timeout?: number
maxRetries?: number
retryDelay?: number
maxRetryDelay?: number
}
export class NotionApiError extends Error {
constructor(
message: string,
public statusCode: number,
public retryAfter?: number,
public originalError?: unknown
) {
super(message)
this.name = 'NotionApiError'
}
isRateLimit(): boolean {
return this.statusCode === 429
}
isRetryable(): boolean {
return this.statusCode >= 500 || this.statusCode === 429
}
}
export class NotionClient {
private readonly apiKey: string
private readonly apiVersion: string
private readonly timeout: number
private readonly maxRetries: number
private readonly retryDelay: number
private readonly maxRetryDelay: number
private readonly baseUrl = 'https://api.notion.com/v1'
constructor(config: NotionClientConfig) {
this.apiKey = config.apiKey
this.apiVersion = config.apiVersion || '2022-06-28'
this.timeout = config.timeout || 10000 // 10 seconds default
this.maxRetries = config.maxRetries || 3
this.retryDelay = config.retryDelay || 1000 // 1 second initial delay
this.maxRetryDelay = config.maxRetryDelay || 60000 // 60 seconds max delay
}
/**
* Search for pages in Notion
*/
async searchPages(options: { pageSize?: number; filter?: { value: string; property: string } } = {}): Promise<NotionPageSummary[]> {
const { pageSize = 5, filter = { value: 'page', property: 'object' } } = options
const response = await this.request<NotionSearchResponse>('POST', '/search', {
page_size: pageSize,
filter
})
return this.extractPageSummaries(response.results || [])
}
/**
* Get user information
*/
async getUserInfo(): Promise<{ id: string; name?: string; email?: string }> {
const response = await this.request<{ id: string; name?: string; person?: { email?: string } }>('GET', '/users/me')
return {
id: response.id,
name: response.name,
email: response.person?.email
}
}
/**
* Extract page summaries from Notion pages
*/
private extractPageSummaries(pages: NotionPage[]): NotionPageSummary[] {
return pages.map((page) => {
let title = 'Untitled'
try {
// Try to extract title from properties
const titleProperty = Object.values(page.properties).find(
(prop) => prop.type === 'title'
)
// Type guard to ensure it's a NotionTitleProperty
if (titleProperty && titleProperty.type === 'title' && 'title' in titleProperty) {
const titleArray = titleProperty.title
if (Array.isArray(titleArray) && titleArray.length > 0) {
title = titleArray
.map((text) => text.plain_text || '')
.join('')
.trim()
}
}
} catch (error) {
// If title extraction fails, log warning and continue with fallback
log.warning(`Failed to extract title from page ${page.id}: ${error instanceof Error ? error.message : String(error)}`)
}
// Fallback to URL if title is still empty
if (!title || title === 'Untitled') {
title = page.url || 'Untitled'
}
return {
id: page.id,
title: title || 'Untitled',
url: page.url
}
})
}
/**
* Make a request to Notion API with retry logic
*/
private async request<T>(
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<T> {
let lastError: NotionApiError | Error | null = null
let attempt = 0
while (attempt <= this.maxRetries) {
try {
const response = await this.fetchWithTimeout(
`${this.baseUrl}${path}`,
{
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Notion-Version': this.apiVersion,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
}
)
if (!response.ok) {
const errorData = await this.parseErrorResponse(response)
const retryAfter = this.getRetryAfter(response)
// Extract error message safely
const errorMessage =
(errorData && typeof errorData === 'object' && 'message' in errorData && typeof errorData.message === 'string')
? errorData.message
: (errorData && typeof errorData === 'object' && 'error' in errorData && typeof errorData.error === 'string')
? errorData.error
: `HTTP ${response.status}`
const error = new NotionApiError(
errorMessage,
response.status,
retryAfter,
errorData
)
// If it's retryable and we have retries left, retry
if (error.isRetryable() && attempt < this.maxRetries) {
lastError = error
const delay = retryAfter || this.calculateBackoffDelay(attempt)
log.warning(
`Notion API error ${response.status}, retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`
)
await this.sleep(delay)
attempt++
continue
}
// Not retryable or out of retries
throw error
}
// Success
const data = await response.json() as T
return data
} catch (error) {
// Network errors or other exceptions
if (error instanceof NotionApiError) {
throw error // Re-throw NotionApiError (already handled above)
}
// Network/timeout errors - retry if we have attempts left
if (attempt < this.maxRetries) {
lastError = error instanceof Error ? error : new Error(String(error))
const delay = this.calculateBackoffDelay(attempt)
log.warning(
`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries}): ${lastError.message}`
)
await this.sleep(delay)
attempt++
continue
}
// Out of retries
throw new NotionApiError(
`Request failed after ${this.maxRetries} retries: ${lastError?.message || String(error)}`,
0,
undefined,
error
)
}
}
// Should never reach here, but TypeScript needs it
throw lastError || new Error('Unknown error')
}
/**
* Fetch with timeout using AbortController
*/
private async fetchWithTimeout(
url: string,
options: RequestInit
): Promise<Response> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal
})
return response
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new NotionApiError(
`Request timeout after ${this.timeout}ms`,
0,
undefined,
error
)
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
/**
* Parse error response from Notion API
*/
private async parseErrorResponse(response: Response): Promise<NotionErrorResponse | { error: string }> {
try {
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
return await response.json() as NotionErrorResponse
}
return { error: await response.text() }
} catch {
return { error: 'Unknown error' }
}
}
/**
* Extract Retry-After header value (in seconds)
*/
private getRetryAfter(response: Response): number | undefined {
const retryAfter = response.headers.get('retry-after')
if (retryAfter) {
const seconds = parseInt(retryAfter, 10)
if (!isNaN(seconds)) {
return seconds * 1000 // Convert to milliseconds
}
}
return undefined
}
/**
* Calculate exponential backoff delay
*/
private calculateBackoffDelay(attempt: number): number {
const delay = this.retryDelay * Math.pow(2, attempt)
return Math.min(delay, this.maxRetryDelay)
}
/**
* Sleep utility
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}