Skip to main content

Rate Limiting

This page documents the rate limiting configuration to protect the API against abuse.

Overview

Rate limiting restricts the number of requests a client can make within a given period, protecting against:

  • Brute force attacks
  • Denial of service (DoS)
  • Abusive scraping
  • Server overload

Architecture

┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Client │ Request │ Rate Limiter │ Allow │ Backend │
│ │ ------> │ (Redis) │ ------> │ API │
│ │ │ │ │ │
│ │ <------ │ │ │ │
│ │ 429 Too │ │ │ │
│ │ Many │ │ │ │
└─────────────────┘ └─────────────────┘ └──────────────┘

Configuration

Bundle Installation

# config/packages/rate_limiter.yaml
framework:
rate_limiter:
# Global API limit
api_general:
policy: sliding_window
limit: 100
interval: '1 minute'

# Login limit (brute force protection)
api_login:
policy: fixed_window
limit: 5
interval: '1 minute'

# Per-IP limit
api_per_ip:
policy: sliding_window
limit: 1000
interval: '1 hour'

# Export limit (resource intensive)
api_export:
policy: fixed_window
limit: 10
interval: '1 hour'

Environment Variables

# Enable rate limiting
RATE_LIMITING_ENABLED=true

# Storage backend
RATE_LIMITER_STORAGE=redis

# Redis connection
REDIS_URL=redis://localhost:6379

Policies

Fixed Window

Counts requests in a fixed window (e.g., per minute).

|---1 min---|---1 min---|---1 min---|
| 100 req | 100 req | 100 req |

Advantage: Simple, predictable Disadvantage: Bursts possible at boundaries

Sliding Window

Sliding window based on current time.

|--------1 min--------|
10:00:30 10:01:30

Advantage: Traffic smoothing Disadvantage: More resources

Token Bucket

Token bucket with regeneration.

Advantage: Allows controlled bursts Disadvantage: Complex configuration

Implementation

Controller with Rate Limiting

use Symfony\Component\RateLimiter\RateLimiterFactory;

class AuthController extends AbstractController
{
public function __construct(
private RateLimiterFactory $loginLimiter
) {}

#[Route('/api/auth/login', methods: ['POST'])]
public function login(Request $request): Response
{
$limiter = $this->loginLimiter->create($request->getClientIp());

if (!$limiter->consume()->isAccepted()) {
return $this->json([
'success' => false,
'error' => 'Too many attempts. Try again in 1 minute.'
], 429);
}

// Login logic...
}
}

Global Listener

class RateLimitListener implements EventSubscriberInterface
{
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$limiter = $this->apiLimiter->create($request->getClientIp());

if (!$limiter->consume()->isAccepted()) {
$response = new JsonResponse([
'success' => false,
'error' => 'Rate limit exceeded',
'retry_after' => $limiter->getInfo()->getRetryAfter()->getTimestamp()
], 429);

$response->headers->set('Retry-After', $limiter->getInfo()->getRetryAfter()->getTimestamp());
$event->setResponse($response);
}
}
}

HTTP Headers

Normal Response

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 75
X-RateLimit-Reset: 1718198400

Limit Reached

HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718198400

{
"success": false,
"error": "Rate limit exceeded",
"retry_after": 45
}

Limits by Endpoint

EndpointLimitIntervalReason
/api/auth/login51 minBrute force protection
/api/auth/magic-link35 minAvoid email spam
/api/export/*101 hourResource intensive
/api/* (global)1001 minGeneral protection

Exemptions

Certain clients can be exempted:

rate_limiter:
api_general:
# ...
exempt:
- 127.0.0.1
- '192.168.1.0/24' # Internal network

By Role

if ($this->isGranted('ROLE_API_UNLIMITED')) {
return; // No limit
}

Monitoring

Prometheus Metrics

# Limited requests
rate_limit_exceeded_total{endpoint="/api/auth/login"} 42

# Requests near limit
rate_limit_near_threshold{endpoint="/api/*"} 156

Grafana Alerts

  • Alert if > 100 blocked requests / minute
  • Alert if same IP blocked > 10 times

Redis Storage

Configuration

services:
rate_limiter.storage.redis:
class: Symfony\Component\RateLimiter\Storage\CacheStorage
arguments:
- '@cache.rate_limiter'

framework:
cache:
pools:
cache.rate_limiter:
adapter: cache.adapter.redis
provider: 'redis://localhost:6379'

Advantages

  • Shared between instances (cluster)
  • Counter persistence
  • High performance

Best Practices

:::tip Recommendations

  1. Adapted limits: Adjust based on actual usage
  2. Strict login: Low limit for auth endpoints
  3. Clear headers: Inform clients of limits
  4. Monitoring: Monitor blocked requests
  5. Limited exemptions: Only exempt what's necessary :::

Troubleshooting

ProblemCauseSolution
Legitimate blocksLimit too lowIncrease limit
Redis downStorage unavailableMemory fallback
Shared IPNAT/ProxyUse X-Forwarded-For