تحديد معدل الطلبات في Next.js App Router: ما الذي ينكسر على الخوادم بلا حالة وكيف نصلحه
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أهم النقاط
محدّد معدل الطلبات المعتمد على Map في ذاكرة العملية يعمل لكل نسخة دالة على حدة في البيئات بلا حالة. مع عشر نسخ دوال نشطة، يسمح حدّ «10 طلبات في الدقيقة» فعلياً بمئة طلب في الدقيقة.
وسيط Next.js (middleware) هو أرخص مكان لإسقاط الحركة المسيئة لأنه يعمل قبل إقلاع دالة المسار نفسه، لكنه يرى أيضاً طلبات الجلب المسبق لمكوّنات الخادم (RSC prefetch) التي تحمل الترويسة `RSC: 1` والتي لم يطلبها المستخدم عمداً.
الخاصية `NextRequest.ip` أُزيلت في Next.js 15. على Vercel اقرأ عنوان العميل عبر `ipAddress(request)` من حزمة `@vercel/functions` بدل الوثوق بترويسة `x-forwarded-for` الخام.
كل Server Action في Next.js يرسل POST إلى عنوان الصفحة الحالية ويحمل ترويسة `Next-Action` مولّدة أثناء البناء، لذا فإن المكان الموثوق الوحيد لتحديد معدل إجراء بعينه هو داخل جسم الإجراء.
ارفض الطلب بالحالة 429 مع ترويسة `Retry-After`، وقرّر لكل مسار سياسة الفشل المفتوح أو المغلق قبل أن يتعطل Redis لأول مرة.
لماذا ينكسر محدّد المعدل المخزّن في الذاكرة على الخوادم بلا حالة؟
محدّد المعدل هو عدّاد له موعد نهائي: اسمح بعدد N من الطلبات لكل هوية في نافذة زمنية، وارفض الباقي بالحالة 429. لكن Map معرّفة على مستوى الوحدة تبقى خاصة بنسخة دالة واحدة، ومنصة بلا حالة تشغّل نسخاً كثيرة في الوقت نفسه. لذلك تفرض كل نسخة الحدّ كاملاً بمفردها، ويصبح الحدّ الفعلي هو الحدّ المضبوط مضروباً في عدد النسخ النشطة — وهو رقم لا تتحكم فيه ولا تستطيع ملاحظته من داخل الطلب.
تقنية Fluid Compute من Vercel تجعل اكتشاف هذا العطل أصعب لا أسهل. فهي تعيد استخدام نسخة دالة واحدة عبر طلبات متزامنة بدل إنشاء نسخة لكل طلب، ما يجعل العدّاد داخل العملية يعيش أطول بكثير مما كان عليه في النموذج التقليدي. في التطوير المحلي وفي نشرة معاينة هادئة يبدو المحدّد سليماً، ولا ينهار إلا تحت حركة موزّعة على عدد كافٍ من النسخ، ولا شيء في السجلات يعلن ذلك.
وللـ Map عيب ثانٍ: إنها لا تتقلص أبداً. على نسخة طويلة العمر يبقى كل مفتاح فريد شوهد يوماً مقيماً في الذاكرة حتى إعادة تدوير النسخة، وهو تسريب ذاكرة بطيء يرتدي زيّ محدّد معدل.
الحل ليس Map أذكى. يجب أن يعيش العدّاد في مخزن تتشاركه كل النسخ ويدعم زيادة ذرّية — Redis أو أي قاعدة بيانات فيها عملية مقارنة وتعيين. في Devya نعتمد Upstash Redis افتراضياً هنا لأنه يتحدث HTTP ولا يحتاج تجمّع اتصالات، وهو أمر مهم عندما يكون المستدعي دالة قصيرة العمر.
هل يعمل الحدّ في الوسيط أم في معالج المسار؟
في الاثنين، لمهمتين مختلفتين. يعمل وسيط Next.js قبل أن يحسم إطار العمل المسار، لذا فالطلب المرفوض هناك لا يُقلع دالة المسار ولا يلمس قاعدة البيانات. هذا يجعل الوسيط المكان الصحيح لحدّ خشن ضد الإساءة لا يعرف الهوية. أما معالج المسار فيعرف المستخدم المصادق عليه وجسم الطلب المحلّل والمعنى التجاري للاستدعاء، ما يجعله المكان الصحيح لحصة لكل مستخدم.
في الوسيط نطابق فقط المسارات التي تستحق الحماية عبر `config.matcher`، ونتجاوز طلبات الجلب المسبق، ونشتق عنوان العميل بـ `ipAddress(request)`، ثم نستدعي المحدّد المشترك ونعيد `NextResponse.json` بالحالة 429 عند الفشل. وما عدا ذلك يمر عبر `NextResponse.next()`.
استثناء الجلب المسبق هو السطر الذي تضيفه معظم الفرق ثانياً، بعد رؤية الحصة تتآكل دون أي نشاط من المستخدم. موجّه Next.js يجلب المسارات المرتبطة مسبقاً عند مرور المؤشر وعند دخولها منطقة العرض، وهذه الطلبات حقيقية وتصل إلى الوسيط حاملة الترويسة `RSC: 1`. احتسابها يعني أن زائراً يتصفح صفحة كثيفة الروابط سيُحدَّد معدله قبل أن ينقر شيئاً. إما أن تتجاوزها أو أن تمنحها سلّة منفصلة سخية.
تفصيل إضافي: منذ Next.js 15.5 يمكن للوسيط أن يعمل على بيئة Node.js عبر `export const config = { runtime: 'nodejs' }`، ما يتيح استخدام عميل Redis تقليدي ووحدات Node المدمجة. ما زلنا نفضّل مخزناً عبر HTTP في الوسيط، لأن الوسيط يقع على مسار زمن الاستجابة لكل طلب مطابق، وجولة ذهاب وإياب واحدة متوقعة أفضل من إدارة دورة حياة اتصال هناك.
ملخص الموضع: الوسيط يرى العنوان والترويسات والكوكيز وعنوان IP، وتكلفة الرفض فيه الأدنى، ويناسب الحماية من الإساءة على مستوى IP. معالج المسار يرى كل شيء بما فيه الجلسة والجسم، وتكون الدالة قد أقلعت فعلاً، ويناسب الحصص لكل مستخدم والتحكم في تكلفة كل نقطة نهاية. جسم Server Action يرى الجلسة والوسائط المكتوبة النوع، ويناسب إرسال النماذج وعمليات التعديل.
أي خوارزمية أستخدم فعلياً؟
اختر أرخص خوارزمية يمكنك التعايش مع نمط فشلها. أربع خوارزميات شائعة، والفارق بينها يتعلق كلياً بسلوك الدفقات المفاجئة.
النافذة الثابتة تحتفظ بعدّاد واحد لكل نافذة، فيكون الفحص أمر INCR واحداً. عيبها عند الحدود: عشرة طلبات عند 11:59:59 وعشرة أخرى عند 12:00:00 تمر جميعها تحت حدّ «عشرة في الدقيقة» بينما تسلّم عشرين طلباً في ثانية واحدة.
سجل النافذة المنزلقة يخزّن طابعاً زمنياً لكل طلب وهو دقيق، لكن ذاكرته تنمو مع الحركة — وهو الاتجاه الخاطئ لدفاع ضد الحركة.
عدّاد النافذة المنزلقة يرجّح النافذة السابقة بمقدار تداخلها مع النافذة الحالية. هو تقريب بذاكرة محدودة وبلا دفقة عند الحدود، وهو خيارنا الافتراضي.
سلّة الرموز (token bucket) تمنح كل هوية سعة ومعدل تعبئة، فيستطيع عميل صامت أن ينفق رموزه المدّخرة دفعة واحدة. هذا الخيار الصحيح لواجهات برمجية يجمّع عملاؤها الطلبات بشكل مشروع.
حزمة `@upstash/ratelimit` توفّر الأربع باسم `fixedWindow` و`slidingWindow` و`tokenBucket` و`cachedFixedWindow`. وعند كتابة محدّد مباشرةً فوق Redis فالأهم هو الذرّية: تنفيذ INCR ثم EXPIRE منفصلاً يعني جولتين، وموت العملية بينهما يترك مفتاحاً بلا انتهاء صلاحية فيُقفل على تلك الهوية إلى الأبد. نفّذ الزيادة و PEXPIRE المشروط داخل سكربت Lua واحد بحيث تُضبط مدة الصلاحية عند أول إصابة في النافذة فقط.
كيف أحدد هوية العميل وكل شيء خلف وكيل؟
جودة الحدّ من جودة مفتاحه، وعلى منصة استضافة يصل كل طلب من حافة المنصة نفسها. `NextRequest.ip` و`NextRequest.geo` أُزيلتا في Next.js 15، فصارت قراءة `request.ip` خطأ في الأنواع بدل أن تكون إجابة خاطئة خفية. البديل على Vercel هو `ipAddress(request)` من `@vercel/functions`.
لا تبنِ المفتاح على ترويسة `x-forwarded-for` الخام إلا إذا كنت واثقاً أن وكيلاً موثوقاً يعيد كتابتها. فهي ترويسة طلب عادية، وعلى أي أصل يمكن الوصول إليه مباشرة يستطيع المهاجم ضبطها بقيمة جديدة لكل طلب فيحصل على عدد غير محدود من السلال الجديدة. هذا ليس فشلاً جزئياً للمحدّد بل تجاوز كامل له.
ولـ IPv6 قاعدة خاصة. يُخصَّص للمشترك المنزلي الواحد عادةً نطاق /64 كامل، لذا فبناء المفتاح على العنوان الكامل بطول 128 بت يمنح مهاجماً واحداً 2^64 هوية مختلفة. اجعل مفتاح عملاء IPv6 على بادئة /64 — أول أربع مجموعات — بدل العنوان الكامل.
وحين يكون الطلب مصادقاً عليه، ابنِ المفتاح على معرّف المستخدم أو مفتاح الواجهة البرمجية. عنوان IP هو البديل للمسارات المجهولة، لا الخيار الافتراضي.
كيف أحدد معدل Server Action وكل الإجراءات POST إلى العنوان نفسه؟
Server Action دالة مُعلَّمة بالتوجيه `'use server'` يستدعيها العميل عبر الشبكة. الاستدعاء هو POST إلى عنوان الصفحة التي استُدعي منها الإجراء، ويحمل ترويسة `Next-Action` بمعرّف مولّد أثناء البناء لذلك الإجراء تحديداً. لا يوجد مسار مخصص، وهذا بالضبط ما يكسر المقاربة البديهية.
في الوسيط، طلب POST من نموذج تواصل على /contact وطلب POST من زر حذف الحساب على /contact لهما العنوان نفسه والطريقة نفسها. الحدّ المبني على المسار إما يعاملهما بالمثل أو لا ينطبق أصلاً. قراءة `request.headers.get('next-action')` تميّز طلبات الإجراءات عن طلبات المستندات العادية، لكن ذلك المعرّف تجزئة تتغير بين البناءات، لذا تفرّع على وجودها لا على قيمتها.
المكان الموثوق هو داخل جسم الإجراء، حيث تتوفر الجلسة والوسائط المكتوبة النوع أصلاً. نحلّ الجلسة، ونبني مفتاحاً مثل `action:sendMessage:<userId>`، ثم نستدعي المحدّد ونعيد قيمة فشل عند تجاوز الحدّ.
إعادة قيمة بدل رمي استثناء أمر مهم. الخطأ غير الملتقط داخل Server Action يُحجب في الإنتاج ويصل إلى العميل كرسالة عامة مع digest، فيُقال للمستخدم إن شيئاً ما فشل بدل أن يُقال له انتظر أربعين ثانية. تحديد المعدل نتيجة طبيعية لا استثناء.
ماذا يجب أن تحتوي استجابة 429؟
الحالة الصحيحة هي 429 Too Many Requests، وترويسة `Retry-After` هي ما يجعل المحدّد صالحاً لأي عميل غير إنسان يحدّق في متصفح. قيمتها إما عدد ثوانٍ أو تاريخ HTTP. بدونها لا يملك العميل المهذّب إلا التخمين، والعملاء الذين يخمّنون يعيدون المحاولة مبكراً جداً.
وإلى جانبها أرسل حالة الحدّ. اصطلاح `X-RateLimit-Limit` و`X-RateLimit-Remaining` و`X-RateLimit-Reset` ليس معياراً رسمياً لكنه ما تحلّله معظم حزم التطوير فعلاً. ومسوّدة IETF المسماة `draft-ietf-httpapi-ratelimit-headers` تعرّف `RateLimit` و`RateLimit-Policy` كحقول بنيوية، ويستحق تبنّيها فقط حين يفهمها مستهلكوك.
خطآن شائعان: إعادة 403 لحدّ معدل، وهو ما يخبر العميل بأن يتوقف إلى الأبد بدل أن يعيد المحاولة؛ وإعادة 500، وهو ما يلوّث معدل الأخطاء بدفاعاتك وهي تعمل بشكل صحيح.
ثم قرّر ما يحدث حين يتعذّر الوصول إلى المخزن، لأن ذلك سيحدث. الفشل المفتوح على نقطة تسجيل الدخول يحوّل عطل Redis إلى نافذة مفتوحة لهجمات التخمين. والفشل المغلق على نقطة قراءة عامة يحوّل عطل Redis إلى تعطّل كامل للموقع. اختر لكل مسار: افشل مغلقاً على المصادقة وعلى كل ما ينفق مالاً، وافشل مفتوحاً على القراءات.
أسئلة شائعة
**س:** هل يمكن تحديد المعدل في Next.js بدون Redis؟
**ج:** فقط إذا كان التطبيق يعمل كعملية واحدة طويلة العمر، مثل نسخة حاوية واحدة. في أي نشر بلا حالة أو ذاتي التوسّع يجب أن يعيش العدّاد في مخزن مشترك بين النسخ، لأن الحالة داخل العملية تُضرب في عدد النسخ.
**س:** هل يعمل وسيط Next.js على طلبات الجلب المسبق RSC؟
**ج:** نعم. الجلب المسبق للموجّه طلبات HTTP حقيقية تطابق `matcher` الوسيط وتحمل الترويسة `RSC: 1`. استثنِها من الحصص الموجّهة للمستخدم وإلا استهلكت ميزانية الزائر قبل أن ينقر شيئاً.
**س:** كيف أحصل على عنوان IP للعميل في Next.js 15 و Next.js 16؟
**ج:** `NextRequest.ip` أُزيلت في Next.js 15. على Vercel استدعِ `ipAddress(request)` من `@vercel/functions`. في غير ذلك اقرأ ترويسة التوجيه التي يضبطها وكيلك الموثوق، وتأكد أن الوكيل يعيد كتابتها لا أن يضيف إليها.
**س:** هل يفشل محدّد المعدل مفتوحاً أم مغلقاً عند تعطل Redis؟
**ج:** قرّر لكل مسار. افشل مغلقاً على تسجيل الدخول والتسجيل وإعادة تعيين كلمة المرور ونقاط الدفع، حيث يخلق الفشل المفتوح ثغرة أمنية. وافشل مفتوحاً على القراءات العامة، حيث يحوّل الفشل المغلق عطل تبعية إلى تعطّل موقع.
**س:** هل Vercel BotID بديل عن تحديد المعدل؟
**ج:** لا. Vercel BotID أداة كشف روبوتات تجيب عمّا إذا كان المستدعي آلياً. أما حدّ المعدل فيجيب عن عدد المرات التي يحق فيها لأي مستدعٍ، إنساناً كان أو لا، أن ينفّذ عملية مكلفة. الاثنان يدافعان عن أشياء مختلفة ويتكاملان جيداً.
قراءات إضافية
هندسة الويب المتكاملة
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر --heapsnapshot-signal، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.
هندسة الويب المتكاملة
لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.