Skip to content

SOP 008: Handle Rate Limits

Fresh Last Updated: January 2025
FieldValue
SOP IDSOP-008
Version1.0
StatusActive
DifficultyAdvanced

Purpose

Implement proper rate limit handling to build reliable Notion API integrations that gracefully handle throttling and maximize throughput.

Prerequisites

  • [ ] Working integration (SOP 002)
  • [ ] Understanding of HTTP status codes
  • [ ] Node.js async/await patterns

Rate Limiting Flow

Notion Rate Limits

Limit TypeValueScope
Requests per second3Per integration
Requests per minute~180Burst capacity
Concurrent requestsVariableDepends on plan

Rate Limit Headers

Watch for Retry-After header in 429 responses. This tells you exactly how long to wait.

Step-by-Step Instructions

Step 1: Basic Retry Logic

javascript
const { Client, isNotionClientError, APIErrorCode } = require('@notionhq/client');

const notion = new Client({ auth: process.env.NOTION_API_KEY });

async function withRetry(operation, maxRetries = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;

      if (isNotionClientError(error) && error.code === APIErrorCode.RateLimited) {
        // Get retry delay from header or use exponential backoff
        const retryAfter = error.headers?.['retry-after']
          ? parseInt(error.headers['retry-after']) * 1000
          : Math.pow(2, attempt) * 1000;

        console.log(`Rate limited. Retry ${attempt}/${maxRetries} in ${retryAfter}ms`);
        await sleep(retryAfter);
        continue;
      }

      // Don't retry other errors
      throw error;
    }
  }

  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Usage
const page = await withRetry(() =>
  notion.pages.retrieve({ page_id: 'xxx' })
);

Step 2: Request Queue with Rate Limiting

javascript
class RateLimitedQueue {
  constructor(requestsPerSecond = 3) {
    this.queue = [];
    this.processing = false;
    this.minInterval = 1000 / requestsPerSecond;
    this.lastRequestTime = 0;
  }

  async add(operation) {
    return new Promise((resolve, reject) => {
      this.queue.push({ operation, resolve, reject });
      this.process();
    });
  }

  async process() {
    if (this.processing || this.queue.length === 0) return;

    this.processing = true;

    while (this.queue.length > 0) {
      const { operation, resolve, reject } = this.queue.shift();

      // Ensure minimum interval between requests
      const now = Date.now();
      const timeSinceLastRequest = now - this.lastRequestTime;

      if (timeSinceLastRequest < this.minInterval) {
        await sleep(this.minInterval - timeSinceLastRequest);
      }

      try {
        this.lastRequestTime = Date.now();
        const result = await operation();
        resolve(result);
      } catch (error) {
        if (isNotionClientError(error) && error.code === APIErrorCode.RateLimited) {
          // Re-queue and wait
          const retryAfter = parseInt(error.headers?.['retry-after'] || '1') * 1000;
          console.log(`Rate limited, waiting ${retryAfter}ms`);

          await sleep(retryAfter);
          this.queue.unshift({ operation, resolve, reject }); // Add back to front
        } else {
          reject(error);
        }
      }
    }

    this.processing = false;
  }
}

// Usage
const queue = new RateLimitedQueue(3); // 3 requests per second

const results = await Promise.all([
  queue.add(() => notion.pages.retrieve({ page_id: 'id1' })),
  queue.add(() => notion.pages.retrieve({ page_id: 'id2' })),
  queue.add(() => notion.pages.retrieve({ page_id: 'id3' })),
  queue.add(() => notion.pages.retrieve({ page_id: 'id4' })),
  queue.add(() => notion.pages.retrieve({ page_id: 'id5' })),
]);

Step 3: Exponential Backoff with Jitter

javascript
class NotionClient {
  constructor(apiKey) {
    this.client = new Client({ auth: apiKey });
    this.maxRetries = 5;
    this.baseDelay = 1000;
    this.maxDelay = 60000;
  }

  calculateBackoff(attempt, retryAfterHeader) {
    // Use Retry-After header if available
    if (retryAfterHeader) {
      return parseInt(retryAfterHeader) * 1000;
    }

    // Exponential backoff with jitter
    const exponentialDelay = Math.min(
      this.maxDelay,
      this.baseDelay * Math.pow(2, attempt)
    );

    // Add random jitter (0-25% of delay)
    const jitter = exponentialDelay * Math.random() * 0.25;

    return exponentialDelay + jitter;
  }

  async request(operation) {
    let lastError;

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        return await operation(this.client);
      } catch (error) {
        lastError = error;

        if (!this.shouldRetry(error)) {
          throw error;
        }

        const delay = this.calculateBackoff(
          attempt,
          error.headers?.['retry-after']
        );

        console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
        await sleep(delay);
      }
    }

    throw new Error(`Max retries (${this.maxRetries}) exceeded: ${lastError.message}`);
  }

  shouldRetry(error) {
    if (!isNotionClientError(error)) return false;

    // Retry rate limits and server errors
    return error.code === APIErrorCode.RateLimited ||
           error.status === 500 ||
           error.status === 502 ||
           error.status === 503 ||
           error.status === 504;
  }

  // Convenience methods
  async getPage(pageId) {
    return this.request(client =>
      client.pages.retrieve({ page_id: pageId })
    );
  }

  async queryDatabase(databaseId, query = {}) {
    return this.request(client =>
      client.databases.query({ database_id: databaseId, ...query })
    );
  }

  async createPage(params) {
    return this.request(client =>
      client.pages.create(params)
    );
  }
}

