Skip to content

Rate Limiting

Doug Fennell edited this page Oct 14, 2025 · 2 revisions

Rate Limiting

This page documents the RDCP SDK's rate limiting capability, including configuration, standard headers (draft-7), and structured error details.

Overview

  • Supports per-endpoint and per-tenant token-bucket limits.
  • Emits standard RateLimit draft-7 headers when enabled (or legacy X-RateLimit-* headers when configured).
  • Returns RDCP_RATE_LIMITED (429) with structured error details when a request is limited.
  • Adapters (Express, Fastify, Koa) behave identically and clean up per-request rate events in a finally block.

Configuration

Enable and configure rate limiting via RDCPServer options in your adapter setup.

import express from 'express'
import { adapters, auth } from '@rdcp.dev/server'

const app = express()
app.use(express.json())

app.use(
  adapters.express.createRDCPMiddleware({
    authenticator: auth.validateRDCPAuth,
    capabilities: {
      rateLimit: {
        enabled: true,
        headers: true,            // emit standard RateLimit headers
        headersMode: 'draft-7',   // 'draft-7' | 'x'
        defaultRule: { windowMs: 60000, maxRequests: 120 },
        perEndpoint: {            // optional endpoint-specific rules
          control: { windowMs: 10000, maxRequests: 10 },
          status: { windowMs: 500, maxRequests: 5 },
        },
        perTenant: {              // optional tenant-specific rules
          'tenant-A': { windowMs: 60000, maxRequests: 30 },
        },
      },
    },
  })
)

app.listen(3000)

Notes:

  • headers: true enables header emission. headersMode selects between standard draft-7 and legacy X-RateLimit-*.
  • The token-bucket limiter refills continuously over the window, enforcing an average rate.

Standard Headers (draft-7)

When headers are enabled in 'draft-7' mode, responses include:

  • RateLimit: e.g. "limit=10, remaining=7, reset=30"
  • RateLimit-Policy: e.g. "10;w=60"
  • RateLimit-Remaining: numeric remaining tokens
  • RateLimit-Reset: epoch seconds when the window resets
  • Retry-After: seconds until retry is permitted (when the request was limited)

Legacy mode ('x') uses:

  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, plus Retry-After on limited responses.

Example (viewing headers on discovery):

curl -i \
  -H 'X-RDCP-Auth-Method: api-key' \
  -H 'X-RDCP-Client-ID: demo-client' \
  -H 'Authorization: Bearer dev-key-change-in-production-min-32-chars' \
  http://localhost:3000/.well-known/rdcp | grep -i 'ratelimit\|retry-after'

Structured Error Details (RDCP_RATE_LIMITED)

When a request is rate limited, the server returns 429 with:

{
  "error": {
    "code": "RDCP_RATE_LIMITED",
    "message": "Control rate limited. Retry after 300ms",
    "protocol": "rdcp/1.0",
    "timestamp": "2025-01-01T00:00:00.000Z",
    "details": {
      "limit": 10,
      "remaining": 0,
      "reset": 1737072000,
      "retryAfterSec": 1,
      "policy": "10;w=60",
      "requestId": "b8e6f..."  
    }
  }
}

Field meanings:

  • limit: configured token capacity in the window.
  • remaining: tokens remaining after the current request attempt (0 when limited).
  • reset: epoch seconds for when the limiter is considered reset.
  • retryAfterSec: seconds to wait before retrying (optional, present on limited responses).
  • policy: human-readable policy string ";w=".
  • requestId: correlation id, present when available.

Cross-adapter Behavior

  • Express, Fastify, and Koa adapters:
    • Emit identical RateLimit headers when enabled (per headersMode).
    • Return the same RDCP_RATE_LIMITED structure.
    • Always echo a correlation id as X-Request-Id in responses. If clients supply X-RDCP-Request-ID (UUID), it is echoed; otherwise a UUID is generated. Invalid IDs return RDCP_REQUEST_ID_INVALID (400).
    • Clean up per-request rate limiter state in a finally block to prevent leaks on all code paths.

Complete Implementation Examples

Production Rate Limiting Server

Here's a complete server implementation with comprehensive rate limiting:

// server.js - Complete rate limiting implementation
import express from 'express'
import { adapters, auth } from '@rdcp.dev/server'

const app = express()
app.use(express.json())

