Rate limiting необходим для защиты API от злоупотреблений, обеспечения справедливого использования ресурсов и поддержания стабильности системы. Это всеобъемлющее руководство охватывает стратегии тестирования для rate limiting API, включая различные алгоритмы, обработку ответов 429, механизмы повторов и паттерны распределенного rate limiting.

Понимание алгоритмов Rate Limiting

Различные алгоритмы rate limiting служат для разных случаев использования:

Алгоритм Token Bucket

Токены добавляются с фиксированной скоростью. Каждый запрос потребляет один токен. Когда bucket пуст, запросы отклоняются.

// token-bucket.js
class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRate; // токены в секунду
    this.lastRefill = Date.now();
  }

  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const tokensToAdd = timePassed * this.refillRate;

    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }

  consume(tokens = 1) {
    this.refill();

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

    return false;
  }

  getAvailableTokens() {
    this.refill();
    return Math.floor(this.tokens);
  }
}

module.exports = TokenBucket;

Тестирование Token Bucket:

// token-bucket.test.js
const TokenBucket = require('./token-bucket');

describe('Rate Limiting Token Bucket', () => {
  test('должен разрешать запросы когда доступны токены', () => {
    const bucket = new TokenBucket(10, 1);

    for (let i = 0; i < 10; i++) {
      expect(bucket.consume()).toBe(true);
    }

    // 11-й запрос должен быть отклонен
    expect(bucket.consume()).toBe(false);
  });

  test('должен пополнять токены со временем', async () => {
    const bucket = new TokenBucket(5, 2); // 2 токена в секунду

    // Потребить все токены
    for (let i = 0; i < 5; i++) {
      bucket.consume();
    }

    expect(bucket.consume()).toBe(false);

    // Подождать 3 секунды (должно добавить 6 токенов, ограничено до 5)
    await new Promise(resolve => setTimeout(resolve, 3000));

    expect(bucket.getAvailableTokens()).toBe(5);
    expect(bucket.consume()).toBe(true);
  });

  test('должен обрабатывать всплески трафика', () => {
    const bucket = new TokenBucket(100, 10);

    // Всплеск 100 запросов
    let successCount = 0;

    for (let i = 0; i < 150; i++) {
      if (bucket.consume()) {
        successCount++;
      }
    }

    expect(successCount).toBe(100);
  });
});

Алгоритм Sliding Window

Отслеживает количество запросов в скользящем временном окне:

// sliding-window.js
class SlidingWindow {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.requests = [];
  }

  removeOldRequests() {
    const cutoff = Date.now() - this.windowMs;
    this.requests = this.requests.filter(timestamp => timestamp > cutoff);
  }

  isAllowed() {
    this.removeOldRequests();

    if (this.requests.length < this.limit) {
      this.requests.push(Date.now());
      return true;
    }

    return false;
  }

  getRemainingRequests() {
    this.removeOldRequests();
    return Math.max(0, this.limit - this.requests.length);
  }

  getResetTime() {
    this.removeOldRequests();

    if (this.requests.length === 0) {
      return 0;
    }

    return this.requests[0] + this.windowMs;
  }
}

module.exports = SlidingWindow;

Тестирование обработки ответов 429

Реализация Express Middleware

// rate-limit-middleware.js
const express = require('express');
const SlidingWindow = require('./sliding-window');

const rateLimiters = new Map();

function rateLimitMiddleware(options = {}) {
  const {
    limit = 100,
    windowMs = 60000,
    keyGenerator = (req) => req.ip
  } = options;

  return (req, res, next) => {
    const key = keyGenerator(req);

    if (!rateLimiters.has(key)) {
      rateLimiters.set(key, new SlidingWindow(limit, windowMs));
    }

    const limiter = rateLimiters.get(key);

    if (limiter.isAllowed()) {
      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', limiter.getRemainingRequests());
      res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));
      next();
    } else {
      const resetTime = Math.ceil((limiter.getResetTime() - Date.now()) / 1000);

      res.setHeader('Retry-After', resetTime);
      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', 0);
      res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));

      res.status(429).json({
        error: 'Слишком много запросов',
        message: `Лимит частоты превышен. Попробуйте снова через ${resetTime} секунд.`,
        retryAfter: resetTime
      });
    }
  };
}

