API Rate Limiting مع Upstash Redis: شرح Node لعام
٢٤ مايو ٢٠٢٦
يحد تحديد معدل (rate limiting) الـ API من عدد الطلبات التي يمكن لكل متصل إجراؤها في نافذة زمنية معينة، مما يحمي خدمتك من الفيضانات، وبرامج الكشط (scrapers)، والفواتير الخارجة عن السيطرة. يبني هذا البرنامج التعليمي تحديد معدل بمستوى الإنتاج لـ Node.js و Hono API باستخدام النوافذ المنزلقة (sliding windows) من Upstash Redis، في حوالي 30 دقيقة.
ملخص
يضيف هذا الدليل العملي تحديد معدل حقيقي إلى Node.js API بدلاً من الأمل في ألا يسيء أحد استخدامه. ستنشئ قاعدة بيانات Upstash Redis، وتبني محدد نافذة منزلقة باستخدام @upstash/ratelimit، وتحدد المتصلين بواسطة عنوان IP موثوق (قيمة X-Forwarded-For في أقصى اليسار هي فخ أمني)، وتغلف كل شيء كبرمجية وسيطة (middleware) لـ Hono قابلة لإعادة الاستخدام، وتعيد استجابات 429 صحيحة المواصفات مع ترويسات Retry-After وتحديد المعدل، وتمنح المتصلين المجهولين والمجانيين والمدفوعين حدوداً مختلفة، وتقوي النظام بالكامل ضد الفيضانات. يستخدم المشروع Node.js 24 LTS، و hono 4.12.22، و @upstash/ratelimit 2.0.81. كل ملف قابل للتشغيل عن طريق النسخ واللصق وتم التحقق من الأنواع (type-checked) مقابل الحزم المنشورة في 24 مايو 2026.
ما ستتعلمه
- إنشاء قاعدة بيانات Upstash Redis وتوصيلها بخدمة Node.js
- بناء هيكل Hono API مكتوب الأنواع يستحق الحماية
- بناء محدد معدل بنافذة منزلقة باستخدام
@upstash/ratelimit— ولماذا تتفوق النافذة المنزلقة على النافذة الثابتة - تحديد المتصلين بواسطة عنوان IP عميل موثوق، ولماذا يمكن تزييف قيمة
X-Forwarded-Forفي أقصى اليسار - تحديد فئة المتصل بحيث يحصل المستخدمون المجهولون والمجانيون والمدفوعون على حدود مختلفة
- إرجاع استجابات
429 Too Many Requestsصحيحة المواصفات مع ترويساتRetry-Afterوتحديد المعدل - تغليف تحديد المعدل كبرمجية وسيطة Hono مكتوبة الأنواع وقابلة لإعادة الاستخدام
- التحصين ضد الفيضانات باستخدام ذاكرة تخزين مؤقت مؤقتة، ومهلة فشل مفتوح (fail-open timeout)، وقوائم الحظر
المتطلبات الأساسية
- Node.js 24 LTS أو أحدث. Node.js 24 هو خط LTS النشط الحالي، مدعوم حتى أبريل 20282. تحقق باستخدام
node --version. - حساب Upstash. الفئة المجانية كافية لهذا البرنامج التعليمي وأحمال العمل الصغيرة في الإنتاج.
- معرفة عملية بـ TypeScript وأساسيات طلب/استجابة HTTP.
- مبنى أوامر (terminal) مع
curlلخطوة التحقق.
التقنيات المستخدمة: Hono 4.12.22 كإطار عمل للويب، و @hono/node-server 2.0.4 كمحول لـ Node، و @upstash/ratelimit 2.0.8 مع @upstash/Redis 1.38.0 للمحدد.
الخطوة 1 — إنشاء قاعدة بيانات Upstash Redis
يحتاج تحديد المعدل إلى مخزن عدادات سريع ومشترك. يقوم محدد النافذة المنزلقة بزيادة عداد في كل طلب، لذا يجب أن يتعامل المخزن مع عمليات القراءة والكتابة بزمن وصول منخفض وتنتهي صلاحية المفاتيح تلقائياً — وهذا بالضبط ما صُمم Redis من أجله. تمنحك Upstash نظام Redis بدون خادم (serverless) يتم الوصول إليه عبر HTTP، مما يعني أن نفس كود المحدد يعمل على خادم Node طويل الأمد، أو حاوية، أو وظيفة حافة (edge function) دون تعقيدات إدارة تجمعات الاتصال (connection-pool).
سجل الدخول في console.upstash.com، وافتح قسم Redis، وانقر على Create Database. اختر اسماً، واختر منطقة أساسية قريبة من مكان تشغيل API الخاص بك، وقم بإنشائها. في صفحة قاعدة البيانات، ابحث عن قسم REST API وانسخ قيمتين: UPSTASH_REDIS_REST_URL و UPSTASH_REDIS_REST_TOKEN.
تتضمن الفئة المجانية حالياً 256 ميجابايت من التخزين، و 500,000 أمر شهرياً، وقاعدة بيانات واحدة لكل حساب؛ بعد ذلك، تكون محاسبة الدفع حسب الاستخدام 0.20 دولار أمريكي لكل 100,000 أمر، اعتباراً من مايو 20263. يكلف فحص النافذة المنزلقة من ثلاثة إلى خمسة أوامر Redis اعتماداً على الحالة، لذا ضع الميزانية وفقاً لذلك — الخطوة 8 تقلل هذه التكلفة بشكل حاد.
أنشئ مجلداً للمشروع وقم بتخزين بيانات الاعتماد في ملف .env:
mkdir rate-limited-API && cd rate-limited-API
cat > .env <<'EOF'
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-rest-token
PORT=3000
TRUSTED_PROXY_HOPS=0
EOF
echo ".env" > .gitignore
لا تقم أبداً برفع .env — رمز REST هو اعتماد كامل لقاعدة بياناتك. سطر .gitignore أعلاه يبقيه خارج نظام التحكم في الإصدار.
الخطوة 2 — بناء هيكل Hono API
الآن قم ببناء API صغير يستحق الحماية. ابدأ المشروع وقم بتثبيت التبعيات المحددة:
npm init -y
npm pkg set type=module
npm install hono@4.12.22 @hono/node-server@2.0.4 \
@upstash/ratelimit@2.0.8 @upstash/Redis@1.38.0
npm install -D TypeScript@6.0.3 tsx@4.22.3 @types/node@25.9.1
أضف ملف tsconfig.json مهيأ لوحدات Node ES الحديثة:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "nodenext",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
تم تفعيل noUncheckedIndexedAccess عمداً: يقوم كود تحديد المعدل بتحليل سلاسل ترويسات غير موثوقة، وإجبار TypeScript لك على التعامل مع undefined من كل فهرس مصفوفة هو شبكة أمان حقيقية.
استبدل كتلة scripts في package.json:
{
"scripts": {
"dev": "node --env-file=.env --import tsx src/index.ts",
"build": "tsc",
"start": "node --env-file=.env dist/index.js"
}
}
يقرأ Node 24 ملف .env بشكل أصلي باستخدام --env-file، لذا لا توجد تبعية لـ dotenv. أنشئ src/env.ts للتحقق من المتغيرات مرة واحدة عند بدء التشغيل بدلاً من اكتشاف رمز مفقود في منتصف الطلب:
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const env = {
UPSTASH_REDIS_REST_URL: required('UPSTASH_REDIS_REST_URL'),
UPSTASH_REDIS_REST_TOKEN: required('UPSTASH_REDIS_REST_TOKEN'),
PORT: Number(process.env.PORT ?? 3000),
TRUSTED_PROXY_HOPS: Number(process.env.TRUSTED_PROXY_HOPS ?? 0),
};
أنشئ API بسيطاً في src/index.ts — مسار واحد للتحقق من الجاهزية (liveness) ونقطة نهاية واحدة لحمايتها:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { env } from './env.js';
const app = new Hono();
app.get('/health', (c) => c.json({ status: 'ok' }));
app.get('/API/quote', (c) =>
c.json({ quote: 'Premature optimization is the root of all evil.' }),
);
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
console.log(`API listening on http://localhost:${info}`);
});
قم بتشغيل npm run dev وتأكد من أن curl http://localhost:3000/API/quote يعيد اقتباس JSON. حالياً، سيستجيب /API/quote لمليون طلب في الثانية دون اعتراض. هذه هي المشكلة التي سيصلحها باقي هذا البرنامج التعليمي.
الخطوة 3 — بناء محدد معدل بنافذة منزلقة
قبل توصيل أي شيء، اختر الخوارزمية. يقوم النافذة الثابتة (fixed window) بعد الطلبات في شرائح زمنية ثابتة — مثلاً، من 00:00:00 إلى 00:00:10. العيب هو الحدود: يمكن للمتصل إرسال نافذة كاملة من الطلبات في 00:00:09 ونافذة كاملة أخرى في 00:00:11، مما يؤدي إلى ضعف المعدل المقصود في ثانيتين4.
تعالج النافذة المنزلقة (sliding window) هذا الأمر. فهي لا تزال تقسم الوقت إلى شرائح، لكنها تزن الشريحة السابقة بمقدار تداخلها مع اللحظة الحالية. إذا كنت تسمح بـ 10 طلبات في الدقيقة وكنت قد مضيت 15 ثانية في النافذة الحالية مع وجود 4 طلبات في النافذة السابقة و 5 حتى الآن في هذه النافذة، فإن المحدد يقدر 4 * ((60 - 15) / 60) + 5 = 8 ويسمح بمرور الطلب4. يتم تنعيم انفجار الحدود، على حساب قراءة عداد النافذة السابقة أيضاً في كل فحص. بالنسبة لأي API تقريباً، تستحق هذه المقايضة العناء.
أنشئ src/ratelimit.ts. وهو يحدد ثلاث فئات مسبقاً ويبني محدداً واحداً لكل فئة:
import { Ratelimit, type Duration } from '@upstash/ratelimit';
import { Redis } from '@upstash/Redis';
import { env } from './env.js';
const Redis = new Redis({
url: envUPSTASH_REDIS_REST_URL,
token: envUPSTASH_REDIS_REST_TOKEN,
});
interface TierConfig {
limit: number;
window: Duration;
windowSeconds: number;
}
// مصدر واحد للحقيقة لحدود كل فئة.
export const TIERS = {
anon: { limit: 20, window: '10 s', windowSeconds: 10 },
free: { limit: 100, window: '60 s', windowSeconds: 60 },
paid: { limit: 1000, window: '60 s', windowSeconds: 60 },
} satisfies Record<string, TierConfig>;
export type Tier = keyof typeof TIERS;
function buildLimiter(tier: Tier): Ratelimit {
const cfg = TIERS[tier];
return new Ratelimit({
Redis,
prefix: `rl:${tier`,
limiter: RatelimitslidingWindow(cfg, cfg),
analytics: true,
});
}
export const limiters: Record<Tier, Ratelimit> = {
anon: buildLimiter('anon'),
free: buildLimiter('free'),
paid: buildLimiter('paid'),
};
ثلاثة أشياء تهم هنا. يقوم prefix بتمييز مفاتيح كل فئة في Redis حتى لا تتصادم المحددات أبداً. يأخذ Ratelimit.slidingWindow(limit, window) عدداً للطلبات وسلسلة مدة مثل '10 s' أو '60 s'. ويقوم analytics: true بتسجيل كل فحص حتى تتمكن من رؤية أعداد الطلبات المسموح بها والمحظورة في لوحة تحكم Rate Limit في Upstash Console — وهذا يضيف أمراً واحداً لـ Redis لكل استدعاء5.
استخدام المحدد هو استدعاء واحد. يعيد limiters.anon.limit(identifier) وعداً (promise) يتم حله إلى كائن يمكنك التصرف بناءً عليه:
const { success, limit, remaining, reset, reason } =
await limiterslimit('ip:203.0.113.7');
// success: false إذا تجاوز المتصل الحد
// limit: أقصى عدد طلبات في النافذة (20 لـ anon)
// remaining: الطلبات المتبقية في النافذة الحالية
// reset: طابع زمني Unix بالمللي ثانية عندما يتم تصفير النافذة
// reason: 'timeout' | 'cacheBlock' | 'denyList' | undefined
reset هو طابع زمني بالمللي ثانية. بالنسبة للنافذة المنزلقة، فإنه يشير إلى بداية النافذة التالية بدلاً من إعادة تعيين دقيقة لكل متصل، وهو أمر قريب بما يكفي لقيمة Retry-After4. الـ identifier هو أي سلسلة تقرر أنها تمثل "متصلاً واحداً" — واختيارها بشكل صحيح يمثل مشكلة بحد ذاتها.
الخطوة 4 — تحديد المتصلين دون الوثوق في الترويسات القابلة للتزييف
محدد المعدل (rate limiter) يكون بجودة المعرف الخاص به فقط. إذا تم تعيين كل طلب لنفس المفتاح، فأنت قد بنيت خانقاً عاماً (global throttle)؛ وإذا تمكن المهاجم من اختيار مفتاحه الخاص، فأنت لم تبنِ شيئاً.
بالنسبة لحركة المرور المجهولة، المفتاح الطبيعي هو عنوان IP الخاص بالعميل. النهج الساذج يقرأ القيمة الموجودة في أقصى اليسار من ترويسة X-Forwarded-For — وهي ثغرة أمنية حقيقية. ترويسة X-Forwarded-For هي قائمة مفصولة بفواصل يقوم كل بروكسي بإضافتها. المدخلات الموجودة في أقصى اليسار يتم توفيرها من قبل العميل وهي غير موثقة تماماً، لذا يمكن للمهاجم إرسال عنوان IP وهمي مختلف مع كل طلب وتجاوز محدد المعدل الذي يعتمد عليها6.
عنوان IP الوحيد الذي يمكنك الوثوق به هو العنوان الذي أضافته بنيتك التحتية الخاصة: عنوان نظير مقبس TCP، أو — عندما تعمل خلف بروكسيات عكسية تتحكم فيها — المدخل الذي أضافته تلك البروكسيات، محسوباً من جهة اليمين6. قم بإنشاء src/client-ip.ts:
import type { Context } from 'hono';
import { getConnInfo } from '@hono/node-server/conninfo';
import { env } from './env.js';
/**
* Resolve the client IP we can trust for rate limiting.
*
* The TCP socket peer address cannot be spoofed by the client. Behind
* reverse proxies you control, those proxies append the real client IP
* to X-Forwarded-For, so we count in from the RIGHT. The leftmost
* entries are attacker-controlled and must never key a limiter.
*/
export function getClientIp(c: Context): string {
const socketIp = getConnInfo(c).remote.address ?? 'unknown';
if (env.TRUSTED_PROXY_HOPS === 0) {
return socketIp;
}
const forwarded = c.req.header('x-forwarded-for');
if (!forwarded) {
return socketIp;
}
const chain = forwarded
.split(',')
.map((part) => part.trim())
.filter(Boolean);
// Each trusted proxy appends one entry, so the rightmost N entries are
// from your own infrastructure. The real client IP is the leftmost of
// those N — at index (length - N).
const index = chain.length - env.TRUSTED_PROXY_HOPS;
return chain[index] ?? socketIp;
}
تأتي getConnInfo من محول Hono Node وتعيد عنوان نظير المقبس الحقيقي7. عندما يكون التطبيق مواجهاً للإنترنت مباشرة، اضبط TRUSTED_PROXY_HOPS=0 واستخدم ذلك العنوان. عندما تضيف بروكسي واحداً — مثل مثيل Nginx، أو موازن حمل سحابي — اضبطه على 1؛ وبالنسبة لـ CDN أمام موازن الحمل، اضبطه على 2. يجب أن تتطابق القيمة مع نشرك الفعلي تماماً: إذا ضبطتها على قيمة عالية جداً، فستبدأ في الوثوق بالمدخلات التي يقدمها المهاجم مرة أخرى.
الخطوة 5 — تحديد فئة المتصل
يتشارك المتصلون المجهولون في حد يعتمد على عنوان IP، ولكن يجب تحديد المتصلين الموثقين بواسطة مفتاح API الخاص بهم. تحديد المتصلين المدفوعين بناءً على مفتاحهم بدلاً من عنوان IP الخاص بهم يعني أيضاً أن العميل لن يتم خنقه أبداً بسبب جار مزعج يشاركه عنوان IP الخاص بالمكتب.
قم بإنشاء src/tiers.ts لتعيين مفتاح API لفئة معينة:
import { type Tier } from './ratelimit.js';
// Demo key store. In production, look API keys up in your database
// (and cache the lookup) instead of hard-coding them.
const API_KEYS = new Map<string, Tier>([
['demo-free-key', 'free'],
['demo-paid-key', 'paid'],
]);
/** Map an incoming API key to a tier. No key, or an unknown key, is anonymous. */
export function resolveTier(apiKey: string | undefined): Tier {
if (apiKey) {
const tier = API_KEYS.get(apiKey);
if (tier) return tier;
}
return 'anon';
}
القاعدة صارمة عن عمد: المفتاح المفقود أو المفتاح غير المعترف به كلاهما يندرج تحت anon. لا يمكن للمتصل ترقية نفسه إلى فئة paid عن طريق اختراع مفتاح، لأن المفتاح غير المعروف يتم تحويله إلى أدنى فئة، وليس إلى خطأ. في خدمة حقيقية، استبدل Map الثابت ببحث في قاعدة البيانات — وقم بتخزينه مؤقتاً، لأن هذا يتم تشغيله مع كل طلب.
الخطوة 6 — بناء استجابات 429 صحيحة وفقاً للمواصفات
عندما يتجاوز المتصل الحد، تكون الاستجابة مهمة بقدر الرفض. الحالة الصحيحة هي 429 Too Many Requests8، وتخبر واجهة API جيدة التصميم العميل بالمدة التي يجب انتظارها باستخدام ترويسة Retry-After، والتي يمكن أن تكون قيمتها عدداً من الثواني9. يستخدمها العملاء وSDKs للتراجع بدلاً من الضغط عليك بشكل أقوى.
يساعد أيضاً الإعلان عن الحد في كل استجابة، وليس فقط في حالات الرفض، حتى يتمكن العملاء من خنق أنفسهم ذاتياً قبل الاصطدام بالحد. توجد اتفاقيتان للترويسات. الثلاثية الفعلية (de-facto) — X-RateLimit-Limit، X-RateLimit-Remaining، X-RateLimit-Reset — هي ما يرسله GitHub ومعظم واجهات API اليوم. تعمل IETF على توحيد بديل: يحدد draft-ietf-httpapi-ratelimit-headers حقول RateLimit و RateLimit-Policy المنظمة. لا تزال مسودة إنترنت — أحدث مراجعة بتاريخ أبريل 2026 ولم تصبح بعد RFC نهائياً10 — لذا فإن الخطوة العملية هي إرسال كليهما.
قم بإنشاء src/rate-limit-headers.ts:
import type { Context } from 'hono';
import { TIERS, type Tier } from './ratelimit.js';
/** The subset of an @upstash/ratelimit limit() response we need for headers. */
export interface RateLimitResult {
success: boolean;
limit: number;
remaining: number;
reset: number;
reason?: 'timeout' | 'cacheBlock' | 'denyList';
}
/**
* Attach rate-limit headers to the response and return the Retry-After
* value (seconds until the window resets).
*/
export function applyRateLimitHeaders(
c: Context,
tier: Tier,
result: RateLimitResult,
): number {
const retryAfter = Math.max(0, Math.ceil((result.reset - Date.now()) / 1000));
const safeRemaining = Math.max(0, result.remaining);
const policy = TIERS[tier];
// De-facto headers most HTTP clients and SDKs already understand.
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(safeRemaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset / 1000)));
// Emerging IETF structured fields (draft-ietf-httpapi-ratelimit-headers).
c.header('RateLimit-Policy', `"${tier}";q=${policy.limit};w=${policy.windowSeconds}`);
c.header('RateLimit', `"${tier}";r=${safeRemaining};t=${retryAfter}`);
return retryAfter;
}
يتم حساب retryAfter من reset (طابع زمني بالمللي ثانية) ناقص الوقت الحالي، مع تقريبه للصفر حتى لا يؤدي انحراف الساعة أبداً إلى انتظار سلبي. يتم إرسال X-RateLimit-Reset كطابع زمني Unix بالثواني، بما يطابق اتفاقية GitHub. تُقرأ قيمة IETF RateLimit-Policy كـ "anon";q=20;w=10 على أنها "تسمح سياسة anon بحصة قدرها 20 في نافذة مدتها 10 ثوانٍ"، ويقوم RateLimit بالإبلاغ عن الحصة المتبقية المباشرة والثواني المتبقية10.
الخطوة 7 — الربط معاً كبرمجية وسيطة (middleware) لـ Hono
الآن قم بتجميع القطع في برمجية وسيطة واحدة قابلة لإعادة الاستخدام. ينتج مساعد المصنع في Hono، وهو createMiddleware، برمجية وسيطة بأنواع TypeScript صحيحة، بما في ذلك أي متغيرات سياق (context variables) يقوم بتعيينها11.
قم بإنشاء src/rate-limit-middleware.ts:
import { createMiddleware } from 'hono/factory';
import { getClientIp } from './client-ip.js';
import { limiters, type Tier } from './ratelimit.js';
import { resolveTier } from './tiers.js';
import { applyRateLimitHeaders } from './rate-limit-headers.js';
export type AppEnv = {
Variables: {
rateLimit: { tier: Tier; limit: number; remaining: number };
};
};
export function rateLimit() {
return createMiddleware<AppEnv>(async (c, next) => {
const apiKey = c.req.header('x-api-key');
const tier = resolveTier(apiKey);
const clientIp = getClientIp(c);
// Anonymous callers are limited per IP; authenticated callers per key.
const identifier = tier === 'anon' ? `ip:${clientIp}` : `key:${apiKey}`;
const result = await limiters[tier].limit(identifier, {
ip: clientIp,
userAgent: c.req.header('user-agent'),
});
// Flush analytics + deny-list updates in the background. A long-lived
// Node process stays alive, so there is no need to await this.
result.pending.catch(() => {});
const retryAfter = applyRateLimitHeaders(c, tier, result);
if (!result.success) {
if (result.reason === 'denyList') {
return c.json(
{ error: 'forbidden', message: 'This request was blocked.' },
403,
);
}
c.header('Retry-After', String(retryAfter));
return c.json(
{
error: 'too_many_requests',
message: `Rate limit exceeded. Retry in ${retryAfter}s.`,
retryAfter,
},
429,
);
}
c.set('rateLimit', {
tier,
limit: result.limit,
remaining: Math.max(0, result.remaining),
});
await next();
});
}
بعض التفاصيل التي تستحق الذكر. تمرر مكالمة limit() كلاً من ip و userAgent جنباً إلى جنب مع المعرف؛ تستخدم الخطوة 8 هذه البيانات لفحوصات قائمة الحظر (deny-list). يحمل الوعد result.pending عمليات كتابة التحليلات وقائمة الحظر — في منصة بدون خادم (serverless) يجب عليك تسليمه إلى waitUntil، ولكن في خادم Node طويل الأمد تظل العملية حية لفترة كافية حتى تنتهي، لذا فإن استخدام .catch() بأسلوب "أطلق وانسَ" (fire-and-forget) هو أمر صحيح ويحافظ على سرعة الاستجابة12. تعيد الإصابة في قائمة الحظر 403 Forbidden بدلاً من 429، لأن ذلك المتصل ليس "سريعاً جداً" — بل هو محظور.
قم بتحديث src/index.ts لتطبيق البرمجية الوسيطة وقراءة نتيجتها:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { env } from './env.js';
import { rateLimit, type AppEnv } from './rate-limit-middleware.js';
const app = new Hono<AppEnv>();
// Liveness probe — deliberately NOT rate limited.
app.get('/health', (c) => c.json({ status: 'ok' }));
// Every route under /api is rate limited.
app.use('/api/*', rateLimit());
app.get('/api/quote', (c) => {
const rl = c.get('rateLimit');
return c.json({
quote: 'Premature optimization is the root of all evil.',
tier: rl.tier,
remaining: rl.remaining,
});
});
const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
console.log(`API listening on http://localhost:${info.port}`);
});
function shutdown(): void {
server.close(() => process.exit(0));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
يقوم app.use('/api/*', rateLimit()) بتطبيق محدد المعدل على كل مسار /api مع ترك /health دون مساس — فحص الصحة الذي يخضع لتحديد المعدل سيؤدي في النهاية إلى وضع علامة على خدمتك بأنها غير صحية تحت الضغط. نظراً لأن التطبيق مكتوب بـ AppEnv، فإن c.get('rateLimit') يكون مكتوباً بالكامل داخل المسار.
الخطوة 8 — التحصين ضد الفيضانات وإساءة الاستخدام
يعمل محدد المعدل، ولكن تحت فيضان حقيقي يكون لديه نقطتا ضعف: كل طلب ضار لا يزال يكلف أوامر Redis، ويمكن أن يؤدي تعثر Redis إلى تعطل واجهة API الخاصة بك معه. ثلاثة خيارات في منشئ Ratelimit تغلق تلك الفجوات وتضيف قائمة حظر للعملاء السيئين المعروفين. قم بتحديث buildLimiter في src/ratelimit.ts:
// Add near the top of src/ratelimit.ts, at module scope:
const ephemeralCache = new Map<string, number>();
function buildLimiter(tier: Tier): Ratelimit {
const cfg = TIERS[tier];
return new Ratelimit({
redis,
prefix: `rl:${tier}`,
limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
analytics: true,
ephemeralCache, // block known-bad callers from memory, 0 Redis calls
timeout: 1000, // fail open if Redis is slow or unreachable
enableProtection: tier === 'anon', // deny-list checks for anon traffic
});
}
ephemeralCache هو Map داخل العملية. بمجرد خضوع المتصل لتحديد المعدل، يتذكر محدد المعدل وقت إعادة التعيين الخاص به في الذاكرة ويرفض أي طلب آخر منه دون لمس Redis على الإطلاق، مع الإبلاغ عن reason: 'cacheBlock'13. أثناء حدوث فيضان من حفنة من عناوين IP، يؤدي هذا إلى تقليص آلاف أوامر Redis إلى صفر. قم بالتصريح عن Map في نطاق الوحدة (module scope) حتى يظل حياً عبر الطلبات.
timeout: 1000 يجعل محدد المعدل يفشل في وضع الفتح (fail open). إذا لم يتم حل مكالمة Redis في غضون 1000 مللي ثانية، فسيتم السماح بالطلب بدلاً من رفضه13. الافتراضي هو 5000 مللي ثانية. السبب: محدد المعدل موجود لحماية واجهة API الخاصة بك، ولا ينبغي أبداً أن يصبح هو السبب في تعطل واجهة API الخاصة بك. انقطاع Redis لفترة وجيزة يجعلك تتراجع إلى وضع "غير محدود" — وهو أمر مزعج — بدلاً من وضع "غير متصل بالإنترنت".
enableProtection: true تقوم بتفعيل قوائم الحظر14. عند تفعيلها، يتم التحقق من قيم ip و userAgent التي تمررها بالفعل إلى limit() مقابل قائمة حظر تديرها في لوحة تحكم Rate Limit في Upstash Console، بالإضافة إلى قائمة حظر تلقائية لعناوين IP (Auto IP Deny List) مبنية من خلاصات عناوين IP الخبيثة مفتوحة المصدر ويتم تحديثها يومياً حوالي الساعة 02:00 بالتوقيت العالمي المنسق. في حالة التطابق، يتم إرجاع success: false مع reason: 'denyList' — وهو ما تقوم البرمجية الوسيطة (middleware) في الخطوة 7 بتحويله بالفعل إلى استجابة 403. تضيف عمليات التحقق من قائمة الحظر أمرين من أوامر Redis لكل استدعاء5، لذا يقوم هذا البرنامج التعليمي بتمكينها فقط للمستوى المجهول (anonymous tier)، حيث يتركز سوء الاستخدام.
لحظر عنوان IP محدد يدوياً، افتح لوحة تحكم Rate Limit، واختر قاعدة بياناتك، وأضف القيمة إلى قائمة الحظر. المطابقة دقيقة — لا يوجد دعم للرموز العامة (wildcard) أو CIDR — لذا أضف العناوين الفردية.
التحقق
ابدأ الخادم باستخدام npm run dev. في نافذة أوامر ثانية، تأكد من نجاح طلب واحد وأنه يحمل الترويسات (headers):
curl -i http://localhost:3000/API/quote
يجب أن ترى HTTP/1.1 200 OK بالإضافة إلى X-RateLimit-Limit: 20، وقيمة X-RateLimit-Remaining متناقصة، وحقول RateLimit / RateLimit-Policy.
الآن تجاوز الحد المجهول البالغ 20 طلباً لكل 10 ثوانٍ بدفعة سريعة:
for i in $(seq 1 25); do
curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/API/quote
done
echo
ستطبع أول 20 طلباً 200 والباقي سيطبع 429 — خوارزمية النافذة المنزلقة (sliding window) قيد التنفيذ. افحص استجابة مرفوضة وتأكد من أنها تحمل Retry-After:
curl -i http://localhost:3000/API/quote
# HTTP/1.1 429 Too Many Requests
# retry-after: 7
# {"error":"too_many_requests","message":"Rate limit exceeded. Retry in 7s.","retryAfter":7}
أخيراً، أثبت أن المستويات مستقلة. مفتاح العرض المدفوع (paid demo key) لديه حد 1000 طلب، لذا سيمر بنجاح بينما لا تزال حركة المرور المجهولة محظورة:
curl -s -H "x-API-key: demo-paid-key" http://localhost:3000/API/quote
# {"quote":"Premature optimization is the root of all evil.","tier":"paid","remaining":999}
مع تفعيل analytics: true، ستظهر أعداد الطلبات المسموح بها والمحظورة من هذه العمليات في لوحة تحكم Rate Limit في Upstash Console.
استكشاف الأخطاء وإصلاحها
كل طلب يتم رفضه فوراً، حتى الأول. من المحتمل أن جميع المتصلين يندرجون تحت معرف واحد. قم بتسجيل identifier في الجزء العلوي من البرمجية الوسيطة: إذا كانت نفس السلسلة النصية لعملاء مختلفين، فإن getConnInfo ترجع قيمة واحدة (شائع عندما يكون التطبيق خلف وكيل (proxy) ولكن TRUSTED_PROXY_HOPS هي 0، لذا يظهر كل طلب بعنوان IP الخاص بالوكيل). اضبط TRUSTED_PROXY_HOPS على عدد الوكلاء الحقيقيين لديك.
المهاجمون يتجاوزون الحد تماماً. من المؤكد تقريباً أنك تعتمد على قيمة X-Forwarded-For الموجودة في أقصى اليسار في مكان ما. هذا الإدخال يقدمه العميل؛ ويقوم المهاجم بتغييره مع كل طلب ولا يشارك مفتاحاً أبداً. استخدم عنوان IP الموثوق من الخطوة 4 — عنوان المقبس (socket address) أو القفزة الموثوقة في أقصى اليمين — في كل مكان يتم فيه تحديد هوية المتصل.
Error: Missing required environment variable: UPSTASH_REDIS_REST_URL. لم يتم تحميل ملف .env. تأكد من وجود الملف في جذر المشروع وأن نص dev يتضمن --env-file=.env. تشغيل tsx src/index.ts مباشرة يتخطى ذلك، لأن tsx لا يحمل .env من تلقاء نفسه.
تنجح الطلبات حتى لو كانت بيانات اعتماد Redis خاطئة. هذا هو خيار timeout الذي يعمل كما هو مصمم له. مع عدم إمكانية الوصول إلى Redis، تنتهي مهلة limit() بعد 1000 مللي ثانية وتسمح بالمرور (fail open)، لذا يتم السماح بالطلب مع reason: 'timeout'. قم بإصلاح UPSTASH_REDIS_REST_URL والرمز المميز (token)؛ إذا كنت تفضل الرفض عند الفشل (fail closed)، فتحقق من result.reason === 'timeout' في البرمجية الوسيطة وقم بالرفض.
التحليلات لا تظهر أبداً في لوحة التحكم. هناك سببان معتادان. يجب أن يتطابق محدد البادئة (prefix selector) في لوحة التحكم مع prefix الذي قمت بضبطه في المحدد (rl:anon، rl:free، rl:paid) — حيث يتم تخزين التحليلات لكل بادئة. وعلى المنصات عديمة الخادم (serverless)، يجب انتظار وعد pending أو تسليمه إلى waitUntil، وإلا ستنتهي العملية قبل اكتمال كتابة التحليلات؛ أما في خادم Node طويل الأمد، فهذه ليست مشكلة.
الخطوات التالية ومزيد من القراءة
لديك الآن نظام تحديد معدل الطلبات بجودة إنتاجية: محدد نافذة منزلقة، تحديد هوية المتصل المقاوم للتزييف، حدود متعددة المستويات، استجابات 429 صحيحة، ذاكرة تخزين مؤقت مؤقتة، مهلة تسمح بالمرور عند الفشل، وقوائم حظر. إليك بعض التوجهات من هنا:
- حدود لكل مسار (Per-route limits). قم بتطبيق محدد أكثر صرامة على المسارات المكلفة عن طريق إنشاء مستوى آخر (على سبيل المثال محدد
searchبمعدل 5 طلبات لكل 10 ثوانٍ) وتركيبه باستخدامapp.use('/API/search', searchRateLimit()). - حدود مرجحة بالتكلفة (Cost-weighted limits). يقوم
limiters.paid.limit(id, { rate: batchSize })بخصم أكثر من رمز مميز واحد لطلب أثقل — وهو مفيد عندما تعالج مكالمة واحدة دفعة من البيانات13. - تخزين البيانات خلف المحدد مؤقتاً. ادمج تحديد معدل الطلبات مع طبقة تخزين مؤقت بحيث تكون الطلبات التي تنجح في المرور غير مكلفة؛ راجع أنماط التخزين المؤقت في Redis للأنظمة القابلة للتوسع.
- تصميم الحدود نفسها. لمعرفة كيفية اختيار حدود عادلة وواعية بالتكلفة عبر واجهة API، راجع تحديد معدل طلبات الذكاء الاصطناعي: العدالة والتكلفة والنطاق و استراتيجيات إصدار API.
الحواشي السفلى
-
@upstash/ratelimit2.0.8 و@upstash/Redis1.38.0، تم التحقق منها في سجل npm، 24 مايو 2026. https://www.npmjs.com/package/@upstash/ratelimit ↩ -
إصدارات Node.js — Node.js 24 هو خط LTS النشط الحالي، مدعوم حتى أبريل 2028. https://nodejs.org/en/about/previous-releases ↩
-
أسعار وحدود Upstash Redis، تم الوصول إليها في 24 مايو 2026. https://upstash.com/docs/Redis/overall/pricing ↩
-
خوارزميات تحديد المعدل — النافذة الثابتة، النافذة المنزلقة، ودلو الرموز، وثائق Upstash. https://upstash.com/docs/Redis/sdks/ratelimit-ts/algorithms ↩ ↩2 ↩3
-
التكاليف — أعداد أوامر Redis لكل خوارزمية، والتحليلات، وقوائم الحظر، وثائق Upstash. https://upstash.com/docs/Redis/sdks/ratelimit-ts/costs ↩ ↩2
-
"مخاطر عنوان IP العميل 'الحقيقي'"، بقلم adam-p، حول سبب وجوب استخدام الكود الحساس للأمان لأقصى إدخال موثوق جهة اليمين في
X-Forwarded-For. https://adam-p.ca/blog/2022/03/x-forwarded-for/ ↩ ↩2 -
مساعد ConnInfo، توثيق Hono —
getConnInfoلمحول Node.js. https://hono.dev/docs/helpers/conninfo ↩ -
RFC 6585، أكواد حالة HTTP الإضافية — القسم 4، "429 Too Many Requests." https://www.rfc-editor.org/rfc/rfc6585 ↩
-
RFC 9110، دلالات HTTP — القسم 10.2.3، حقل رأس
Retry-After. https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3 ↩ -
"حقول رأس RateLimit لـ HTTP"، draft-ietf-httpapi-ratelimit-headers، مجموعة عمل IETF HTTPAPI، أحدث مراجعة 18 أبريل 2026 — لا تزال مسودة إنترنت. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ ↩ ↩2
-
مساعد المصنع —
createMiddleware، توثيق Hono. https://hono.dev/docs/helpers/factory ↩ -
الأساليب — استجابة
limit()ووعدpending، توثيق Upstash. https://upstash.com/docs/Redis/sdks/ratelimit-ts/methods ↩ -
الميزات — التخزين المؤقت، المهلة، التحليلات، المعدلات المخصصة، توثيق Upstash. https://upstash.com/docs/Redis/sdks/ratelimit-ts/features ↩ ↩2 ↩3
-
حماية حركة المرور — قوائم الحظر وقائمة الحظر التلقائي لعناوين IP، توثيق Upstash. https://upstash.com/docs/Redis/sdks/ratelimit-ts/traffic-protection ↩