أكبر كذبة في Express: كيف خدعنا async/await وكاد أن يدمر الخادم

أذكرها وكأنها البارحة، ليلة خميس هادئة، وفنجان القهوة بجانبي وأنا أستعد لإنهاء بعض المهام قبل عطلة نهاية الأسبوع. فجأة، بدأت التنبيهات تنهال على هاتف العمل كالمطر: “High Latency Detected”، “5xx Error Rate Spike”. فتحت لوحة المراقبة لأجد كارثة صامتة: الطلبات تستغرق وقتًا طويلًا جدًا لتستجيب، والـ Load Balancer يقوم بإغلاقها (timeout)، والذاكرة في الخوادم ترتفع بشكل جنوني.

يا زلمة شو القصة؟ نظرت في سجلات الأخطاء (logs)، لا شيء! الخادم لا ينهار، ولا يوجد `uncaughtException` واضحة. كل شيء يبدو طبيعيًا ظاهريًا، لكن النظام كان يحتضر ببطء. بعد ساعات من الحفر والتدقيق، أنا وفريق العمل، وجدنا المشكلة في مكان لم نتوقعه أبدًا: دالة `async` بسيطة في أحد الـ routes، كانت تحتوي على عملية غير متزامنة داخل `setTimeout`، وعندما كان يحدث خطأ بداخلها، كان “يهرب” دون أن يراه أحد. لم يتم استدعاء `next(err)`، ولم يتم إرسال استجابة خطأ. بقي الطلب معلّقًا في الهواء، يستهلك الموارد حتى يموت وحيدًا.

تلك الليلة، تعلمت درسًا قاسيًا: `async/await` في Express ليست العصا السحرية التي تحل كل شيء. إنها مجرد “سكر نحوي” (syntactic sugar) جميل، لكن تحته تكمن نفس القواعد القديمة للبرمجة غير المتزامنة. دعوني أشرح لكم هذه الكذبة الكبيرة، وكيف نحمي أنفسنا منها.

الكارثة الصامتة: كيف يتحول خطأ بسيط إلى انهيار في بيئة الإنتاج

عندما يفلت خطأ غير مُدار في تطبيق Express، فهو لا يصرخ دائمًا طالبًا النجدة. غالبًا ما يختبئ في الظل، مسببًا أعراضًا غريبة ومضللة. هذه المشكلة لها ثلاث نهايات شائعة، وكلها سيئة:

  1. Crash مفاجئ للـ Process: هذا هو السيناريو “الأفضل” نسبيًا، لأنه واضح. يحدث عندما يخرج خطأ غير مُدار تمامًا (مثل `uncaughtException` أو `unhandledRejection`) خارج نطاق Express، مما يؤدي إلى انهيار عملية Node.js بأكملها. إذا كنت تستخدم مدير عمليات مثل PM2، فسيقوم بإعادة تشغيلها، لكنك ستظل ترى انقطاعات في الخدمة.
  2. طلبات معلّقة (Hanging requests): هذا هو السيناريو الأخطر والأكثر خبثًا. يحدث عندما يقع خطأ في مسار التنفيذ لكنك لا ترسل استجابة للعميل (`res.send`, `res.json`, etc.) ولا تمرر الخطأ إلى الـ middleware التالي (`next(err)`). يبقى الطلب معلقًا، والاتصال مفتوحًا، ومع تزايد هذه الطلبات، ترتفع نسبة استهلاك الذاكرة وعدد الاتصالات المفتوحة حتى يختنق الخادم ويموت ببطء.
  3. تشخيص مضلل وخسائر زمنية: هذا هو الأثر الجانبي للسيناريو الثاني. لأن الخطأ الحقيقي لا يظهر في سجلات الأخطاء المرتبطة بالطلب، ستقضي ساعات في البحث في المكان الخطأ. سترى أخطاء `timeout` في الـ Load Balancer أو عند العميل، وستظن أن المشكلة في الشبكة أو في قاعدة البيانات، بينما الجاني الحقيقي هو خطأ بسيط هارب في الكود الخاص بك.

الجذر التقني: ليش Express ما “بلقط” كل الأخطاء؟

السبب بسيط: Express لا يملك “فقاعة سحرية” تحيط بكل شيء يحدث في تطبيقك. سلسلة الـ middleware تعمل بشكل خطي ومتوقع. عندما تستدعي دالة الـ handler الخاصة بمسار معين، Express ينتظر منك أحد أمرين: إما أن ترسل استجابة، أو أن تستدعي `next()` للانتقال للتالي. إذا حدث خطأ، فهو يتوقع منك استدعاء `next(err)`.