module.exports = rateLimitMiddleware;

Тестирование ответов 429:

// rate-limit-middleware.test.js
const request = require('supertest');
const express = require('express');
const rateLimitMiddleware = require('./rate-limit-middleware');

describe('Middleware Rate Limit', () => {
  let app;

  beforeEach(() => {
    app = express();
    app.use(rateLimitMiddleware({ limit: 5, windowMs: 1000 }));
    app.get('/api/test', (req, res) => res.json({ success: true }));
  });

  test('должен разрешать запросы в пределах лимита', async () => {
    for (let i = 0; i < 5; i++) {
      const response = await request(app).get('/api/test');

      expect(response.status).toBe(200);
      expect(response.headers['x-ratelimit-limit']).toBe('5');
      expect(response.headers['x-ratelimit-remaining']).toBeDefined();
    }
  });

  test('должен возвращать 429 при превышении лимита', async () => {
    // Исчерпать лимит частоты
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    const response = await request(app).get('/api/test');

    expect(response.status).toBe(429);
    expect(response.body.error).toBe('Слишком много запросов');
    expect(response.headers['retry-after']).toBeDefined();
    expect(response.headers['x-ratelimit-remaining']).toBe('0');
  });

  test('должен включать заголовок retry-after', async () => {
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    const response = await request(app).get('/api/test');

    expect(response.headers['retry-after']).toBeDefined();
    expect(parseInt(response.headers['retry-after'])).toBeGreaterThan(0);
  });

  test('должен сбрасываться после истечения окна', async () => {
    // Использовать все запросы
    for (let i = 0; i < 5; i++) {
      await request(app).get('/api/test');
    }

    // Проверить превышение лимита частоты
    let response = await request(app).get('/api/test');
    expect(response.status).toBe(429);

    // Подождать сброса окна
    await new Promise(resolve => setTimeout(resolve, 1100));

    // Должен снова разрешить запросы
    response = await request(app).get('/api/test');
    expect(response.status).toBe(200);
  });
});

Тестирование экспоненциального Backoff

// exponential-backoff.js
class ExponentialBackoff {
  constructor(options = {}) {
    this.initialDelay = options.initialDelay || 1000;
    this.maxDelay = options.maxDelay || 60000;
    this.factor = options.factor || 2;
    this.jitter = options.jitter !== false;
    this.maxRetries = options.maxRetries || 5;
  }

  async execute(fn, retries = 0) {
    try {
      return await fn();
    } catch (error) {
      if (retries >= this.maxRetries) {
        throw error;
      }

      if (error.response?.status === 429) {
        const retryAfter = error.response.headers['retry-after'];
        let delay;

        if (retryAfter) {
          delay = parseInt(retryAfter) * 1000;
        } else {
          delay = Math.min(
            this.initialDelay * Math.pow(this.factor, retries),
            this.maxDelay
          );

          if (this.jitter) {
            delay = delay * (0.5 + Math.random() * 0.5);
          }
        }

        console.log(`Повтор через ${delay}ms (попытка ${retries + 1}/${this.maxRetries})`);

        await new Promise(resolve => setTimeout(resolve, delay));

        return this.execute(fn, retries + 1);
      }

      throw error;
    }
  }
}

module.exports = ExponentialBackoff;

Тестирование экспоненциального Backoff:

// exponential-backoff.test.js
const ExponentialBackoff = require('./exponential-backoff');

