Error Medic

Notion API Rate Limit & 502 Errors: Complete Troubleshooting Guide (429 Too Many Requests)

Fix Notion API rate limit errors (429) and 502 Bad Gateway issues with exponential backoff, request queuing, and retry strategies. Step-by-step guide.

Last updated:
Last verified:
1,971 words
Key Takeaways
  • Notion enforces a hard limit of 3 requests per second per integration token — exceeding it returns HTTP 429 with a Retry-After header
  • 502 Bad Gateway from Notion's API is a transient infrastructure error unrelated to your code; it requires exponential backoff, not a code change
  • The fastest fix for 429 errors is reading the Retry-After response header and sleeping that many seconds before retrying
  • Batching writes with the Notion Blocks append API and caching read responses dramatically reduces request volume
  • All production Notion integrations must implement exponential backoff with jitter to handle both 429 and 502 gracefully
Fix Approaches Compared
MethodWhen to UseImplementation TimeRisk
Read Retry-After header and sleepFirst 429 hit in any integration< 30 minutesLow — official recommendation
Exponential backoff with jitterSustained high-throughput workloads1–2 hoursLow — industry standard pattern
Request queue with concurrency limiterMultiple parallel workers hitting same token2–4 hoursLow — prevents thundering herd
Response caching (Redis/in-memory)Repeated reads of the same page/database1–3 hoursLow — reduces read pressure significantly
Pagination + bulk writes batchingSyncing large datasets to Notion2–4 hoursMedium — requires restructuring sync logic
Multiple integration tokens (sharding)Legitimate multi-tenant architectures4–8 hoursMedium — Notion ToS must be reviewed
Contact Notion support for limit increaseEnterprise integrations with documented needDaysLow — official channel

Understanding Notion API Rate Limit and 502 Errors

Notion's API imposes rate limits to ensure fair usage across all integrations. When your integration exceeds these limits, the API returns HTTP 429 Too Many Requests. Separately, 502 Bad Gateway errors originate from Notion's own infrastructure and indicate a transient upstream failure — not a client error.

Both errors require retry logic, but their root causes and fixes differ. This guide walks through diagnosing each error type, implementing production-grade retry strategies, and restructuring your integration to avoid hitting limits in the first place.


What the Errors Look Like

429 Too Many Requests

HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json

{
  "object": "error",
  "status": 429,
  "code": "rate_limited",
  "message": "This request has been rate limited. Please slow down your requests."
}

502 Bad Gateway

HTTP/1.1 502 Bad Gateway
Content-Type: application/json

{
  "object": "error",
  "status": 502,
  "code": "internal_server_error",
  "message": "Internal server error."
}

Step 1: Diagnose the Root Cause

Before writing any retry code, confirm which error you are actually hitting and why.

Check your request rate

Log the timestamp of every API call for 60 seconds and count calls per second window. If you see more than 3 requests per second against the same integration token, you are triggering rate limiting.

# Count Notion API requests per second from your application logs
grep 'notion.so/v1' /var/log/app/access.log \
  | awk '{print $1}' \
  | cut -d'T' -f2 \
  | cut -d'.' -f1 \
  | sort | uniq -c | sort -rn | head -20

Inspect response headers on 429

The Retry-After header tells you exactly how long to wait:

curl -s -D - -o /dev/null \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2022-06-28" \
  https://api.notion.com/v1/databases/YOUR_DB_ID \
  | grep -iE '(http|retry-after|x-ratelimit)'

Differentiate 429 from 502

A 429 is deterministic — you sent too many requests. A 502 is non-deterministic — Notion's servers had a transient fault. Check Notion's status page at https://status.notion.so to see if a 502 correlates with an ongoing incident.


Step 2: Implement Exponential Backoff with Jitter

The correct response to both 429 and 502 is an exponential backoff retry loop. This is the most important fix you can make.

Python implementation (production-ready)

import time
import random
import requests
from typing import Any, Dict, Optional

NOTION_VERSION = "2022-06-28"
MAX_RETRIES = 5
BASE_DELAY = 1.0  # seconds
MAX_DELAY = 60.0  # seconds