المشكلة تظهر مع العمليات غير المتزامنة التي تخرج عن هذا المسار:

  • الـ Callbacks القديمة: إذا نفّذت كودًا غير متزامن عبر callback (مثل `setTimeout` أو `fs.readFile(path, cb)`), فأي `throw new Error()` يحدث داخل الـ callback يقع خارج سياق الـ try/catch الخاص بـ Express. لقد انتهى دور Express بمجرد استدعاء دالتك، وما يحدث لاحقًا في الـ Event Loop هو مسؤوليتك.
  • حتى مع `async/await`: أنت في أمان نسبي فقط إذا كان الـ handler الخاص بك عبارة عن دالة `async` تُرجع Promise. في هذه الحالة، يمكن لـ Express (خاصة في الإصدار 5) أن يمسك بالـ Promise rejection ويحوله تلقائيًا إلى `next(err)`. لكن هذا لا يغطي كل الحالات. ما زالت هناك فئة كبيرة من الأخطاء التي لا تقع ضمن سلسلة الـ Promise (مثل الأحداث events أو الـ streams أو الـ callbacks التي ذكرناها) وتحتاج إلى تعامل صريح.

الفكرة المحورية: في Express، “الخطأ الذي لا يصل إلى `next(err)`، غالبًا لن يصل أبدًا إلى الـ error middleware”.

مثال حي: الخطأ الهارب

دعونا نرى مثالًا بسيطًا يوضح هذه الكارثة.

4.1 خطأ يفلت لأنه داخل callback

تخيل هذا الكود في تطبيقك:

import express from "express";
const app = express();

app.get("/demo", (req, res) => {
  res.json({ ok: true });

  setTimeout(() => {
    // هذا الخطأ يحدث خارج سياق Express تمامًا
    // وسيتسبب في انهيار العملية (uncaughtException)
    throw new Error("Boom after response!");
  }, 10);
});

// هذا الـ middleware لن يمسك بالخطأ أبدًا
app.use((err, req, res, next) => {
  console.error("Captured by error middleware:", err);
  res.status(500).json({ error: "Internal Server Error" });
});

app.listen(3000);

ماذا تتوقع أن يحدث عند طلب `/demo`؟ العميل سيحصل على استجابة `{ ok: true }` فورًا، وبعد 10 ميللي ثانية، ستنهار عملية Node.js بأكملها بسبب خطأ غير معالج. الـ error middleware لن يتم استدعاؤه أبدًا.

4.2 الإصلاح اليدوي: لا ترمِ الخطأ، مرّره!

الطريقة الصحيحة للتعامل مع هذا هي التقاط الخطأ يدويًا وتمريره إلى `next`.

app.get("/demo", (req, res, next) => {
  setTimeout(() => {
    try {
      // لنفترض أن هذا الخطأ يجب أن يوقف الطلب
      throw new Error("Boom in callback!");
    } catch (err) {
      // الآن نحن نمرر الخطأ بشكل صريح إلى Express
      next(err);
    }
  }, 10);
});

هذا الحل يعمل، لكن تخيل أن عليك كتابة `try/catch` في كل callback أو promise في تطبيقك. الكود سيصبح فوضويًا وغير قابل للقراءة. هنا يأتي دور الحل الأنيق.

النمط الذهبي للإنتاج: الـ Async Wrapper المنقذ

إذا كنت تستخدم `async/await` بكثرة في الـ routes (وهو ما يجب عليك فعله)، فلا تعتمد على ذاكرتك لوضع `try/catch` في كل مكان. استخدم دالة مساعدة (wrapper) تقوم بهذا العمل نيابة عنك بشكل موحد.

5.1 صناعة الـ Wrapper البسيط

هذه الدالة الصغيرة هي منقذك. كل ما تفعله هو أنها تأخذ دالة handler، وتُرجع دالة جديدة تمسك بأي خطأ يحدث داخلها وتمرره إلى `next`.

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

5.2 تطبيقه في الـ Routes

الآن، قم بلف كل route handler غير متزامن بهذه الدالة:

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id); // مثال لعملية async

  if (!user) {
    // لا حاجة لـ next() هنا، فقط أرسل استجابة
    return res.status(404).json({ error: "Not Found" });
  }

  // أي خطأ يحدث في db.users.findById سيمسكه الـ asyncHandler
  // وأي throw new Error() هنا سيمسكه أيضًا
  res.json({ user });
}));

ماذا كسبنا؟ (الخلاصة يا جماعة الخير)

  • كود نظيف: لا حاجة لتكرار `try/catch` في كل route.
  • معالجة موحدة: أي `throw` داخل دالة `async` أو أي `Promise` يتم رفضه (rejection) سيتم التقاطه وتمريره إلى `next(err)` تلقائيًا.
  • موثوقية عالية: أنت تضمن أن الأخطاء لن تفلت وتتسبب في تعليق الطلبات.

الفخ الأخطر: أرسلت الـ Response ثم رميت خطأ

هذا خطأ شائع جدًا أراه في كثير من المشاريع. المبرمج يرسل استجابة ناجحة للعميل، ثم يكمل تنفيذ بعض العمليات غير المتزامنة في الخلفية (مثل إرسال إيميل، تحديث سجلات، …إلخ).

// مثال سيء جدًا
app.post("/order", asyncHandler(async (req, res) => {
  const order = await createOrder(req.body);

  res.status(201).json({ orderId: order.id });

  // عملية لاحقة قد تفشل
  await sendNotificationEmail(order.userEmail); // ماذا لو فشلت هذه؟
}));