describe('Экспоненциальный Backoff', () => {
  test('должен повторять с экспоненциальными задержками', async () => {
    const backoff = new ExponentialBackoff({
      initialDelay: 100,
      factor: 2,
      maxRetries: 3
    });

    let attempts = 0;
    const timestamps = [];

    const mockFn = jest.fn(async () => {
      timestamps.push(Date.now());
      attempts++;

      if (attempts < 3) {
        const error = new Error('Rate limited');
        error.response = { status: 429, headers: {} };
        throw error;
      }

      return 'success';
    });

    const result = await backoff.execute(mockFn);

    expect(result).toBe('success');
    expect(attempts).toBe(3);

    // Проверить что задержки увеличиваются экспоненциально
    const delay1 = timestamps[1] - timestamps[0];
    const delay2 = timestamps[2] - timestamps[1];

    expect(delay1).toBeGreaterThanOrEqual(90);
    expect(delay2).toBeGreaterThanOrEqual(180);
  });

  test('должен соблюдать заголовок retry-after', async () => {
    const backoff = new ExponentialBackoff({ maxRetries: 2 });

    let attempts = 0;
    const timestamps = [];

    const mockFn = jest.fn(async () => {
      timestamps.push(Date.now());
      attempts++;

      if (attempts === 1) {
        const error = new Error('Rate limited');
        error.response = {
          status: 429,
          headers: { 'retry-after': '2' }
        };
        throw error;
      }

      return 'success';
    });

    const result = await backoff.execute(mockFn);

    expect(result).toBe('success');

    const delay = timestamps[1] - timestamps[0];
    expect(delay).toBeGreaterThanOrEqual(1900);
    expect(delay).toBeLessThan(2200);
  });

  test('должен терпеть неудачу после максимума повторов', async () => {
    const backoff = new ExponentialBackoff({
      initialDelay: 10,
      maxRetries: 2
    });

    const mockFn = jest.fn(async () => {
      const error = new Error('Rate limited');
      error.response = { status: 429, headers: {} };
      throw error;
    });

    await expect(backoff.execute(mockFn)).rejects.toThrow('Rate limited');
    expect(mockFn).toHaveBeenCalledTimes(3); // Первоначальный + 2 повтора
  });
});

Лучшие практики тестирования Rate Limiting

Чеклист тестирования

  • Тестировать каждый алгоритм rate limiting (token bucket, sliding window, fixed window)
  • Проверить что ответы 429 включают правильные заголовки
  • Тестировать значения заголовка Retry-After
  • Валидировать заголовки X-RateLimit (Limit, Remaining, Reset)
  • Тестировать экспоненциальный backoff с jitter
  • Проверить что лимиты частоты сбрасываются правильно
  • Тестировать распределенный rate limiting между экземплярами
  • Тестировать разные лимиты частоты на пользователя/API ключ
  • Валидировать обработку всплесков трафика
  • Тестировать rate limiting под конкурентной нагрузкой
  • Мониторить влияние на производительность rate limiter

Сравнение алгоритмов

АлгоритмПреимуществаНедостаткиСлучай использования
Token BucketРазрешает всплески, плавная частотаСложная реализацияAPI с переменной нагрузкой
Sliding WindowТочный, справедливыйБольшее использование памятиСтрогое применение частоты
Fixed WindowПростой, низкий overheadПроблема всплеска на границеAPI с высокой пропускной способностью
Leaky BucketСглаживает выходную частотуОтклоняет всплескиСистемы на основе очередей

Заключение

Эффективное тестирование rate limiting обеспечивает что API могут обрабатывать злоупотребления, поддерживать стабильность и предоставлять четкую обратную связь клиентам. Реализуя всеобъемлющие тесты для различных алгоритмов, обработки ответов 429, экспоненциального backoff и распределенных сценариев, вы можете построить надежные системы rate limiting.

Ключевые выводы:

  • Выбирать правильный алгоритм для вашего случая использования
  • Всегда включать заголовки Retry-After в ответы 429
  • Реализовывать экспоненциальный backoff с jitter на стороне клиента
  • Использовать Redis для распределенного rate limiting
  • Тестировать лимиты частоты под реалистичными условиями нагрузки
  • Мониторить метрики rate limiting в продакшене

Надежный rate limiting защищает ваши API, обеспечивая хороший пользовательский опыт для легитимных клиентов.