// Usage
const notionClient = new NotionClient(process.env.NOTION_API_KEY);
const page = await notionClient.getPage('page-id');

Step 4: Batch Operations with Throttling

javascript
async function batchProcess(items, operation, options = {}) {
  const {
    concurrency = 3,
    delayBetweenBatches = 1000,
    onProgress = () => {}
  } = options;

  const results = [];
  const errors = [];
  let processed = 0;

  // Process in batches
  for (let i = 0; i < items.length; i += concurrency) {
    const batch = items.slice(i, i + concurrency);

    const batchResults = await Promise.allSettled(
      batch.map(item => withRetry(() => operation(item)))
    );

    for (let j = 0; j < batchResults.length; j++) {
      const result = batchResults[j];
      const item = batch[j];

      if (result.status === 'fulfilled') {
        results.push({ item, data: result.value });
      } else {
        errors.push({ item, error: result.reason });
      }

      processed++;
      onProgress(processed, items.length);
    }

    // Delay between batches
    if (i + concurrency < items.length) {
      await sleep(delayBetweenBatches);
    }
  }

  return { results, errors };
}

// Usage
const pageIds = ['id1', 'id2', 'id3', 'id4', 'id5', 'id6', 'id7', 'id8'];

const { results, errors } = await batchProcess(
  pageIds,
  (id) => notion.pages.retrieve({ page_id: id }),
  {
    concurrency: 3,
    delayBetweenBatches: 500,
    onProgress: (done, total) => {
      console.log(`Progress: ${done}/${total}`);
    }
  }
);

console.log(`Success: ${results.length}, Errors: ${errors.length}`);

Step 5: Token Bucket Rate Limiter

javascript
class TokenBucket {
  constructor(tokensPerSecond, bucketSize) {
    this.tokensPerSecond = tokensPerSecond;
    this.bucketSize = bucketSize;
    this.tokens = bucketSize;
    this.lastRefill = Date.now();
  }

  refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.bucketSize,
      this.tokens + elapsed * this.tokensPerSecond
    );
    this.lastRefill = now;
  }

  async acquire() {
    this.refill();

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return true;
    }

    // Wait for token to become available
    const waitTime = (1 - this.tokens) / this.tokensPerSecond * 1000;
    await sleep(waitTime);

    this.refill();
    this.tokens -= 1;
    return true;
  }
}

// Rate-limited Notion client using token bucket
class ThrottledNotionClient {
  constructor(apiKey) {
    this.client = new Client({ auth: apiKey });
    this.bucket = new TokenBucket(3, 5); // 3/sec, burst of 5
  }

  async request(operation) {
    await this.bucket.acquire();

    try {
      return await operation(this.client);
    } catch (error) {
      if (isNotionClientError(error) && error.code === APIErrorCode.RateLimited) {
        const retryAfter = parseInt(error.headers?.['retry-after'] || '1') * 1000;
        await sleep(retryAfter);
        return this.request(operation);
      }
      throw error;
    }
  }
}

Step 6: Monitoring and Metrics

javascript
class NotionMetrics {
  constructor() {
    this.requests = 0;
    this.rateLimits = 0;
    this.errors = 0;
    this.totalLatency = 0;
    this.startTime = Date.now();
  }

  recordRequest(latency, wasRateLimited = false, hadError = false) {
    this.requests++;
    this.totalLatency += latency;

    if (wasRateLimited) this.rateLimits++;
    if (hadError) this.errors++;
  }

  getStats() {
    const elapsed = (Date.now() - this.startTime) / 1000;

    return {
      totalRequests: this.requests,
      requestsPerSecond: (this.requests / elapsed).toFixed(2),
      rateLimitHits: this.rateLimits,
      rateLimitRate: ((this.rateLimits / this.requests) * 100).toFixed(2) + '%',
      errors: this.errors,
      avgLatency: (this.totalLatency / this.requests).toFixed(0) + 'ms',
      uptime: elapsed.toFixed(0) + 's'
    };
  }

  log() {
    console.table(this.getStats());
  }
}

// Instrumented client
class InstrumentedNotionClient {
  constructor(apiKey) {
    this.client = new Client({ auth: apiKey });
    this.metrics = new NotionMetrics();
  }

  async request(operation) {
    const start = Date.now();
    let wasRateLimited = false;
    let hadError = false;

    try {
      return await operation(this.client);
    } catch (error) {
      if (isNotionClientError(error) && error.code === APIErrorCode.RateLimited) {
        wasRateLimited = true;
        // Implement retry logic
        throw error;
      }
      hadError = true;
      throw error;
    } finally {
      const latency = Date.now() - start;
      this.metrics.recordRequest(latency, wasRateLimited, hadError);
    }
  }