إذا فشلت `sendNotificationEmail`، سيحاول الـ `asyncHandler` استدعاء `next(err)`. لكن المشكلة أنك أرسلت استجابة للعميل بالفعل! النتيجة؟ ستحصل على خطأ فادح في الـ console يقول `Error: Can’t set headers after they are sent to the client`، وقد يؤدي ذلك إلى انهيار العملية.

نصيحة أبو عمر العملية: بعد أن ترسل استجابة (`res.json`, `res.send`, `res.end`)، اعتبر أن دورك في هذا الطلب قد انتهى. أي عمل لاحق يجب أن يكون “أطلق وانسى” (fire-and-forget) مع إدارة أخطاء مستقلة تمامًا (مثل تسجيل الخطأ في ملف logs أو إرساله إلى خدمة مراقبة) وليس ضمن دورة حياة الطلب نفسه.

الطريقة الصحيحة لفعل ذلك:

app.post("/order", asyncHandler(async (req, res) => {
  const order = await createOrder(req.body);

  res.status(201).json({ orderId: order.id });

  // افصل العملية اللاحقة عن دورة حياة الطلب
  process.nextTick(() => {
    sendNotificationEmail(order.userEmail).catch((err) => {
      // هنا لا تحاول الرد على العميل أبدًا
      // فقط سجل الخطأ للتحليل لاحقًا
      console.error(`Failed to send notification for order ${order.id}:`, err);
    });
  });
}));

بناء حصن منيع: الـ Error Middleware المضبوط

أخيرًا، تأكد من أن لديك middleware لمعالجة الأخطاء في نهاية ملف التطبيق الخاص بك (بعد كل الـ routes والـ middlewares الأخرى). هذا هو شبكة الأمان الأخيرة التي تلتقط كل الأخطاء التي تم تمريرها عبر `next(err)`.

app.use((err, req, res, next) => {
  // دائمًا سجل الخطأ للمراجعة لاحقًا
  console.error(err);

  // إذا تم إرسال الـ headers بالفعل، فهذا يعني أن خطأً فادحًا حدث
  // بعد إرسال الاستجابة. هنا نسلّم الأمر لـ Express للتعامل معه.
  if (res.headersSent) {
    return next(err);
  }

  res.status(500).json({
    error: "Internal Server Error",
    // لا تُرجع تفاصيل الخطأ (err.stack) للعميل في بيئة الإنتاج أبدًا!
  });
});

قائمة الفحص السريع قبل النشر (Production Checklist)

  • ✅ هل تستخدم `asyncHandler` (أو مكتبة مشابهة مثل `express-async-handler`) لكل الـ routes غير المتزامنة؟ (خاصة في Express 4).
  • ✅ هل تتجنب `throw` داخل الـ callbacks والأحداث دون `try/catch` وتمرير الخطأ لـ `next`؟
  • ✅ هل تستخدم `return res.json(…)` لتضمن أن الكود لا يكمل التنفيذ بعد إرسال الاستجابة؟
  • ✅ هل يوجد لديك error middleware شامل في نهاية سلسلة الـ middlewares؟
  • ✅ هل تراقب مؤشرات الأداء في بيئة الإنتاج (معدل أخطاء 5xx، أوقات الاستجابة، استهلاك الذاكرة، عدد الاتصالات المفتوحة)؟

خلاصة أبو عمر ونصيحة من القلب 💡

يا جماعة، `async/await` جعلت كتابة الكود غير المتزامن أسهل وأجمل، لكنها لم تعفِنا من مسؤولية فهم كيف تعمل الأشياء تحت الغطاء. أكبر خطأ يمكن أن نرتكبه كمطورين هو الثقة العمياء في أدواتنا دون فهم حدودها.

الدرس المستفاد من تلك الليلة الصعبة هو أن الموثوقية في بيئة الإنتاج لا تأتي من الصدفة، بل من التصميم المتعمد. النمط الذي شرحته اليوم (`asyncHandler` + `error middleware` شامل) ليس مجرد كود جميل، بل هو أساس لبناء تطبيقات قوية وقادرة على الصمود في وجه الأخطاء غير المتوقعة.

لا تنتظر وقوع الكارثة لتتعلم الدرس. كن استباقيًا، وافهم أدواتك بعمق، واكتب كودًا لا يعمل فقط عندما تكون الظروف مثالية، بل يصمد ويفشل بأمان عندما تسوء الأمور. يعطيكم العافية.

أبو عمر

سجل دخولك لعمل نقاش تفاعلي

كافة المحادثات خاصة ولا يتم عرضها على الموقع نهائياً

آراء من النقاشات

لا توجد آراء منشورة بعد. كن أول من يشارك رأيه!

آخر المدونات

أتمتة العمليات

قهوتك الصباحية مع ملخص الإنجازات: كيف تبني داشبورد يومي يصلك على الموبايل باستخدام n8n والذكاء الاصطناعي

كف عن تشتيت نفسك كل صباح بين Jira وGitHub والإيميلات. تعلم معي، أبو عمر، كيف تبني ورك فلو أتمتة يرسل لك ملخصاً ذكياً ومنسقاً بإنجازات...

12 فبراير، 2026 قراءة المزيد
البودكاست