قواطع الدائرة في Node.js باستخدام opossum + TypeScript (2026)
٣٠ يونيو ٢٠٢٦
يقوم قاطع الدائرة (circuit breaker) في Node.js بتغليف استدعاء لتبعية غير مستقرة و"يفصل" بعد حدوث إخفاقات كثيرة، بحيث يفشل خدمتك بسرعة مع توفير بديل (fallback) بدلاً من التراكم فوق خدمة علوية (upstream) متوقفة. يبني هذا البرنامج التعليمي قاطعًا باستخدام opossum و TypeScript، ويقوده عبر كل حالة، ويتحقق من المخرجات.
ملخص
سوف تقوم بتغليف استدعاء غير متزامن (async) في قاطع دائرة opossum، وضبط وقت فصله بدقة، وإضافة بديل، ومنع أخطاء 4xx المتوقعة من فتحه، ووضعه خلف مسار Express. التقنيات المستخدمة: opossum 9.0.01 (بدون تبعات تشغيل)، TypeScript 6.0.3، Express 5.2.12، Node 22 LTS (يتطلب opossum إصدار Node 20+). يتم تشغيل كل انتقال — مغلق (closed) ← مفتوح (open) ← نصف مفتوح (half-open) ← مغلق (closed) — في البيئة التجريبية (sandbox) ويتم لصق المخرجات الحقيقية. الميزانية الزمنية حوالي 25 دقيقة.
ملاحظة الإصدار. اعتبارًا من يونيو 2026، يؤدي تنفيذ
npm i opossumإلى تثبيت 10.0.0 (تم إصداره في 24 يونيو 2026). يثبت هذا الدليل الإصدار 9.0.0 كقاعدة أساسية مختبرة؛ التغيير الجذري الوحيد في 10.0.0 هو إسقاط دعم Node 20، لذا فإن كل قصاصة كود هنا متطابقة في 10.x طالما أنك تقوم بتشغيل Node 22 أو أحدث.3
ما ستتعلمه
- كيف يمنع نمط قاطع الدائرة تبعية واحدة بطيئة من إسقاط خدمتك بالكامل
- كيفية تغليف أي دالة غير متزامنة في قاطع opossum وقراءة حالاته الثلاث
- كيفية ضبط وقت فصل القاطع بدقة: عتبة الخطأ، عتبة الحجم، والنافذة المتدحرجة (rolling window)
- كيفية إضافة بديل (fallback) للتدهور التدريجي — وأين يتم تشغيل البديل فعليًا
- كيفية منع أخطاء 4xx المتوقعة من فصل القاطع باستخدام
errorFilter - كيفية وضع قاطع لكل تبعية خلف مسار Express
- كيفية الجمع بين القاطع والمهلات (timeouts) وإعادات المحاولة (retries) بالترتيب الصحيح
- كيفية مراقبة تغييرات الحالة والتنظيف عند الإغلاق
المتطلبات الأساسية
- Node.js 20+ (يستخدم هذا الدليل 22.x؛ إصدار opossum 9 يسقط الدعم لـ Node 16 و 183)
- npm 10+
- أساسيات TypeScript و
async/await - طرفية (terminal)؛ لا حاجة لـ Docker أو قاعدة بيانات أو حساب سحابي — يتم محاكاة التبعية "البعيدة" محليًا
الخطوة 1 — الفشل الذي تمنعه فعليًا
تخيل خدمة طلبات (order service) تستدعي خدمة مخزون (inventory service) مع كل طلب. في أحد الأيام، تصبح خدمة المخزون بطيئة — ليست متوقفة، بل بطيئة فقط، وتستجيب في 8 ثوانٍ بدلاً من 80 مللي ثانية. كل طلب ينتظر يحجز مقبسًا (socket) واستمرارية في حلقة الأحداث (event-loop). في غضون ثوانٍ، سيكون لدى خدمة الطلبات مئات الاستدعاءات المعلقة على تبعية لن تستجيب أبدًا في الوقت المناسب، والآن تتوقف هي أيضًا عن الاستجابة. لقد حدث فشل متسلسل (cascaded failure).
قاطع الدائرة هو صندوق المصهرات لهذا الموقف. شاع هذا النمط بواسطة Michael Nygard في كتاب Release It! (2007) ووصفه باستفاضة Martin Fowler4: قم بتغليف الاستدعاء الخطير، واحسب الإخفاقات، وبمجرد أن يتجاوز معدل الفشل عتبة معينة، افتح الدائرة — توقف عن استدعاء التبعية تمامًا وعد فورًا. بعد فترة تهدئة، اسمح بمرور مسبار (probe) واحد لمعرفة ما إذا كانت التبعية قد تعافت. هذه هي الفكرة بالكامل، ويقوم opossum بتنفيذها حتى لا تضطر للقيام بذلك بنفسك.
قم بإعداد مشروع:
mkdir circuit-breaker-demo && cd circuit-breaker-demo
npm init -y
npm pkg set type=module
npm i opossum@9.0.0
npm i -D TypeScript@6.0.3 tsx@4.22.4 @types/opossum@8.1.9 @types/node@22
يأتي opossum كـ CommonJS مع صفر تبعات تشغيل، ولكنه يستورد بسلاسة في مشروع ES-module.1 لا يدمج أنواعه الخاصة، لذا نضيف @types/opossum من DefinitelyTyped.5 أنشئ tsconfig.json:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}
سنقوم بتشغيل TypeScript مباشرة باستخدام tsx والتحقق من الأنواع باستخدام tsc --noEmit — بدون خطوة بناء. يسمح لنا allowImportingTsExtensions باستيراد الملفات المحلية بامتداد .ts الخاص بها، وهو ما يتطلبه تحليل nodenext وتجريد الأنواع الأصلي في Node 22 (يقبل tsx الامتداد أو بدونه).
الخطوة 2 — أول قاطع لك وحالاته الثلاث
أنشئ بديلاً للاستدعاء البعيد. القاطع الحقيقي يغلف fetch، أو مشغل قاعدة بيانات، أو طريقة SDK؛ هنا يقوم مفتاح تبديل بمحاكاة توقف التبعية ليكون العرض التوضيحي حتميًا. احفظ src/inventory.ts:
// A stand-in for a remote call. Flip the toggle to simulate the upstream
// going down or recovering.
let down = true;
export function setUpstream(state: 'up' | 'down') {
down = state === 'down';
}
export async function getInventory(sku: string): Promise<{ sku: string; qty: number }> {
await new Promise((resolve) => setTimeout(resolve, 20)); // simulated latency
if (down) throw new Error('HTTP 503 from inventory-service');
return { sku, qty: 42 };
}
الآن قم بتغليفه. احفظ src/breaker.ts:
import CircuitBreaker from 'opossum';
import { getInventory } from './inventory.ts';
export const inventoryBreaker = new CircuitBreaker<[string], { sku: string; qty: number }>(
getInventory,
{
timeout: 200, // reject any call slower than 200ms
errorThresholdPercentage: 50, // open once the failure rate exceeds 50%
resetTimeout: 1000, // after opening, wait 1s before probing again
volumeThreshold: 3, // need at least 3 calls before the breaker may trip
name: 'inventory',
},
);
يقوم النوع العام CircuitBreaker<[string], { sku: string; qty: number }> بتحديد أنواع وسيطات الإجراء وقيمة الإرجاع، لذا فإن inventoryBreaker.fire('SKU-1') محدد النوع بالكامل.5 تقوم باستدعاء الدالة المغلفة باستخدام breaker.fire(...args)، والتي تعيد وعدًا (promise).6
يكون القاطع دائمًا في واحدة من ثلاث حالات، يتم كشفها كـ getters منطقية: closed (الاستدعاءات تمر بشكل طبيعي)، opened (الاستدعاءات تفشل بسرعة دون لمس التبعية)، و halfOpen (يُسمح بمرور استدعاء مسبار واحد لاختبار التعافي).6 سنراقب الثلاثة جميعًا تاليًا.
الخطوة 3 — ضبط وقت فصل القاطع بدقة
هذا هو المكان الذي تتوقف فيه معظم البرامج التعليمية وتبدأ فيه معظم حوادث الإنتاج. إليك الإعدادات الافتراضية لـ opossum، مأخوذة مباشرة من كود المصدر للإصدار 9.0.0، وما يتحكم فيه كل منها:6
| الخيار | الافتراضي | ما يتحكم فيه |
|---|---|---|
timeout | 10000 ms | الموعد النهائي لكل استدعاء؛ الاستدعاء الأبطأ يُحسب كفشل. false يعطله. |
errorThresholdPercentage | 50 | معدل الفشل (عبر النافذة) الذي يفتح الدائرة بعد تجاوزه. |
volumeThreshold | 0 | الحد الأدنى من الاستدعاءات في النافذة قبل أن يتمكن القاطع من الفصل. 0 تعني أنه يمكن أن يفتح عند أول فشل. |
resetTimeout | 30000 ms | الوقت الذي يقضيه القاطع مفتوحًا قبل السماح بمسبار واحد نصف مفتوح. |
rollingCountTimeout | 10000 ms | طول نافذة الإحصائيات المتدحرجة. |
rollingCountBuckets | 10 | عدد الأجزاء (buckets) التي تنقسم إليها النافذة. |
اثنان من هذه الإعدادات الافتراضية قد يسببان مشاكل، لذا كن صريحًا بشأنهما.
errorThresholdPercentage هو مقارنة "أكبر من" صارمة. عند كل فشل، يحسب opossum errorRate = failures / fires * 100 عبر النافذة المتدحرجة ويفتح الدائرة فقط عندما يكون errorRate > errorThresholdPercentage.6 عند 50% بالضبط لا يفتح — يجب عليك تجاوز العتبة.
الإعداد الافتراضي لـ volumeThreshold هو 0، مما يعني أن القاطع يمكن أن يفتح عند أول فشل مباشرة. اضبطه على حد أدنى واقعي (10-20 في بيئة الإنتاج) حتى لا يتسبب طلب واحد غير محظوظ أثناء فترة هدوء في حركة المرور في تشغيل القاطع. الجانب الآخر، الذي قد يربك الاختبارات: مع volumeThreshold: 3، لن يفتح القاطع حتى يتم تسجيل ثلاث مكالمات على الأقل في النافذة الزمنية، بغض النظر عن مدى سوء فشلها. إذا كان اختبارك "يثبت" أن القاطع لا يفتح أبداً، فهذا هو السبب عادةً.
يحدد كل من rollingCountTimeout (10 ثوانٍ) و rollingCountBuckets (10) نافذة الإحصائيات: يحتفظ opossum بنتائج آخر 10 ثوانٍ في عشر سلال (buckets) مدة كل منها ثانية واحدة ويقوم بتدويرها للأمام، بحيث يعكس معدل الخطأ السلوك الأخير بدلاً من السجل الكامل للخدمة.6
الخطوة 4 — البدائل (Fallbacks): التدهور التدريجي السليم
القاطع الذي يرمي خطأً (throws) بمجرد فتحه ينقل الفشل فقط من مكان لآخر. أما البديل (fallback) فيسمح لك بالتدهور التدريجي — تقديم بيانات مخزنة مؤقتاً، أو قيمة افتراضية، أو كتابة مؤجلة في طابور. قم بتسجيل واحد باستخدام .fallback(). أضف هذا إلى src/breaker.ts:
// يتم استدعاؤه مع الوسائط الأصلية بالإضافة إلى الخطأ كوسيط أخير.
inventoryBreaker.fallback((sku: string) => ({ sku, qty: 0 }));
هناك تفصيلان لا تتحدث عنهما الوثائق كثيراً ولكن الكود المصدري يوضحهما.6 أولاً، يتلقى البديل الوسائط الأصلية مع إلحاق الخطأ كآخر وسيط، لذا يمكنك تحديد التصرف بناءً على سبب فشل المكالمة. ثانياً — وهذا يفاجئ البعض — يعمل البديل عند كل فشل، وليس فقط عندما تكون الدائرة مفتوحة. المكالمة الفاشلة في الحالة المغلقة (closed) لا تزال تستدعي البديل؛ فتح الدائرة يعني فقط أن المكالمات المستقبلية ستتخطى التبعية وتذهب مباشرة إليه. عندما يعيد البديل قيمة، فإن fire() تنجح (resolves) بتلك القيمة بدلاً من الرفض، لذا يرى موقع الاستدعاء نتيجة ناجحة (وإن كانت متدهورة).
إذا كنت تفضل إظهار الحالة المفتوحة للمستدعي — على سبيل المثال، لإرجاع HTTP 503 — فاحذف البديل. عندها، المكالمة أثناء فتح الدائرة ستُرفض بخطأ يكون الـ code الخاص به هو 'EOPENBREAKER' والرسالة هي 'Breaker is open'، والمكالمة التي تتجاوز الـ timeout ستُرفض بكود 'ETIMEDOUT' ورسالة 'Timed out after 200ms'.6 أنت تختار لكل تبعية ما إذا كان التدهور الصامت أو الفشل الصريح هو السلوك الصحيح.
الخطوة 5 — لا تدع أخطاء 4xx المتوقعة تفتح القاطع
إليك خطأ حقيقي يختبئ في معظم القواطع المنسوخة: خطأ 404 Not Found أو 422 Unprocessable Entity يعني أن التبعية تعمل بشكل صحيح، ولكن بالنسبة لقاطع بسيط، هو مجرد وعد (promise) مرفوض آخر، وعدد كافٍ منها سيفتح دائرتك ويوقف خدمة سليمة عن العمل.
يحل errorFilter هذه المشكلة. هو دالة تستقبل الخطأ؛ أعد true للأخطاء التي لا ينبغي احتسابها كإخفاقات.6 قم بتحديث الخيارات في src/breaker.ts:
export const inventoryBreaker = new CircuitBreaker<[string], { sku: string; qty: number >(
getInventory,
{
timeout: 200,
errorThresholdPercentage: 50,
resetTimeout: 1000,
volumeThreshold: 3,
name: 'inventory',
// 4xx تعني أن الطلب تم فهمه والإجابة عليه — وليس انقطاعاً في التبعية.
errorFilter: (err: { statusCode?: number ) =>
err.statusCode !== undefined && err.statusCode >= 400 && err.statusCode < 500,
},
);
الدلالات دقيقة وتستحق الضبط الصحيح: عندما يعيد errorFilter القيمة true، يصدر opossum حدث success (لذا تُحتسب المكالمة كنجاح في الإحصائيات وتبعد القاطع عن الفتح) ولكنه لا يزال يرفض الوعد بالخطأ الأصلي.6 بمعنى آخر، يغير الفلتر حسابات القاطع، وليس معالجة الأخطاء الخاصة بك — لا يزال الـ catch الخاص بك يعمل. إليك هذا السلوك، عند تشغيله في البيئة التجريبية مع دالة ترمي دائماً خطأ 404:
8x HTTP-404 مع errorFilter(4xx) => الحالة CLOSED، الإخفاقات=0، التشغيل=8
3x HTTP-404 بدون errorFilter => الحالة OPEN، الإخفاقات=3
مع الفلتر، ثمانية أخطاء 404 تترك الدائرة مغلقة وعدد الإخفاقات صفراً. بدونه، الخطأ 404 الثالث يفتح الدائرة (قيمة volumeThreshold لهذا القاطع هي 3، ومعدل خطأ 100% يتجاوز عتبة الـ 50%). هذا هو الفرق بين خدمة مرنة وانقطاع ناتج عن خطأ مطبعي في عنوان URL.
الخطوة 6 — قاطع لكل تبعية خلف مسار Express
الآن قم بتوصيل قاطع حول fetch حقيقي واكشفه. القاعدة الأساسية: قاطع واحد لكل تبعية، يتم إنشاؤه مرة واحدة وإعادة استخدامه — لا تقم أبداً بإنشاء قاطع جديد لكل طلب، لأنه لن يكون لديه ذاكرة ولن يفتح أبداً. احفظ src/server.ts:
import express from 'express';
import CircuitBreaker from 'opossum';
async function fetchInventory(sku: string): Promise<{ sku: string; qty: number > {
const res = await fetch(`http://localhost:4101/inventory/${sku`);
if (!res.ok) {
const error: Error & { statusCode?: number = new Error(`inventory ${res.status`);
error.statusCode = res.status;
throw error;
}
return (await res.json()) as { sku: string; qty: number ;
}
const inventory = new CircuitBreaker<[string], { sku: string; qty: number >(fetchInventory, {
timeout: 300,
errorThresholdPercentage: 50,
resetTimeout: 1000,
volumeThreshold: 3,
name: 'inventory',
});
inventory.fallback((sku: string) => ({ sku, qty: 0 ));
const app = express();
app.get('/inventory/:sku', async (req, res) => {
const data = await inventory.fire(req.params.sku);
res.json({ data, breaker: inventory.opened ? 'open' : 'closed' );
});
app.listen(4102, () => console.log('order-service on http://localhost:4102'));
نظراً لتسجيل بديل (fallback)، فإن fire() تنجح دائماً، لذا يعيد المسار دائماً 200 إما ببيانات حية (qty: 42) أو بيانات متدهورة (qty: 0). إذا كنت تفضل الفشل الصريح، فستتخلى عن البديل وتلف fire() في بلوك try/catch يعيد res.status(503) عندما يكون err.code === 'EOPENBREAKER'. قم بتشغيله ضد خدمة علوية غير مستقرة وسيقوم القاطع بعمله — استجابات متدهورة أثناء تعطل التبعية، ثم تعافٍ تلقائي:
req#1 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"closed"}
req#2 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"closed"}
req#3 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#4 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#5 http=200 body={"data":{"sku":"SKU-1","qty":0},"breaker":"open"}
req#6 http=200 body={"data":{"sku":"SKU-1","qty":42},"breaker":"closed"}
تفتح الدائرة عند الفشل الثالث (بمجرد استيفاء volumeThreshold)، وتقدم البديل فوراً دون لمس الخدمة العلوية المتعطلة، وتغلق نفسها مرة أخرى بعد تعافي التبعية.
الخطوة 7 — المهلات وإعادة المحاولة، بالترتيب الصحيح
القاطع هو واحد من ثلاثة ضوابط للمرونة تعمل معاً؛ والآخران هما المهلات (timeouts) وإعادة المحاولة (retries)، والترتيب مهم جداً.
تنتمي المهلة (timeout) إلى داخل القاطع، وهو بالضبط ما يوفره لك خيار timeout في opossum — ولهذا السبب يكون افتراضياً 10 ثوانٍ بدلاً من إيقافه.6 المكالمة التي تتجاوز الوقت تُحتسب كفشل وتساهم في فتح الدائرة. إذا قمت بتعطيله (timeout: false)، فإن التبعية التي تتعلق (hangs) لن تنتج أبداً "فشلاً"، لذا لن يفتح القاطع أبداً — وهو السيناريو الدقيق من الخطوة 1. احتفظ بالمهلة، واضبطها لتكون أقل بكثير من أسوأ زمن استجابة مقبول للخدمة العلوية.
إعادة المحاولة (retries) توضع خارج القاطع، وليس داخله. إذا قمت بإعادة المحاولة داخل الدالة المغلفة، فإن كل دفعة من إعادات المحاولة تُحتسب كـ fire() واحدة، وستقوم بالضغط على تبعية متعثرة، ولن يتمكن القاطع من رؤية الإخفاقات الفردية. ضع القاطع في أقرب نقطة للتبعية وقم بإعادة محاولة القاطع نفسه:
async function fireWithRetry(sku: string, attempts = 3): Promise<{ sku: string; qty: number > {
for (let i = 1; i <= attempts; i++) {
try {
return await inventory.fire(sku);
} catch (err) {
const isOpen = (err as { code?: string ).code === 'EOPENBREAKER';
if (isOpen || i === attempts) throw err; // لا تحاول مجدداً إذا كانت الدائرة مفتوحة وتفشل بسرعة
await new Promise((r) => setTimeout(r, 100 * 2 ** (i - 1))); // تراجع أسي (exponential backoff)
}
}
throw new Error('unreachable');
}
هذا النمط يناسب القاطع بدون بديل (fallback): الدائرة المفتوحة تجعل fire() ترفض فوراً بـ EOPENBREAKER، لذا تخرج حلقة إعادة المحاولة فوراً بدلاً من الانتظار في التراجع لتبعية تعرف مسبقاً أنها معطلة. إذا قمت بتسجيل بديل (الخطوة 4)، فإن fire() تنجح بالقيمة المتدهورة بدلاً من ذلك ولا يوجد شيء لإعادة محاولته — البدائل وإعادات المحاولة هي استراتيجيات بديلة، لذا اختر واحدة لكل تبعية. في كلتا الحالتين، يتعاون القاطع وإعادة المحاولة بدلاً من التصادم.
الخطوة 8 — مراقبة تغييرات الحالة والتنظيف عند الإغلاق
إن opossum هو EventEmitter، والأحداث هي الطريقة التي تدمج بها القاطع في سجلاتك ولوحات التحكم الخاصة بك. تتضمن المجموعة الكاملة fire، success، failure، timeout، reject، open، halfOpen، close، و fallback، من بين أحداث أخرى.6 اشترك في الأحداث التي تهمك للتنبيه:
inventory.on('open', () => console.warn('[breaker:inventory] OPEN — failing fast'));
inventory.on('halfOpen', () => console.info('[breaker:inventory] HALF_OPEN — probing'));
inventory.on('close', () => console.info('[breaker:inventory] CLOSED — recovered'));
inventory.on('timeout', () => console.warn('[breaker:inventory] call timed out'));
يكشف قاطع الدائرة (breaker) أيضًا عن عدادات جارية عبر inventory.stats — وهي fires، و successes، و failures، و timeouts، و fallbacks، و rejects — والتي ترتبط مباشرة بمصدر Prometheus إذا كنت تقوم بالفعل بسحب المقاييس (metrics). (راجع الدليل المتعلق بـ مقاييس Prometheus المخصصة في Node.js لمعرفة كيفية التوصيل.)
أخيرًا، هناك استدعاءان لدورة الحياة. breaker.shutdown() يعطل القاطع نهائيًا: حيث يرسل حدث shutdown أخير، ويزيل مستمعي القاطع، ويمسح مؤقتات إعادة الضبط والإحصائيات المتغيرة الداخلية.6 لست بحاجة إليه فقط للسماح لعمليتك بالخروج — يقوم opossum بإنشاء كل مؤقت باستخدام .unref()، لذا لا يبقي أي منها Node قيد التشغيل — ولكنه الطريقة النظيفة لتفكيك القاطع في afterEach الخاص بالاختبار، أو أثناء الإغلاق التدريجي جنبًا إلى جنب مع نظام SIGTERM من دليل عمليات النشر بدون توقف لـ Kubernetes:
process.on('SIGTERM', () => {
inventory.shutdown();
process.exit(0);
});
الآخر هو breaker.healthCheck(fn, interval)، والذي يشغل fn كل interval مللي ثانية (الافتراضي 5000) و يفتح الدائرة كلما رفضت fn — وهي طريقة لتشغيل القاطع من إشارة خارجية، مثل فشل اختبار اتصال بقاعدة البيانات (ping)، قبل أن تصل طلبات المستخدم إليه.6
التحقق: تشغيل كل حالة وقراءة المخرجات
الدليل على عمل القاطع هو مراقبة انتقاله. يفرض هذا السكربت الدورة الكاملة — مغلق (closed) تحت الحمل، مفتوح (open) بعد الإخفاقات، اختبار نصف مفتوح (half-open)، إعادة الفتح عند استمرار فشل الاختبار، ثم الإغلاق عند التعافي. احفظ src/demo.ts:
import CircuitBreaker from 'opossum';
import { getInventory, setUpstream } from './inventory.ts';
const breaker = new CircuitBreaker<[string], { sku: string; qty: number }>(getInventory, {
timeout: 200, errorThresholdPercentage: 50, resetTimeout: 1000, volumeThreshold: 3, name: 'inventory',
});
breaker.fallback((sku: string) => ({ sku, qty: 0 }));
breaker.on('open', () => console.log(' >> EVENT: open'));
breaker.on('halfOpen', () => console.log(' >> EVENT: halfOpen'));
breaker.on('close', () => console.log(' >> EVENT: close'));
const state = () => (breaker.opened ? 'OPEN' : breaker.halfOpen ? 'HALF_OPEN' : 'CLOSED');
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fire(tag: string) {
const out = await breaker.fire('SKU-1').catch((e: { code?: string }) => ({ err: e.code }));
const s = breaker.stats;
console.log(`${tag} state=${state()} result=${JSON.stringify(out)} ` +
`fires=${s.fires} failures=${s.failures} fallbacks=${s.fallbacks}`);
}
for (let i = 1; i <= 4; i++) await fire(`fire#${i}`); // upstream down -> trips open
await sleep(1100); await fire('probe-A'); // half-open, still down -> reopen
setUpstream('up'); await sleep(1100); await fire('probe-B'); // half-open, healthy -> close
breaker.shutdown();
قم بتشغيله باستخدام npx tsx src/demo.ts. تحقق من الأنواع أولاً باستخدام npx tsc --noEmit. هذا هو ناتج Sandbox الفعلي:
fire#1 state=CLOSED result={"sku":"SKU-1","qty":0} fires=1 failures=1 fallbacks=1
fire#2 state=CLOSED result={"sku":"SKU-1","qty":0} fires=2 failures=2 fallbacks=2
>> EVENT: open
fire#3 state=OPEN result={"sku":"SKU-1","qty":0} fires=3 failures=3 fallbacks=3
fire#4 state=OPEN result={"sku":"SKU-1","qty":0} fires=4 failures=3 fallbacks=4
>> EVENT: halfOpen
>> EVENT: open
probe-A state=OPEN result={"sku":"SKU-1","qty":0} fires=5 failures=4 fallbacks=5
>> EVENT: halfOpen
>> EVENT: close
probe-B state=CLOSED result={"sku":"SKU-1","qty":42} fires=6 failures=4 fallbacks=5
اقرأه من الأعلى إلى الأسفل: تتراكم الإخفاقات أثناء الإغلاق، وتفتح الدائرة عند الفشل الثالث، ويترك استدعاء الحالة المفتوحة (fire#4) الـ failures ثابتة — حيث يُحسب رفض الدائرة المفتوحة كـ reject، وليس failure. يظهر probe-A اختبار نصف مفتوح ينفذ الاستدعاء الحقيقي (الذي لا يزال يفشل) ويعيد الفتح؛ ويظهر probe-B الاختبار المتعافي وهو يغلق الدائرة ويعيد بيانات حية (qty: 42). تعمل آلة الحالة تمامًا كما هو معلن عنها.
الأخطاء الشائعة
القاطع لا يفتح أبدًا في اختباراتي. من المؤكد تقريبًا أنك قمت بضبط volumeThreshold أعلى من عدد الاستدعاءات التي يجريها اختبارك. لن يعمل opossum حتى يتم تسجيل عدد استدعاءات volumeThreshold على الأقل في النافذة المتغيرة.6 إما أرسل المزيد من الطلبات أو اخفض العتبة للاختبار.
عدد قليل من أخطاء 404 فتح الدائرة الخاصة بي. يتم احتساب أخطاء العميل المتوقعة كإخفاقات. أضف errorFilter يعيد true لرموز الحالة 4xx (الخطوة 5) بحيث يتم تسجيلها كنجاحات للقاطع بينما لا تزال ترفض للمستدعي الخاص بك.
القاطع ينقل الحالة بين اختباراتي. القاطع ذو نطاق الوحدة (النمط الصحيح لإعادة الاستخدام) يحتفظ بإحصائياته ومستمعيه طوال عمر العملية، لذا تتسرب حالته عبر الاختبارات. استدعِ breaker.shutdown() في afterEach لتعطيله ومسح مؤقتاته وإزالة المستمعين. يقوم opossum بإنشاء مؤقتاته باستخدام .unref()، لذا لن يؤدي نسيان القاطع إلى تعليق عمليتك — ولكنه لن يعيد ضبط نفسه فقط.6
import CircuitBreaker from 'opossum' يفشل في التحقق من النوع. opossum هو CommonJS ولا يشحن أنواعًا. قم بتثبيت @types/opossum واحتفظ بـ esModuleInterop: true في tsconfig الخاص بك.5 الاستيراد الافتراضي صحيح — يقوم opossum بتصدير الفئة عبر module.exports.
قاطع عالمي واحد لكل شيء. يعني وجود قاطع واحد مشترك عبر تبعيات غير مرتبطة أن خدمة دفع (API) غير مستقرة يمكن أن تفتح الدائرة لخدمة مخزون (API) سليمة. أنشئ قاطعًا واحدًا لكل تبعية تابعة، كل منها باسمه الخاص (name)، وأعد استخدامه عبر الطلبات.
الخطوات التالية وقراءات إضافية
قاطع الدائرة هو أحد حواجز الحماية؛ وإعادة المحاولة المتينة مع طابور الرسائل المهملة (dead-letter queue) هي حاجز آخر. بالنسبة لأعباء العمل غير المتزامنة، ادمج هذا مع دليل عمليات إعادة محاولة العامل المتينة و DLQ في NATS JetStream، وقم بتغذية breaker.stats في إعداد مقاييس Prometheus بحيث تنبه الدائرة المفتوحة شخصًا ما. من هنا، أضف حدًا للـ capacity (bulkhead) لتحديد عدد الاستدعاءات المتزامنة قيد التنفيذ، و healthCheck لتشغيل الدائرة بشكل استباقي عندما يبدأ اختبار خارجي — على سبيل المثال، اختبار اتصال بقاعدة البيانات — في الفشل.
الحواشي
-
opossum — حزمة npm (الإصدار 9.0.0، CommonJS، بدون تبعيات،
engines.node^20 || ^22 || ^24). https://www.npmjs.com/package/opossum ↩ ↩2 -
Express 5.x — التوثيق الرسمي. https://expressjs.com/ ↩
-
سجل تغييرات opossum — 10.0.0 (2026-06-24) يزيل دعم Node 20؛ 9.0.0 يزيل دعم Node 16 و 18. https://GitHub.com/nodeshift/opossum/blob/main/CHANGELOG.md ↩ ↩2
-
Martin Fowler — "CircuitBreaker" (وصف النمط؛ الأصل في كتاب Michael Nygard بعنوان Release It!، 2007). https://martinfowler.com/bliki/CircuitBreaker.html ↩
-
@types/opossum — تعريفات أنواع DefinitelyTyped (قوالب
CircuitBreaker<TI, TR>، الخيارات، الأحداث). https://www.npmjs.com/package/@types/opossum ↩ ↩2 ↩3 -
nodeshift/opossum — ملف README، مرجع API، والمصدر (
lib/circuit.js): الخيارات الافتراضية، انتقالات الحالة، الأحداث، دلالاتerrorFilter، وfallback. https://GitHub.com/nodeshift/opossum ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15