  printMetrics() {
    this.metrics.log();
  }
}

Complete Production Client

javascript
const { Client, isNotionClientError, APIErrorCode } = require('@notionhq/client');

class ProductionNotionClient {
  constructor(apiKey, options = {}) {
    this.client = new Client({ auth: apiKey });
    this.maxRetries = options.maxRetries || 5;
    this.baseDelay = options.baseDelay || 1000;
    this.maxDelay = options.maxDelay || 60000;
    this.requestsPerSecond = options.requestsPerSecond || 3;

    this.lastRequestTime = 0;
    this.minInterval = 1000 / this.requestsPerSecond;

    // Metrics
    this.metrics = {
      requests: 0,
      retries: 0,
      rateLimits: 0,
      errors: 0
    };
  }

  async request(operation) {
    // Throttle requests
    const now = Date.now();
    const timeSinceLastRequest = now - this.lastRequestTime;

    if (timeSinceLastRequest < this.minInterval) {
      await this.sleep(this.minInterval - timeSinceLastRequest);
    }

    let lastError;

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        this.lastRequestTime = Date.now();
        this.metrics.requests++;

        return await operation(this.client);
      } catch (error) {
        lastError = error;

        if (!this.shouldRetry(error)) {
          this.metrics.errors++;
          throw error;
        }

        this.metrics.retries++;

        if (isNotionClientError(error) && error.code === APIErrorCode.RateLimited) {
          this.metrics.rateLimits++;
        }

        const delay = this.calculateBackoff(attempt, error);
        console.log(`[Notion] Retry ${attempt + 1}/${this.maxRetries} in ${delay}ms`);
        await this.sleep(delay);
      }
    }

    this.metrics.errors++;
    throw new Error(`Max retries exceeded: ${lastError?.message}`);
  }

  shouldRetry(error) {
    if (!isNotionClientError(error)) return false;

    return [
      APIErrorCode.RateLimited,
      'internal_server_error',
      'service_unavailable'
    ].includes(error.code) || [500, 502, 503, 504].includes(error.status);
  }

  calculateBackoff(attempt, error) {
    if (isNotionClientError(error) && error.headers?.['retry-after']) {
      return parseInt(error.headers['retry-after']) * 1000;
    }

    const delay = Math.min(
      this.maxDelay,
      this.baseDelay * Math.pow(2, attempt) * (0.5 + Math.random())
    );

    return Math.floor(delay);
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  // API Methods
  async getPage(pageId) {
    return this.request(c => c.pages.retrieve({ page_id: pageId }));
  }

  async updatePage(pageId, properties) {
    return this.request(c => c.pages.update({ page_id: pageId, properties }));
  }

  async createPage(params) {
    return this.request(c => c.pages.create(params));
  }

  async queryDatabase(databaseId, query = {}) {
    return this.request(c => c.databases.query({ database_id: databaseId, ...query }));
  }

  async queryAll(databaseId, query = {}) {
    const pages = [];
    let cursor = undefined;

    do {
      const response = await this.queryDatabase(databaseId, {
        ...query,
        start_cursor: cursor,
        page_size: 100
      });

      pages.push(...response.results);
      cursor = response.has_more ? response.next_cursor : undefined;
    } while (cursor);

    return pages;
  }

  async appendBlocks(pageId, children) {
    return this.request(c => c.blocks.children.append({
      block_id: pageId,
      children
    }));
  }

  getMetrics() {
    return { ...this.metrics };
  }
}

module.exports = ProductionNotionClient;

// Usage
const notion = new ProductionNotionClient(process.env.NOTION_API_KEY, {
  maxRetries: 5,
  requestsPerSecond: 3
});

const pages = await notion.queryAll('database-id', {
  filter: { property: 'Status', select: { equals: 'Active' } }
});

console.log(`Fetched ${pages.length} pages`);
console.log('Metrics:', notion.getMetrics());

Verification Checklist

  • [ ] Rate limit errors are caught and retried
  • [ ] Exponential backoff implemented
  • [ ] Retry-After header respected
  • [ ] Maximum retries enforced
  • [ ] Metrics captured for monitoring
  • [ ] Bulk operations throttled properly

Troubleshooting

IssueCauseSolution
Consistent 429sToo many requestsReduce concurrency
Retries exhaustedPersistent rate limitingIncrease delays
Slow throughputOver-aggressive throttlingTune rate limiter
Memory issuesLarge queuesImplement backpressure

Best Practices

  1. Always respect Retry-After - Use the header value when provided
  2. Add jitter - Prevents thundering herd when multiple clients retry
  3. Monitor metrics - Track rate limit hits to tune your settings
  4. Implement circuit breaker - Stop requests during prolonged outages
  5. Use pagination - Don't request large datasets in single calls

See Also

Built with VitePress | Powered by Claude AI