Integration · 13 min read

Handling API Rate Limits

Practical strategies for staying within sports API rate limits: token bucket algorithms, request queuing, adaptive backoff, and real-time quota monitoring.

Last updated: August 2026

Overview

Every sports API enforces rate limits. Free tiers might allow 100 requests per day, while enterprise plans cap at millions per month. Hitting these limits causes HTTP 429 responses, broken features, and frustrated users. The solution is a combination of client-side rate limiting, request queuing, and intelligent backoff.

Most APIs communicate rate limit status through HTTP headers like X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After. Parsing these headers lets you proactively throttle requests before hitting the limit.

Architecture

A robust rate limit handling system has three components:

  1. Token bucket limiter — Controls the rate of outgoing requests locally, ensuring you never exceed the per-minute or per-second allowance.
  2. Request queue — Buffers requests when the bucket is empty, processing them as tokens become available instead of failing immediately.
  3. Header-aware backoff — Reads rate limit headers from responses and dynamically adjusts the request rate, including respecting Retry-After when a 429 is received.

Implementation

Token Bucket Rate Limiter (JavaScript)

The token bucket algorithm is the most common approach. Tokens are added at a fixed rate, and each request consumes one. If no tokens are available, the request waits:

// rateLimiter.js
class TokenBucket {
  constructor({ capacity, refillRatePerSec }) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRatePerSec;
    this.lastRefill = Date.now();
    this.queue = [];
    this.processing = false;
  }

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

  async acquire() {
    this.refill();

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

    // Not enough tokens — queue the request
    return new Promise((resolve) => {
      this.queue.push(resolve);
      this.processQueue();
    });
  }

  processQueue() {
    if (this.processing) return;
    this.processing = true;

    const tick = () => {
      this.refill();

      while (this.queue.length > 0 && this.tokens >= 1) {
        this.tokens -= 1;
        const resolve = this.queue.shift();
        resolve();
      }

      if (this.queue.length > 0) {
        // Calculate wait time for next token
        const tokensNeeded = this.queue.length;
        const waitMs = (tokensNeeded / this.refillRate) * 1000;
        setTimeout(tick, Math.min(waitMs, 100));
      } else {
        this.processing = false;
      }
    };

    tick();
  }
}

// Example: 100 requests per minute = ~1.67/sec, burst of 10
const limiter = new TokenBucket({
  capacity: 10,
  refillRatePerSec: 1.67,
});

export { limiter };

Header-Aware Request Wrapper (Python)

This wrapper parses rate limit headers and automatically backs off when the API signals that limits are close to being exceeded:

# rate_limited_client.py
import time
import asyncio
import httpx

class RateLimitedClient:
    def __init__(self, api_key, base_url):
        self.api_key = api_key
        self.base_url = base_url
        self.client = httpx.AsyncClient(
            headers={"x-apisports-key": api_key},
            timeout=30,
        )
        self.remaining = None
        self.reset_at = None
        self.min_delay = 0.5  # Minimum delay between requests

    async def get(self, path, **params):
        # Respect minimum delay
        await asyncio.sleep(self.min_delay)

        response = await self.client.get(
            f"{self.base_url}/{path}",
            params=params,
        )

        # Parse rate limit headers
        self._update_limits(response.headers)

        # Handle 429 Too Many Requests
        if response.status_code == 429:
            retry_after = int(response.headers.get(
                "Retry-After", "60"
            ))
            print(f"Rate limited. Waiting {retry_after}s...")
            await asyncio.sleep(retry_after)
            return await self.get(path, **params)  # Retry

        # Proactively slow down if remaining is low
        if self.remaining is not None and self.remaining < 10:
            print(f"Low quota: {self.remaining} remaining. Slowing down.")
            self.min_delay = max(self.min_delay, 2.0)
        else:
            self.min_delay = 0.5  # Reset to normal

        response.raise_for_status()
        return response.json()

    def _update_limits(self, headers):
        # Common header patterns across sports APIs
        remaining = headers.get("x-ratelimit-remaining")
        reset = headers.get("x-ratelimit-reset")

        if remaining:
            self.remaining = int(remaining)
        if reset:
            self.reset_at = float(reset)

    async def close(self):
        await self.client.aclose()

# Usage
async def main():
    client = RateLimitedClient(
        api_key="your-key",
        base_url="https://v3.football.api-sports.io",
    )

    try:
        # Fetch fixtures for multiple leagues
        for league_id in [39, 140, 135]:  # EPL, La Liga, Serie A
            data = await client.get(
                "fixtures",
                league=league_id,
                season=2025,
            )
            matches = data["response"]
            print(f"League {league_id}: {len(matches)} fixtures")
    finally:
        await client.close()

asyncio.run(main())

Code Examples

Request Queue with Priority

Not all API requests are equally important. A live score update should take priority over a background data sync. Here is a priority-aware request queue:

// priorityQueue.js
import { limiter } from "./rateLimiter.js";

const queue = [];
let processing = false;

function enqueue(requestFn, priority = 0) {
  return new Promise((resolve, reject) => {
    queue.push({ requestFn, priority, resolve, reject });
    queue.sort((a, b) => b.priority - a.priority); // Higher = first
    processQueue();
  });
}

async function processQueue() {
  if (processing || queue.length === 0) return;
  processing = true;

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

    try {
      await limiter.acquire(); // Wait for a token
      const result = await requestFn();
      resolve(result);
    } catch (err) {
      reject(err);
    }
  }

  processing = false;
}

// Usage: high-priority live score fetch
const liveScores = await enqueue(
  () => fetch("https://v3.football.api-sports.io/fixtures?live=all", {
    headers: { "x-apisports-key": process.env.SPORTS_API_KEY },
  }).then((r) => r.json()),
  10 // High priority
);

// Low-priority background sync
const standings = await enqueue(
  () => fetch("https://v3.football.api-sports.io/standings?league=39&season=2025", {
    headers: { "x-apisports-key": process.env.SPORTS_API_KEY },
  }).then((r) => r.json()),
  1 // Low priority
);

Best Practices

Always parse rate limit headers

Do not wait for a 429 to start throttling. Read X-RateLimit-Remaining from every response and proactively slow down when the count drops below a threshold (e.g., 10% of your quota).

Combine caching with rate limiting

Caching is the most effective rate limit strategy. A cached response does not consume an API request at all. See our caching guide for TTL strategies by data type.

Implement circuit breaker for repeated 429s

If you receive multiple 429 responses in quick succession, open a circuit breaker that pauses all requests for a cooldown period (e.g., 60 seconds). This prevents cascading failures.

Monitor quota usage with alerts

Set up alerts when daily or monthly quota usage crosses 75% and 90%. This gives you time to upgrade your plan or optimize your request patterns before hitting the wall.

Related Guides

Compare API rate limits side by side

Find providers with generous rate limits for your use case.