API Rate Limiting: Patterns and Best Practices

Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.

E
ECOSIRE Research and Development Team
|19 مارس 202610 دقائق قراءة2.3k كلمات|

تحديد معدل واجهة برمجة التطبيقات: الأنماط وأفضل الممارسات

تعتبر كل نقطة نهاية عامة لواجهة برمجة التطبيقات هدفًا - فالروبوتات والكاشطات والممثلون السيئون سوف يهاجمون خادمك في اللحظة التي تبدأ فيها البث المباشر. بدون تحديد المعدل، يمكن لعميل واحد يسيء التصرف أن يستنفد اتصالات قاعدة البيانات الخاصة بك، ويزيد من فاتورة السحابة الخاصة بك، ويوقف الخدمة لكل مستخدم شرعي. تحديد السعر ليس اختياريًا؛ إنه خط الدفاع الأول لأي واجهة برمجة تطبيقات إنتاجية.

يستعرض هذا الدليل الخوارزميات الأربعة الرئيسية لتحديد المعدل ومقايضاتها وكيفية تنفيذها بشكل صحيح في NestJS مع Redis. ستغادر مع تكوينات النسخ واللصق للسيناريوهات الشائعة ونموذج عقلي لاختيار الإستراتيجية الصحيحة لكل نقطة نهاية.

الوجبات الرئيسية

  • تسمح مجموعة الرمز المميز بالانفجار المتحكم فيه بينما تكون عدادات النوافذ الثابتة هي الأرخص في التنفيذ
  • سجل النافذة المنزلقة هو الأكثر دقة ولكنه يستهلك الكثير من الذاكرة؛ عداد النافذة المنزلقة هو أفضل توازن
  • Redis هو مخزن الدعم الصحيح الوحيد عند تشغيل عدة نسخ متماثلة لواجهة برمجة التطبيقات (API).
  • يدعم NestJS @nestjs/throttler محولات التخزين المخصصة - قم بالتبديل في Redis مع تغيير تكوين واحد
  • قم دائمًا بإرجاع الرؤوس Retry-After، وX-RateLimit-Limit، وX-RateLimit-Remaining، وX-RateLimit-Reset
  • التمييز بين الحدود حسب حساسية نقطة النهاية: المصادقة (5/دقيقة) مقابل قراءة واجهات برمجة التطبيقات (1000/دقيقة)
  • استخدم الحدود المستندة إلى IP لحركة المرور المجهولة والحدود المستندة إلى المستخدم للطلبات المصادق عليها
  • لا تقم أبدًا بإسقاط الطلبات بصمت — قم دائمًا بإرجاع 429 Too Many Requests برسالة مفيدة

الخوارزميات الأساسية الأربعة

عداد النوافذ الثابتة

أبسط نهج: حساب الطلبات في نافذة زمنية محددة، وإعادة تعيينها عند الحدود.

