Rate Limiting
Understanding API rate limits and how to handle rate limit errors
Rate Limiting
The API enforces rate limits to ensure fair usage and protect against abuse. There are three layers of rate limiting:
| Layer | Key | Window | Max Requests | Description |
|---|---|---|---|---|
| Global | IP address | 60 seconds | 100 | Applies to all requests from an IP |
| Login | IP address | 60 seconds | 5 | Applies only to login mutation attempts |
| Per-user | User ID (from JWT) | 60 seconds | 60 | Applies to authenticated requests per user |
How It Works
- Global limit — Every incoming request is counted against your IP address. If you exceed 100 requests per minute, you'll receive an HTTP 429 response.
- Login limit — Login mutations are additionally limited to 5 attempts per minute per IP to prevent brute-force attacks.
- Per-user limit — Authenticated requests are tracked by the user ID in your JWT token, limited to 60 requests per minute.
Error Responses
Global Rate Limit (HTTP 429)
When the global limit is exceeded, the server responds with HTTP status 429 and rate limit headers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000000
Retry-After: 45
{
"errors": [
{
"message": "Too many requests"
}
]
}| Header | Description |
|---|---|
X-RateLimit-Remaining | Number of requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
Retry-After | Seconds to wait before retrying |
Login Rate Limit (GraphQL Error)
When the login limit is exceeded, you'll receive a GraphQL error response with HTTP 200:
{
"errors": [
{
"message": "Too many login attempts. Please try again later.",
"extensions": {
"code": "RATE_LIMITED",
"retryAfter": 45
}
}
]
}Per-User Rate Limit (GraphQL Error)
When the per-user limit is exceeded:
{
"errors": [
{
"message": "Too many requests. Please slow down.",
"extensions": {
"code": "RATE_LIMITED",
"retryAfter": 30
}
}
]
}Handling Rate Limits
Check for the RATE_LIMITED error code and use the retryAfter value to wait before retrying:
#!/bin/bash
API_URL="https://data.nextschool.io/"
ACCESS_TOKEN="your_token"
QUERY='{ "query": "{ students(limit: 10) { id fullName } }" }'
MAX_RETRIES=3
for attempt in $(seq 1 $MAX_RETRIES); do
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "$QUERY")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
# Global rate limit — check HTTP status
if [ "$HTTP_CODE" = "429" ]; then
RETRY_AFTER=$(echo "$BODY" | jq -r '.errors[0].extensions.retryAfter // 60')
echo "Rate limited. Retrying in ${RETRY_AFTER}s..."
sleep "$RETRY_AFTER"
continue
fi
# GraphQL rate limit — check error code
CODE=$(echo "$BODY" | jq -r '.errors[0].extensions.code // empty')
if [ "$CODE" = "RATE_LIMITED" ]; then
RETRY_AFTER=$(echo "$BODY" | jq -r '.errors[0].extensions.retryAfter // 60')
echo "Rate limited. Retrying in ${RETRY_AFTER}s..."
sleep "$RETRY_AFTER"
continue
fi
echo "$BODY" | jq .
exit 0
done
echo "Max retries exceeded"
exit 1const API_URL = 'https://data.nextschool.io/';
async function requestWithRetry(query, token, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ query })
});
// Global rate limit — check HTTP status
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
const result = await response.json();
// GraphQL rate limit — check error code
const rateLimitError = result.errors?.find(
e => e.extensions?.code === 'RATE_LIMITED'
);
if (rateLimitError) {
const retryAfter = rateLimitError.extensions.retryAfter || 60;
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
return result;
}
throw new Error('Max retries exceeded');
}import time
import requests
API_URL = 'https://data.nextschool.io/'
def request_with_retry(query: str, token: str, max_retries: int = 3):
for attempt in range(max_retries):
resp = requests.post(API_URL, headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}',
}, json={'query': query})
# Global rate limit — check HTTP status
if resp.status_code == 429:
retry_after = int(resp.headers.get('Retry-After', 60))
time.sleep(retry_after)
continue
result = resp.json()
# GraphQL rate limit — check error code
errors = result.get('errors', [])
if errors:
ext = errors[0].get('extensions', {})
if ext.get('code') == 'RATE_LIMITED':
retry_after = ext.get('retryAfter', 60)
time.sleep(retry_after)
continue
return result
raise Exception('Max retries exceeded')<?php
function requestWithRetry(string $query, string $token, int $maxRetries = 3): array {
$apiUrl = 'https://data.nextschool.io/';
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = substr($response, 0, $headerSize);
$body = json_decode(substr($response, $headerSize), true);
curl_close($ch);
// Global rate limit — check HTTP status
if ($httpCode === 429) {
preg_match('/Retry-After:\s*(\d+)/i', $headers, $m);
$retryAfter = isset($m[1]) ? (int) $m[1] : 60;
sleep($retryAfter);
continue;
}
// GraphQL rate limit — check error code
$code = $body['errors'][0]['extensions']['code'] ?? null;
if ($code === 'RATE_LIMITED') {
$retryAfter = $body['errors'][0]['extensions']['retryAfter'] ?? 60;
sleep($retryAfter);
continue;
}
return $body;
}
throw new \RuntimeException('Max retries exceeded');
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
const apiURL = "https://data.nextschool.io/"
func requestWithRetry(query, token string, maxRetries int) (map[string]any, error) {
for attempt := 0; attempt < maxRetries; attempt++ {
body, _ := json.Marshal(map[string]string{"query": query})
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Global rate limit — check HTTP status
if resp.StatusCode == 429 {
retryAfter, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if retryAfter == 0 {
retryAfter = 60
}
time.Sleep(time.Duration(retryAfter) * time.Second)
continue
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
// GraphQL rate limit — check error code
if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
errMap := errors[0].(map[string]any)
if ext, ok := errMap["extensions"].(map[string]any); ok {
if ext["code"] == "RATE_LIMITED" {
retryAfter := int(ext["retryAfter"].(float64))
if retryAfter == 0 {
retryAfter = 60
}
time.Sleep(time.Duration(retryAfter) * time.Second)
continue
}
}
}
return result, nil
}
return nil, fmt.Errorf("max retries exceeded")
}use reqwest::{Client, StatusCode};
use serde_json::{json, Value};
use std::time::Duration;
use tokio::time::sleep;
async fn request_with_retry(
client: &Client,
query: &str,
token: &str,
max_retries: u32,
) -> Result<Value, Box<dyn std::error::Error>> {
let api_url = "https://data.nextschool.io/";
for _attempt in 0..max_retries {
let resp = client
.post(api_url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", token))
.json(&json!({ "query": query }))
.send()
.await?;
// Global rate limit — check HTTP status
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after: u64 = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
sleep(Duration::from_secs(retry_after)).await;
continue;
}
let result: Value = resp.json().await?;
// GraphQL rate limit — check error code
if let Some(errors) = result["errors"].as_array() {
if let Some(first) = errors.first() {
let code = first["extensions"]["code"].as_str().unwrap_or("");
if code == "RATE_LIMITED" {
let retry_after = first["extensions"]["retryAfter"]
.as_u64()
.unwrap_or(60);
sleep(Duration::from_secs(retry_after)).await;
continue;
}
}
}
return Ok(result);
}
Err("Max retries exceeded".into())
}Best Practices
- Cache your tokens — Don't call the login mutation before every request. Store and reuse your access token until it expires.
- Retry with backoff — When rate limited, wait for the
retryAfterduration before retrying. - Batch your queries — Combine multiple queries into a single GraphQL request to reduce the number of API calls.
- Monitor response headers — Check
X-RateLimit-Remainingto proactively slow down before hitting the limit.