// Production-ready rate limiting configuration
const rdcpMiddleware = adapters.express.createRDCPMiddleware({
  authenticator: auth.validateRDCPAuth,
  capabilities: {
    rateLimit: {
      enabled: true,
      headers: true,
      headersMode: 'draft-7', // Use standard headers
      
      // Global default: 120 requests per minute
      defaultRule: { 
        windowMs: 60000,      // 1 minute window
        maxRequests: 120      // 120 requests max
      },
      
      // Endpoint-specific limits
      perEndpoint: {
        // Control endpoint: stricter limits
        control: { 
          windowMs: 60000,    // 1 minute window
          maxRequests: 30     // Only 30 control operations per minute
        },
        // Status endpoint: more lenient for monitoring
        status: { 
          windowMs: 60000,    // 1 minute window
          maxRequests: 600    // 600 status checks per minute
        },
        // Discovery: very permissive
        discovery: {
          windowMs: 60000,    // 1 minute window
          maxRequests: 1000   // 1000 discovery calls per minute
        }
      },
      
      // Tenant-specific limits (organization isolation)
      perTenant: {
        // Premium tenant: higher limits
        'tenant-premium-001': {
          windowMs: 60000,    // 1 minute window
          maxRequests: 300    // 300 requests per minute
        },
        // Basic tenant: standard limits
        'tenant-basic-002': {
          windowMs: 60000,    // 1 minute window  
          maxRequests: 60     // 60 requests per minute
        },
        // Free tier: very restrictive
        'tenant-free-003': {
          windowMs: 60000,    // 1 minute window
          maxRequests: 10     // Only 10 requests per minute
        }
      }
    },
    
    // Enable audit to track rate limiting events
    audit: {
      enabled: true,
      sink: 'console',
      sampleRate: 1.0  // Log all rate limit events
    }
  }
})

app.use(rdcpMiddleware)

// Demo route to show rate limiting in action
app.get('/api/demo', (req, res) => {
  res.json({ 
    message: 'This route is not rate limited by RDCP', 
    timestamp: new Date().toISOString() 
  })
})

app.listen(3000, () => {
  console.log('πŸš€ RDCP server with rate limiting running on port 3000')
  console.log('\nπŸ“Š Rate Limits Configured:')
  console.log('  β€’ Global default: 120 req/min')
  console.log('  β€’ Control endpoint: 30 req/min')
  console.log('  β€’ Status endpoint: 600 req/min')
  console.log('  β€’ Premium tenants: 300 req/min')
  console.log('  β€’ Free tenants: 10 req/min')
})

Client with Rate Limiting Handling

// rate-limit-client.js - Client that handles rate limits gracefully
class RateLimitedRDCPClient {
  constructor(baseUrl, apiKey, clientId = 'rate-limited-client') {
    this.baseUrl = baseUrl
    this.apiKey = apiKey
    this.clientId = clientId
    this.requestQueue = []
    this.isProcessing = false
  }
  
  async makeRequest(endpoint, options = {}) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        'X-API-Key': this.apiKey,
        'X-RDCP-Auth-Method': 'api-key',
        'X-RDCP-Client-ID': this.clientId,
        'X-RDCP-Request-ID': crypto.randomUUID(),
        ...options.headers
      }
    })
    
    // Log rate limit headers for monitoring
    const rateLimitHeaders = this.extractRateLimitHeaders(response)
    if (rateLimitHeaders.limit) {
      console.log('πŸ“Š Rate Limit Status:', {
        endpoint: endpoint,
        limit: rateLimitHeaders.limit,
        remaining: rateLimitHeaders.remaining,
        reset: new Date(rateLimitHeaders.reset * 1000).toISOString(),
        policy: rateLimitHeaders.policy
      })
    }
    
    // Handle rate limiting
    if (response.status === 429) {
      const errorData = await response.json()
      const retryAfterSec = errorData.error.details?.retryAfterSec || 60
      
      console.warn(`🚦 Rate limited! Waiting ${retryAfterSec}s before retry...`)
      
      // Wait for the specified retry period
      await new Promise(resolve => setTimeout(resolve, retryAfterSec * 1000))
      
      // Retry the request
      return this.makeRequest(endpoint, options)
    }
    
    if (!response.ok) {
      const errorData = await response.json()
      throw new Error(`RDCP Error: ${errorData.error?.code} - ${errorData.error?.message}`)
    }
    
    return response.json()
  }
  
  extractRateLimitHeaders(response) {
    return {
      limit: response.headers.get('RateLimit-Limit'),
      remaining: response.headers.get('RateLimit-Remaining'), 
      reset: response.headers.get('RateLimit-Reset'),
      policy: response.headers.get('RateLimit-Policy'),
      retryAfter: response.headers.get('Retry-After')
    }
  }
  
  // Queue-based request management to prevent overwhelming the server
  async queuedRequest(endpoint, options = {}) {
    return new Promise((resolve, reject) => {
      this.requestQueue.push({ endpoint, options, resolve, reject })
      this.processQueue()
    })
  }
  
  async processQueue() {
    if (this.isProcessing || this.requestQueue.length === 0) return
    
    this.isProcessing = true
    
    while (this.requestQueue.length > 0) {
      const { endpoint, options, resolve, reject } = this.requestQueue.shift()
      
      try {
        const result = await this.makeRequest(endpoint, options)
        resolve(result)
        
        // Small delay between requests to avoid rapid bursts
        await new Promise(resolve => setTimeout(resolve, 100))
      } catch (error) {
        reject(error)
      }
    }
    
    this.isProcessing = false
  }
  
  // High-level methods
  async getStatus() {
    return this.queuedRequest('/rdcp/v1/status')
  }
  
  async enableCategories(categories) {
    return this.queuedRequest('/rdcp/v1/control', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        action: 'enable',
        categories: categories
      })
    })
  }
}

