NextSchoolNextSchool Data API

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:

LayerKeyWindowMax RequestsDescription
GlobalIP address60 seconds100Applies to all requests from an IP
LoginIP address60 seconds5Applies only to login mutation attempts
Per-userUser ID (from JWT)60 seconds60Applies to authenticated requests per user

How It Works

  1. 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.
  2. Login limit — Login mutations are additionally limited to 5 attempts per minute per IP to prevent brute-force attacks.
  3. 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"
    }
  ]
}
HeaderDescription
X-RateLimit-RemainingNumber of requests remaining in the current window
X-RateLimit-ResetUnix timestamp (seconds) when the window resets
Retry-AfterSeconds 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 1
const 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 retryAfter duration 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-Remaining to proactively slow down before hitting the limit.

On this page