def notion_request(
    method: str,
    url: str,
    token: str,
    **kwargs: Any
) -> Dict:
    headers = {
        "Authorization": f"Bearer {token}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    }
    
    for attempt in range(MAX_RETRIES):
        response = requests.request(method, url, headers=headers, **kwargs)
        
        if response.status_code == 200:
            return response.json()
        
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", BASE_DELAY))
            # Add jitter: sleep between retry_after and retry_after * 1.5
            sleep_time = retry_after + random.uniform(0, retry_after * 0.5)
            print(f"Rate limited. Sleeping {sleep_time:.2f}s (attempt {attempt+1}/{MAX_RETRIES})")
            time.sleep(sleep_time)
            continue
        
        if response.status_code in (500, 502, 503, 504):
            # Exponential backoff with full jitter for server errors
            delay = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
            sleep_time = random.uniform(0, delay)
            print(f"Server error {response.status_code}. Sleeping {sleep_time:.2f}s (attempt {attempt+1}/{MAX_RETRIES})")
            time.sleep(sleep_time)
            continue
        
        # Non-retryable error (400, 401, 403, 404)
        response.raise_for_status()
    
    raise RuntimeError(f"Max retries ({MAX_RETRIES}) exceeded for {url}")

TypeScript / Node.js implementation

const NOTION_VERSION = '2022-06-28';
const MAX_RETRIES = 5;

async function notionRequest(
  method: string,
  path: string,
  token: string,
  body?: unknown
): Promise<unknown> {
  const url = `https://api.notion.com/v1${path}`;
  
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    const res = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${token}`,
        'Notion-Version': NOTION_VERSION,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    
    if (res.ok) return res.json();
    
    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
      const jitter = Math.random() * retryAfter * 0.5;
      await sleep((retryAfter + jitter) * 1000);
      continue;
    }
    
    if ([500, 502, 503, 504].includes(res.status)) {
      const delay = Math.min(60, 2 ** attempt);
      await sleep(Math.random() * delay * 1000);
      continue;
    }
    
    throw new Error(`Notion API error ${res.status}: ${await res.text()}`);
  }
  
  throw new Error(`Max retries exceeded for ${path}`);
}

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

Step 3: Reduce Request Volume

Retry logic is a safety net — you should also reduce how many requests you send in the first place.

Cache read responses

Notion database queries and page reads are the most common source of rate limit hits. Cache them:

import functools
import time

_cache: dict = {}
_CACHE_TTL = 30  # seconds

def cached_notion_get(url: str, token: str) -> dict:
    now = time.time()
    if url in _cache:
        value, expiry = _cache[url]
        if now < expiry:
            return value
    
    result = notion_request("GET", url, token)
    _cache[url] = (result, now + _CACHE_TTL)
    return result

Use cursor-based pagination correctly

Do NOT make all pagination requests in parallel — this instantly hits the rate limit for large databases:

def query_all_pages(database_id: str, token: str) -> list:
    results = []
    url = f"https://api.notion.com/v1/databases/{database_id}/query"
    payload = {"page_size": 100}  # Maximum allowed
    
    while True:
        data = notion_request("POST", url, token, json=payload)
        results.extend(data["results"])
        
        if not data.get("has_more"):
            break
        
        payload["start_cursor"] = data["next_cursor"]
        # Sequential pagination — no parallelism here
    
    return results

Batch block appends

Each append_children call can include up to 100 blocks. Instead of one API call per block:

def append_blocks_in_batches(page_id: str, blocks: list, token: str):
    url = f"https://api.notion.com/v1/blocks/{page_id}/children"
    
    for i in range(0, len(blocks), 100):
        batch = blocks[i:i+100]
        notion_request("PATCH", url, token, json={"children": batch})

Step 4: Implement a Request Queue for High-Throughput Integrations

If your integration has multiple workers or parallel tasks, funnel all Notion calls through a rate-limited queue:

import asyncio
from collections import deque

class NotionRateLimiter:
    """Enforces max 3 req/s against a single Notion token."""
    
    def __init__(self, rps: float = 2.5):  # Stay under 3 for safety margin
        self.rps = rps
        self._lock = asyncio.Lock()
        self._last_call = 0.0
    
    async def acquire(self):
        async with self._lock:
            now = asyncio.get_event_loop().time()
            wait = (1.0 / self.rps) - (now - self._last_call)
            if wait > 0:
                await asyncio.sleep(wait)
            self._last_call = asyncio.get_event_loop().time()

# Usage
limiter = NotionRateLimiter(rps=2.5)

async def safe_notion_call(url: str, token: str):
    await limiter.acquire()
    return notion_request("GET", url, token)

Step 5: Monitor and Alert

Add observability so you catch rate limit issues before they cascade:

# Prometheus-style counter — add to your app metrics
# Track: notion_api_requests_total{status="429"}
# Alert when: rate(notion_api_requests_total{status="429"}[5m]) > 0.1

# Quick check from logs
grep '"status": 429' /var/log/app/notion-client.log \
  | wc -l

Frequently Asked Questions

bash
#!/usr/bin/env bash
# notion-api-diag.sh — Diagnose rate limit and 502 issues
# Usage: NOTION_TOKEN=secret_xxx DATABASE_ID=xxx ./notion-api-diag.sh

set -euo pipefail

NOTION_TOKEN="${NOTION_TOKEN:?Set NOTION_TOKEN}"
DATABASE_ID="${DATABASE_ID:-}"
NOTION_VERSION="2022-06-28"
BASE_URL="https://api.notion.com/v1"

echo "=== Notion API Diagnostics ==="
echo "Date: $(date -u)"
echo ""

# 1. Check token validity and workspace info
echo "[1] Checking token validity..."
RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: $NOTION_VERSION" \
  "$BASE_URL/users/me")
HTTP_STATUS=$(echo "$RESPONSE" | grep 'HTTP_STATUS' | cut -d: -f2)
BODY=$(echo "$RESPONSE" | grep -v 'HTTP_STATUS')

if [ "$HTTP_STATUS" = "200" ]; then
  echo "  Token valid. Bot user: $(echo $BODY | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name','unknown'))" 2>/dev/null || echo 'unknown')"
elif [ "$HTTP_STATUS" = "401" ]; then
  echo "  ERROR: Token is invalid or revoked. Generate a new internal integration token."
  exit 1
else
  echo "  ERROR: Unexpected status $HTTP_STATUS"
fi

# 2. Fire 5 rapid requests to test rate limit behavior
echo ""
echo "[2] Firing 5 rapid requests to probe rate limit..."
for i in $(seq 1 5); do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer $NOTION_TOKEN" \
    -H "Notion-Version: $NOTION_VERSION" \
    "$BASE_URL/users/me")
  echo "  Request $i: HTTP $STATUS"
done

# 3. Test database access if DATABASE_ID provided
if [ -n "$DATABASE_ID" ]; then
  echo ""
  echo "[3] Testing database access for $DATABASE_ID..."
  HEADERS=$(curl -s -D - -o /dev/null \
    -H "Authorization: Bearer $NOTION_TOKEN" \
    -H "Notion-Version: $NOTION_VERSION" \
    "$BASE_URL/databases/$DATABASE_ID")
  echo "$HEADERS" | grep -iE '(HTTP/|retry-after|x-ratelimit|content-type)'
fi

# 4. Check Notion status page
echo ""
echo "[4] Checking Notion status page..."
STATUS_BODY=$(curl -s 'https://status.notion.so/api/v2/status.json' 2>/dev/null || echo '{}')
STATUS_DESC=$(echo $STATUS_BODY | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status',{}).get('description','unknown'))" 2>/dev/null || echo 'unknown')
echo "  Notion platform status: $STATUS_DESC"

echo ""
echo "=== Diagnostics complete ==="
E

Error Medic Editorial

Error Medic Editorial is a team of senior DevOps and SRE engineers with experience building production integrations against third-party APIs at scale. We write actionable troubleshooting guides grounded in real incident postmortems and official API documentation.

Sources

Related Articles in Notion Api

Explore More API Errors Guides