// Usage demonstration
async function demonstrateRateLimit() {
  const client = new RateLimitedRDCPClient(
    'http://localhost:3000',
    'dev-key-change-in-production-min-32-chars'
  )
  
  console.log('πŸ§ͺ Testing rate limit handling...')
  
  // Make rapid requests to trigger rate limiting
  const promises = []
  for (let i = 0; i < 50; i++) {
    promises.push(
      client.enableCategories(['DATABASE']).catch(err => {
        console.log(`Request ${i} failed:`, err.message)
      })
    )
  }
  
  await Promise.allSettled(promises)
  console.log('βœ… Rate limit test completed')
}

// Run demo
if (import.meta.url === new URL(process.argv[1], 'file://').href) {
  demonstrateRateLimit().catch(console.error)
}

Multi-Tenant Rate Limiting Demo

// tenant-rate-limit-demo.js - Show per-tenant rate limiting
async function testTenantRateLimit() {
  const tenants = [
    { id: 'tenant-premium-001', limit: 300, name: 'Premium' },
    { id: 'tenant-basic-002', limit: 60, name: 'Basic' }, 
    { id: 'tenant-free-003', limit: 10, name: 'Free' }
  ]
  
  for (const tenant of tenants) {
    console.log(`\n🏒 Testing ${tenant.name} tenant (${tenant.id}):`)
    
    const requests = []
    const startTime = Date.now()
    
    // Make requests up to the tenant's limit + 5 more
    for (let i = 0; i < tenant.limit + 5; i++) {
      requests.push(
        fetch('http://localhost:3000/rdcp/v1/status', {
          headers: {
            'X-API-Key': 'dev-key-change-in-production-min-32-chars',
            'X-RDCP-Auth-Method': 'api-key',
            'X-RDCP-Client-ID': `${tenant.id}-client`,
            'X-RDCP-Tenant-ID': tenant.id
          }
        }).then(response => ({
          status: response.status,
          headers: {
            remaining: response.headers.get('RateLimit-Remaining'),
            reset: response.headers.get('RateLimit-Reset')
          }
        }))
      )
    }
    
    const results = await Promise.allSettled(requests)
    const successful = results.filter(r => r.value?.status === 200).length
    const rateLimited = results.filter(r => r.value?.status === 429).length
    
    console.log(`  βœ… Successful requests: ${successful}`)
    console.log(`  🚦 Rate limited requests: ${rateLimited}`)
    console.log(`  ⏱️  Total time: ${Date.now() - startTime}ms`)
  }
}

// Run tenant demo
if (import.meta.url === new URL(process.argv[1], 'file://').href) {
  testTenantRateLimit().catch(console.error)
}

Quick Tests

1. Basic Rate Limit Test

# First request - should succeed with rate limit headers
curl -i \
  -H 'X-RDCP-Auth-Method: api-key' \
  -H 'X-RDCP-Client-ID: test-client' \
  -H 'X-API-Key: dev-key-change-in-production-min-32-chars' \
  http://localhost:3000/.well-known/rdcp | grep -i ratelimit

2. Force Rate Limiting

# Make rapid control requests to trigger rate limiting
for i in {1..35}; do
  echo "Request $i:"
  curl -s -w 'Status: %{http_code}\n' \
    -X POST \
    -H 'X-RDCP-Auth-Method: api-key' \
    -H 'X-RDCP-Client-ID: burst-test' \
    -H 'X-API-Key: dev-key-change-in-production-min-32-chars' \
    -H 'Content-Type: application/json' \
    -d '{"action":"enable","categories":["DATABASE"]}' \
    http://localhost:3000/rdcp/v1/control
  sleep 0.1
done

3. Monitor Rate Limit Headers

# Script to monitor rate limit consumption
#!/bin/bash
echo "Monitoring rate limit headers..."
for i in {1..10}; do
  echo "\n--- Request $i ---"
  curl -s -i \
    -H 'X-RDCP-Auth-Method: api-key' \
    -H 'X-RDCP-Client-ID: monitor-test' \
    -H 'X-API-Key: dev-key-change-in-production-min-32-chars' \
    http://localhost:3000/rdcp/v1/status | grep -E '(RateLimit|X-Request-Id)'
  sleep 1
done

See also:

  • Error-Responses.md (RDCP_RATE_LIMITED details)
  • Basic-Usage.md (capabilities configuration)
  • Monitoring & Metrics (Monitoring.md) β€” /status measured metrics and Prometheus /metrics

Clone this wiki locally