SOP 008: Handle Rate Limits
Fresh Last Updated: January 2025| Field | Value |
|---|---|
| SOP ID | SOP-008 |
| Version | 1.0 |
| Status | Active |
| Difficulty | Advanced |
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 Type | Value | Scope |
|---|---|---|
| Requests per second | 3 | Per integration |
| Requests per minute | ~180 | Burst capacity |
| Concurrent requests | Variable | Depends 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
| Issue | Cause | Solution |
|---|---|---|
| Consistent 429s | Too many requests | Reduce concurrency |
| Retries exhausted | Persistent rate limiting | Increase delays |
| Slow throughput | Over-aggressive throttling | Tune rate limiter |
| Memory issues | Large queues | Implement backpressure |
Best Practices
- Always respect Retry-After - Use the header value when provided
- Add jitter - Prevents thundering herd when multiple clients retry
- Monitor metrics - Track rate limit hits to tune your settings
- Implement circuit breaker - Stop requests during prolonged outages
- Use pagination - Don't request large datasets in single calls