// Fixed window: 100 requests per minute
// Window resets at :00, :01, :02 ...
const windowKey = `ratelimit:${userId}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(windowKey);
await redis.expire(windowKey, 60);

if (count > 100) {
  throw new TooManyRequestsException();
}

الضعف: استغلال الحدود. يمكن للعميل إرسال 100 طلب في الساعة 12:00:59 و100 طلب آخر في الساعة 12:01:00 - أي 200 طلب في ثانيتين. بالنسبة لمعظم واجهات برمجة التطبيقات، يعد هذا أمرًا مقبولًا. بالنسبة لنقاط نهاية المصادقة، فهي ليست كذلك.

سجل النافذة المنزلقة

قم بتخزين الطابع الزمني لكل طلب. في كل طلب، قم بحساب الطوابع الزمنية في النافذة الأخيرة.

const now = Date.now();
const windowStart = now - 60_000; // 60 seconds ago

// Remove old entries, add current
await redis.zremrangebyscore(key, 0, windowStart);
await redis.zadd(key, now, now.toString());
const count = await redis.zcard(key);
await redis.expire(key, 60);

if (count > 100) {
  throw new TooManyRequestsException();
}

المقايضة: دقيقة تمامًا ولكنها تخزن إدخالات O(n) لكل مستخدم حيث n هو عدد الطلبات. عند 1000 دورة في الثانية عبر 10000 مستخدم، تتزايد ذاكرة Redis بسرعة. يُستخدم لنقاط النهاية منخفضة الحجم وعالية الأمان مثل إعادة تعيين كلمة المرور.

عداد النافذة المنزلقة

نافذة منزلقة تقريبية باستخدام نافذتين ثابتتين - لا يوجد انفجار في الذاكرة.

const now = Date.now();
const currentWindow = Math.floor(now / 60000);
const previousWindow = currentWindow - 1;
const windowProgress = (now % 60000) / 60000; // 0.0 to 1.0

const [current, previous] = await redis.mget(
  `rl:${userId}:${currentWindow}`,
  `rl:${userId}:${previousWindow}`
);

const estimated =
  (parseInt(previous ?? '0') * (1 - windowProgress)) +
  parseInt(current ?? '0');

if (estimated >= 100) {
  throw new TooManyRequestsException();
}

await redis.incr(`rl:${userId}:${currentWindow}`);
await redis.expire(`rl:${userId}:${currentWindow}`, 120);

هذه هي الخوارزمية التي يستخدمها Cloudflare. يعمل على تسهيل ارتفاع الحدود بأقل قدر من الحمل - مفتاحان من Redis لكل مستخدم في كل نافذة.

دلو الرمز المميز

المعيار الذهبي للسماح بالانفجارات مع الحفاظ على معدل طويل الأجل. كل مستخدم لديه دلو يمتلئ بمعدل ثابت. الطلبات تستهلك الرموز.

async function consumeToken(
  redis: Redis,
  userId: string,
  ratePerSec: number,
  capacity: number
): Promise<boolean> {
  const now = Date.now() / 1000;
  const key = `bucket:${userId}`;

  const values = await redis.hmget(key, 'tokens', 'lastRefill');
  const currentTokens = parseFloat(values[0] ?? String(capacity));
  const lastRefillTime = parseFloat(values[1] ?? String(now));

  // Refill tokens based on elapsed time
  const elapsed = now - lastRefillTime;
  const refilled = Math.min(capacity, currentTokens + elapsed * ratePerSec);

  if (refilled < 1) {
    return false; // No tokens available
  }

  await redis.hset(key, 'tokens', String(refilled - 1), 'lastRefill', String(now));
  await redis.expire(key, Math.ceil(capacity / ratePerSec) + 60);

  return true;
}

تعتبر مجموعة الرموز المميزة مثالية لواجهات برمجة التطبيقات التي تحتاج إلى السماح بدفعات قصيرة (تحميل 10 ملفات في وقت واحد) مع منع إساءة الاستخدام المستمرة.


تكوين NestJS Throttler

يأتي @nestjs/throttler v5 مزودًا بمحول تخزين Redis. فيما يلي إعداد جاهز للإنتاج:

pnpm add @nestjs/throttler @nestjs/throttler-storage-redis ioredis
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nestjs/throttler-storage-redis';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        throttlers: [
          { name: 'short',  ttl: 1000,    limit: 5    }, // 5 req/sec burst
          { name: 'medium', ttl: 60000,   limit: 300  }, // 300 req/min
          { name: 'long',   ttl: 3600000, limit: 5000 }, // 5000 req/hr
        ],
        storage: new ThrottlerStorageRedisService({
          host: config.get('REDIS_HOST'),
          port: config.get('REDIS_PORT'),
        }),
      }),
    }),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

تجاوز الحدود لكل وحدة تحكم أو مسار:

@Controller('auth')
export class AuthController {
  // Authentication: very strict — 5 attempts per minute
  @Post('login')
  @Throttle({ medium: { ttl: 60000, limit: 5 } })
  async login(@Body() dto: LoginDto) { /* ... */ }

  // Refresh: moderate — 30 per minute
  @Post('refresh')
  @Throttle({ medium: { ttl: 60000, limit: 30 } })
  async refresh(@Body() dto: RefreshDto) { /* ... */ }

  // Skip throttling on the exchange endpoint (protected by one-time code TTL)
  @Post('exchange')
  @SkipThrottle()
  async exchange(@Body() dto: ExchangeDto) { /* ... */ }
}

مولدات المفاتيح المخصصة

بشكل افتراضي، يستخدم throttler NestJS عنوان IP الخاص بالعميل. في الإنتاج خلف Nginx/Cloudflare، تحتاج إلى X-Real-IP أو CF-Connecting-IP.

// throttler-behind-proxy.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable, ExecutionContext } from '@nestjs/common';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    // Authenticated user — use userId for accurate per-user limits
    if (req.user?.sub) {
      return `user:${req.user.sub}`;
    }
    // Anonymous — use real IP from Cloudflare header
    return (
      req.headers['cf-connecting-ip'] ||
      req.headers['x-real-ip'] ||
      (req.headers['x-forwarded-for'] as string)?.split(',')[0] ||
      req.ip
    );
  }

  protected async throwThrottlingException(
    context: ExecutionContext,
    throttlerLimitDetail: ThrottlerLimitDetail
  ): Promise<void> {
    const response = context.switchToHttp().getResponse();
    response.header(
      'Retry-After',
      Math.ceil(throttlerLimitDetail.ttl / 1000)
    );
    await super.throwThrottlingException(context, throttlerLimitDetail);
  }
}

رؤوس الاستجابة

تخبر رؤوس RFC 6585 ومسودة RateLimit العملاء بالضبط متى يجب إعادة المحاولة:

// rate-limit.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class RateLimitHeadersInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      tap(() => {
        const res = context.switchToHttp().getResponse();
        const req = context.switchToHttp().getRequest();

        // Values injected by ThrottlerGuard after evaluation
        if (req.rateLimit) {
          res.set({
            'X-RateLimit-Limit': req.rateLimit.limit,
            'X-RateLimit-Remaining': Math.max(
              0,
              req.rateLimit.limit - req.rateLimit.current
            ),
            'X-RateLimit-Reset': new Date(
              Date.now() + req.rateLimit.ttl
            ).toISOString(),
            'RateLimit-Policy': `${req.rateLimit.limit};w=${Math.ceil(
              req.rateLimit.ttl / 1000
            )}`,
          });
        }
      })
    );
  }
}

الاستراتيجيات الخاصة بنقطة النهاية

نقاط النهاية المختلفة تتطلب حدودًا مختلفة. فيما يلي جدول مرجعي للأنماط الشائعة:

نوع نقطة النهايةالخوارزميةالحدنافذة
تسجيل الدخول / إعادة تعيين كلمة المرورسجل النافذة المنزلقة515 دقيقة
التحقق من OTP / 2FAنافذة ثابتة310 دقائق
قراءة عامة APIدلو رمزي1000 انفجار، تعبئة 100/ثانية
طفرة API (مصادق عليها)عداد النافذة المنزلقة3001 دقيقة
ابتلاع Webhookنافذة ثابتة10,0001 دقيقة
تحميل الملفدلو رمزي10 انفجارات، تعبئة 1/ثانية
الذكاء الاصطناعي / LLM نقاط النهايةنافذة ثابتة201 دقيقة
بحث (مجهول)نافذة ثابتة301 دقيقة

مخطوطات Lua الذرية للسلامة الموزعة

عندما يكون لديك عدة نسخ متماثلة لواجهة برمجة التطبيقات (API)، فإن ظروف السباق في تسلسلات التحقق من الزيادة يمكن أن تسمح بتدفقات أعلى من الحد. استخدم برنامج Lua النصي الذي تم تحميله عبر redis.defineCommand لجعل الفحص والزيادة ذريًا:

// rate-limit.service.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RateLimitService {
  constructor(private readonly redis: Redis) {
    // Define atomic increment+check as a custom Redis command
    this.redis.defineCommand('rateLimitCheck', {
      numberOfKeys: 1,
      lua: `
        local key   = KEYS[1]
        local limit = tonumber(ARGV[1])
        local ttlMs = tonumber(ARGV[2])
        local count = redis.call('INCR', key)
        if count == 1 then
          redis.call('PEXPIRE', key, ttlMs)
        end
        if count > limit then
          return {0, redis.call('PTTL', key)}
        end
        return {1, -1}
      `,
    });
  }

  async isAllowed(
    key: string,
    limit: number,
    windowMs: number
  ): Promise<{ allowed: boolean; retryAfterMs: number }> {
    const result = await (this.redis as any).rateLimitCheck(
      key, limit, windowMs
    ) as [number, number];

    return {
      allowed: result[0] === 1,
      retryAfterMs: result[1] > 0 ? result[1] : 0,
    };
  }
}

استراتيجيات التدهور والتجاوز اللطيفة

لا ينبغي أن يؤدي تحديد المعدل إلى منع عمليات التحقق من الصحة الداخلية أو وكلاء المراقبة أو الشركاء الموثوق بهم.

// trusted-bypass.guard.ts
@Injectable()
export class RateLimitWithBypassGuard extends ThrottlerBehindProxyGuard {
  private readonly trustedTokens = new Set([
    process.env.MONITORING_TOKEN,
    process.env.PARTNER_API_TOKEN,
  ].filter(Boolean));

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest();

    // Internal health checks bypass all rate limits
    if (req.path === '/health' || req.path === '/ready') {
      return true;
    }

    // Trusted API tokens bypass
    const token = req.headers['x-bypass-token'];
    if (token && this.trustedTokens.has(token)) {
      return true;
    }

    return super.canActivate(context);
  }
}

لتحديد المعدل التدريجي (التحذير قبل الحظر الشامل)، قم بإرجاع 429 برأس Retry-After فقط بعد تجاوز 80% من الحد:

// In your custom guard, after counting requests:
if (count > limit * 0.9 && count <= limit) {
  response.set('X-RateLimit-Warning', 'approaching limit');
}
if (count > limit) {
  response.set('Retry-After', retryAfterSeconds.toString());
  throw new HttpException('Rate limit exceeded', 429);
}

تحديد معدل الاختبار

// rate-limit.spec.ts
describe('Rate Limiting', () => {
  it('should block after limit exceeded', async () => {
    const app = moduleRef.createNestApplication();
    await app.init();

    // Hit the endpoint 5 times (limit for login)
    for (let i = 0; i < 5; i++) {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({ email: '[email protected]', password: 'wrongpassword' })
        .expect((res) => expect(res.status).toBeLessThan(429));
    }

    // 6th request should be blocked
    const response = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: '[email protected]', password: 'wrongpassword' });

    expect(response.status).toBe(429);
    expect(response.headers['retry-after']).toBeDefined();
    expect(response.body.message).toContain('rate limit');
  });
});

الرصد والتنبيه

أحداث الحد الأقصى للمعدل هي إشارات قيمة. قم بتسجيلها في منصة المراقبة الخاصة بك:

@Injectable()
export class RateLimitMetricsService {
  async recordRateLimitHit(userId: string, endpoint: string, ip: string) {
    await this.metricsService.increment('rate_limit.hits', {
      endpoint,
      user_type: userId ? 'authenticated' : 'anonymous',
    });

    // Alert on sustained attacks (>100 hits in 1 min from same IP)
    const alertKey = `rl_alert:${ip}`;
    const recentHits = await this.redis.incr(alertKey);
    if (recentHits === 1) {
      await this.redis.expire(alertKey, 60);
    }

    if (recentHits === 100) {
      await this.alertService.send({
        severity: 'high',
        message: `Rate limit attack detected from IP ${ip}`,
        endpoint,
      });
    }
  }
}

إنشاء تتبع لوحات المعلومات:

  • عدد مرات الوصول إلى حد المعدل لكل نقطة نهاية (ص 95، ص 99)
  • أعلى عناوين IP/المستخدمين تصل إلى الحدود
  • نسبة الطلبات المحظورة مقابل المقدمة
  • إعادة المحاولة بعد فترات لاكتشاف العملاء الذين تم تكوينهم بشكل خاطئ

الأسئلة المتداولة

هل يجب أن أضع حدًا للتقييم حسب عنوان IP أو معرف المستخدم؟

استخدم كليهما. بالنسبة لنقاط النهاية غير المصادقة، IP هو المعرف الوحيد المتاح. بالنسبة لنقاط النهاية التي تمت مصادقتها، تفضل دائمًا معرف المستخدم - فهو أكثر دقة ويمنع عنوان IP مشتركًا واحدًا (مثل NAT للشركة) من حظر جميع الموظفين. قم بتنفيذ فحص من مستويين: حد IP على مستوى Nginx وحد معرف المستخدم على مستوى التطبيق.

ما هو رمز حالة HTTP الصحيح لتحديد المعدل؟

دائمًا 429 Too Many Requests وفقًا لـ RFC 6585. لا تستخدم مطلقًا 503 Service Unavailable (يعني فشل البنية التحتية) أو 403 Forbidden (يشير إلى فشل التفويض). قم بتضمين Retry-After كرأس في ثوانٍ حتى يعرف العملاء متى يجب إعادة المحاولة.

كيف أتعامل مع تحديد المعدل خلف Cloudflare أو موازن التحميل؟

قم بتكوين الوكيل الخاص بك لتعيين X-Real-IP أو CF-Connecting-IP والثقة فقط في نطاق IP الخاص بالوكيل الخاص بك. في Nginx: set_real_ip_from 103.21.244.0/22; real_ip_header CF-Connecting-IP;. في NestJS، قم بتعيين app.set('trust proxy', 1) وقراءة req.ip الذي يحله NestJS من رأس الوكيل الموثوق به.

ما هي بنية بيانات Redis الأفضل لتحديد المعدل؟

بالنسبة لعدادات النوافذ الثابتة/الانزلاقية، استخدم INCR + EXPIRE على مفتاح سلسلة - O(1) لكل طلب. بالنسبة لسجل النوافذ المنزلقة، استخدم مجموعة مرتبة (ZADD، ZREMRANGEBYSCORE، ZCARD) - O(log n). بالنسبة إلى مجموعة الرمز المميز، استخدم التجزئة (HSET، HGET) — O(1). تجعل نصوص Lua جميع العمليات ذرية عبر الأنماط الثلاثة.

كيف يمكنني التعامل مع حدود المعدلات لخطافات الويب من مقدمي الخدمة الموثوقين؟

يقوم Stripe وGitHub ومقدمو الخدمة المشابهون بإرسال خطافات الويب من نطاقات IP المعروفة. احتفظ بقائمة مسموح بها لنطاقات CIDR وحدود معدل التجاوز لعناوين IP هذه على نقطة نهاية استيعاب الرد التلقائي على الويب لديك. تحقق من توقيع webhook أولاً — التحقق من التوقيع هو طبقة الأمان الفعلية الخاصة بك هناك، وليس تحديد المعدل.

هل يمكنني تنفيذ تحديد المعدل على مستوى Nginx بدلاً من ذلك؟

نعم، ويجب عليك الحصول على الحماية الأساسية من DDoS. استخدم limit_req_zone في Nginx للحدود التقريبية المستندة إلى IP (1000 req/min). طبقة تحديد معدل مستوى التطبيق في الأعلى للتحكم الدقيق لكل مستخدم ولكل نقطة نهاية. تكمل الطبقتان بعضهما البعض: يتعامل Nginx مع الهجمات الحجمية بتكلفة زهيدة دون الوصول إلى التطبيق الخاص بك، ويتعامل NestJS مع حدود منطق الأعمال الدقيقة.


الخطوات التالية

إن إنشاء واجهة برمجة تطبيقات إنتاجية بدون قيود قوية على المعدل يشبه ترك بابك الأمامي مفتوحًا. تشكل الأنماط الموجودة في هذا الدليل - مجموعة الرموز المميزة لحركة المرور المتقطعة، وعداد النوافذ المنزلقة للتنفيذ السلس، والتخزين الموزع المدعوم من Redis، واستجابات 429 المناسبة - العمود الفقري لواجهة برمجة تطبيقات آمنة وقابلة للتطوير.

تقوم ECOSIRE بإنشاء واجهات خلفية NestJS على مستوى المؤسسات مع تحديد المعدل والتخزين المؤقت لـ Redis وإمكانية المراقبة الكاملة منذ اليوم الأول. إذا كنت تقوم بإطلاق واجهة برمجة تطبيقات جديدة أو تعزيز واجهة موجودة، استكشف خدماتنا الهندسية الخلفية لمعرفة كيف يمكننا تسريع عملية التسليم.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب