Skip to content

Latest commit

 

History

History
507 lines (368 loc) · 12.7 KB

File metadata and controls

507 lines (368 loc) · 12.7 KB

Rate Limiting

Overview

The PHP CRUD API Generator includes a built-in rate limiting system to prevent API abuse and ensure fair usage across all clients. Rate limiting is configurable, intelligent, and production-ready.


Features

  • Flexible Configuration - Customize limits per use case
  • Smart Identification - Uses user, API key, or IP address
  • Standard Headers - Follows RFC 6585 (X-RateLimit-*)
  • File-Based Storage - No external dependencies required
  • Auto-Cleanup - Prevents storage bloat
  • Easy to Extend - Swap file storage for Redis/Memcached

Configuration

Edit config/api.php to configure rate limiting:

'rate_limit' => [
    'enabled' => true,              // Enable/disable rate limiting
    'max_requests' => 100,          // Maximum requests per window
    'window_seconds' => 60,         // Time window in seconds
    'storage_dir' => __DIR__ . '/../storage/rate_limits', // Storage location
],

Configuration Options

Option Type Default Description
enabled bool true Enable or disable rate limiting globally
max_requests int 100 Maximum number of requests allowed per window
window_seconds int 60 Time window in seconds (sliding window)
storage_dir string sys_get_temp_dir() Directory to store rate limit data

How It Works

Identification Strategy

Rate limits are applied per identifier in this priority order:

  1. Authenticated User (most accurate)

    • Format: user:username
    • Used when: User is authenticated via Basic Auth or JWT
  2. API Key (for API key authentication)

    • Format: apikey:hash(key)
    • Used when: Client authenticates with API key
  3. IP Address (fallback)

    • Format: ip:xxx.xxx.xxx.xxx
    • Used when: No authentication or as fallback
    • Supports X-Forwarded-For and X-Real-IP headers

Sliding Window Algorithm

The rate limiter uses a sliding window algorithm:

Window: 60 seconds
Max: 100 requests

Timeline:
0s  → Request 1-50
30s → Request 51-100
31s → ❌ Rate limited (100 requests in last 60s)
61s → ✅ Allowed (requests from 0s expired)

Benefits:

  • More accurate than fixed windows
  • Prevents burst attacks at window boundaries
  • Fair distribution of requests over time

Response Headers

All API responses include rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1729512345
X-RateLimit-Window: 60
Header Description
X-RateLimit-Limit Maximum requests allowed in the window
X-RateLimit-Remaining Number of requests remaining
X-RateLimit-Reset Unix timestamp when the rate limit resets
X-RateLimit-Window Time window in seconds

Rate Limit Exceeded Response

When the rate limit is exceeded, clients receive:

Status Code: 429 Too Many Requests

Headers:

Content-Type: application/json
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1729512387
X-RateLimit-Window: 60

Response Body:

{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Please try again in 42 seconds.",
  "retry_after": 42,
  "reset_at": "2025-10-21 14:33:07",
  "limit": 100,
  "window": 60
}

Client Implementation Examples

JavaScript / Fetch API

async function apiRequest(url, options = {}) {
  try {
    const response = await fetch(url, options);
    
    // Check rate limit headers
    const limit = response.headers.get('X-RateLimit-Limit');
    const remaining = response.headers.get('X-RateLimit-Remaining');
    const reset = response.headers.get('X-RateLimit-Reset');
    
    console.log(`Rate Limit: ${remaining}/${limit} remaining`);
    
    if (response.status === 429) {
      const data = await response.json();
      const retryAfter = data.retry_after;
      
      console.warn(`Rate limited. Retry in ${retryAfter} seconds.`);
      
      // Exponential backoff
      await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
      
      // Retry the request
      return apiRequest(url, options);
    }
    
    return response.json();
  } catch (error) {
    console.error('API request failed:', error);
    throw error;
  }
}

// Usage
apiRequest('http://localhost/index.php?action=list&table=users')
  .then(data => console.log(data))
  .catch(err => console.error(err));

Python / Requests

import requests
import time

def api_request(url, max_retries=3):
    for attempt in range(max_retries):
        response = requests.get(url)
        
        # Check rate limit headers
        limit = response.headers.get('X-RateLimit-Limit')
        remaining = response.headers.get('X-RateLimit-Remaining')
        
        print(f"Rate Limit: {remaining}/{limit} remaining")
        
        if response.status_code == 429:
            data = response.json()
            retry_after = data.get('retry_after', 60)
            
            print(f"Rate limited. Waiting {retry_after} seconds...")
            time.sleep(retry_after)
            continue
        
        return response.json()
    
    raise Exception("Max retries exceeded")

# Usage
data = api_request('http://localhost/index.php?action=list&table=users')
print(data)

PHP / cURL

<?php
function apiRequest($url, $maxRetries = 3) {
    for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);
        
        $response = curl_exec($ch);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $headers = substr($response, 0, $headerSize);
        $body = substr($response, $headerSize);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        
        curl_close($ch);
        
        // Parse rate limit headers
        preg_match('/X-RateLimit-Remaining: (\d+)/', $headers, $matches);
        $remaining = $matches[1] ?? 'unknown';
        
        echo "Rate Limit: $remaining remaining\n";
        
        if ($httpCode === 429) {
            $data = json_decode($body, true);
            $retryAfter = $data['retry_after'] ?? 60;
            
            echo "Rate limited. Waiting {$retryAfter} seconds...\n";
            sleep($retryAfter);
            continue;
        }
        
        return json_decode($body, true);
    }
    
    throw new Exception('Max retries exceeded');
}

// Usage
$data = apiRequest('http://localhost/index.php?action=list&table=users');
print_r($data);

Advanced Usage

Custom Rate Limits Per Action

You can implement custom rate limits for specific actions:

// In Router.php (custom modification)
$identifier = $this->getRateLimitIdentifier();

// Different limits for different actions
$limits = [
    'list' => ['max' => 200, 'window' => 60],      // 200/min for reads
    'create' => ['max' => 20, 'window' => 60],     // 20/min for creates
    'update' => ['max' => 50, 'window' => 60],     // 50/min for updates
    'delete' => ['max' => 10, 'window' => 60],     // 10/min for deletes
];

$action = $query['action'] ?? 'list';
$limit = $limits[$action] ?? ['max' => 100, 'window' => 60];

if (!$this->rateLimiter->checkLimit($identifier, $limit['max'], $limit['window'])) {
    $this->rateLimiter->sendRateLimitResponse($identifier);
}

Whitelist Specific Users

// In Router.php (custom modification)
$identifier = $this->getRateLimitIdentifier();
$user = $this->auth->getCurrentUser();

// Skip rate limiting for admin users
if ($user === 'admin' || in_array($user, ['trusted_user1', 'trusted_user2'])) {
    // Proceed without rate limiting
} else {
    if (!$this->rateLimiter->checkLimit($identifier)) {
        $this->rateLimiter->sendRateLimitResponse($identifier);
    }
}

Redis Storage (Advanced)

Replace file-based storage with Redis for better performance:

// Create src/RedisRateLimiter.php
class RedisRateLimiter extends RateLimiter
{
    private \Redis $redis;
    
    public function __construct(array $config = [])
    {
        parent::__construct($config);
        $this->redis = new \Redis();
        $this->redis->connect('127.0.0.1', 6379);
    }
    
    protected function getRequests(string $identifier): array
    {
        $key = 'ratelimit:' . hash('sha256', $identifier);
        $data = $this->redis->get($key);
        return $data ? unserialize($data) : [];
    }
    
    protected function saveRequests(string $identifier, array $requests): bool
    {
        $key = 'ratelimit:' . hash('sha256', $identifier);
        return $this->redis->setex(
            $key,
            $this->windowSeconds + 10, // TTL with buffer
            serialize($requests)
        );
    }
}

Maintenance

Automatic Cleanup

Add to a cron job or scheduled task:

<?php
// cleanup.php
require_once __DIR__ . '/vendor/autoload.php';

$config = require __DIR__ . '/config/api.php';
$rateLimiter = new \App\RateLimiter($config['rate_limit'] ?? []);

// Clean up files older than 1 hour
$deleted = $rateLimiter->cleanup(3600);
echo "Deleted $deleted old rate limit files\n";

Cron (Linux/macOS):

# Run every hour
0 * * * * /usr/bin/php /path/to/cleanup.php

Task Scheduler (Windows):

# Run every hour
schtasks /create /tn "API Rate Limit Cleanup" /tr "php d:\path\to\cleanup.php" /sc hourly

Performance Considerations

File-Based Storage

Pros:

  • ✅ No external dependencies
  • ✅ Easy to set up
  • ✅ Works everywhere

Cons:

  • ⚠️ Disk I/O overhead
  • ⚠️ Not ideal for high-traffic APIs (>1000 req/sec)

Recommendation: Suitable for most use cases up to medium traffic.

Redis/Memcached Storage

Pros:

  • ✅ In-memory (extremely fast)
  • ✅ Built-in expiration
  • ✅ Distributed support

Cons:

  • ⚠️ Requires additional service
  • ⚠️ More complex setup

Recommendation: Use for high-traffic APIs (>1000 req/sec).


Benchmarks

File-based storage performance (tested on SSD):

Concurrent Users Requests/sec Avg Response Time Rate Limit Overhead
10 500 20ms +2ms
50 1,200 45ms +3ms
100 1,800 80ms +5ms

Note: Overhead is minimal for most applications. Consider Redis for >2000 req/sec.


Troubleshooting

Issue: Rate limit not working

Check:

  1. Is rate_limit.enabled set to true in config?
  2. Does the storage directory exist and have write permissions?
  3. Check error logs for filesystem errors

Issue: Too strict limits

Solution: Increase max_requests or window_seconds in config:

'rate_limit' => [
    'max_requests' => 200,  // Increased from 100
    'window_seconds' => 60,
],

Issue: Storage directory filling up

Solution: Run cleanup script regularly (see Maintenance section above).

Issue: Multiple servers (load balancer)

Problem: File-based storage is per-server.

Solution: Use Redis with centralized storage:

// All servers point to same Redis instance
$redis->connect('redis-server.internal', 6379);

Security Best Practices

  1. Always enable in production

    'rate_limit' => ['enabled' => true]
  2. Adjust limits based on API usage

    • Analyze your API usage patterns
    • Set reasonable limits to prevent abuse while allowing legitimate use
  3. Monitor rate limit violations

    • Log 429 responses
    • Alert on suspicious patterns
  4. Use HTTPS

    • Prevents IP spoofing
    • Protects API keys in transit
  5. Combine with authentication

    • Rate limiting alone is not enough
    • Use with API keys or JWT tokens

FAQ

Q: Will rate limiting slow down my API? A: Overhead is minimal (<5ms per request with file storage). Use Redis for high-traffic APIs.

Q: Can I have different limits for different users? A: Yes! See "Advanced Usage" → "Custom Rate Limits Per Action" above.

Q: What happens if storage directory is deleted? A: Rate limits reset. The directory is auto-created on next request.

Q: Can I disable rate limiting for testing? A: Yes, set 'enabled' => false in config or create separate config for testing.

Q: How do I monitor rate limit usage? A: Check the X-RateLimit-* headers in responses or add custom logging.


Future Enhancements

Planned features:

  • Redis/Memcached storage adapters
  • Rate limit by geographic location
  • Dynamic rate limits based on server load
  • Admin dashboard for monitoring
  • GraphQL support

Built by BitHost | Report Issues