Fail-Open مقابل Fail-Closed Middleware: Hono + Redis (2026)

٢٨ مايو ٢٠٢٦

Fail-Open vs Fail-Closed Middleware: Hono + Redis (2026)

الفشل المفتوح (Fail-open) يسمح بمرور الطلب عندما يكون الاعتماد (dependency) متوقفًا؛ أما الفشل المغلق (fail-closed) فيرفضه. يجب أن تفشل محددات المعدل (Rate limits) وأعلام الميزات (feature flags) بشكل مفتوح حتى لا يؤدي خلل بسيط في Redis إلى إرجاع خطأ 503 في API. بينما يجب أن تفشل المصادقة (Authentication) بشكل مغلق — فالسماح بطلب لم تتمكن من التصريح به يعد تجاوزًا أمنيًا.

ملخص

ستقوم ببناء خدمة باستخدام Hono 4.12.23 + ioredis 5.11.0 مع ثلاث برمجيات وسيطة (middleware): فحص مفتاح API بفشل مغلق (يرفض الطلب عندما يكون Redis متوقفًا — لمنع تجاوز المصادقة)، وفحص أعلام الميزات بفشل مفتوح مع قيمة افتراضية آمنة صريحة (يسمح بمرور حركة المرور)، ومحدد معدل بنظام النافذة المنزلقة (sliding-window) بفشل مفتوح (يسمح بالطلبات بدلاً من إرجاع 503 في API). تشترك الثلاثة في فئة CircuitBreaker مكتوبة بالأنواع (~40 سطرًا) بحيث يتوقف الانقطاع المستمر عن الضغط على الاعتماد المتوقف. تم التحقق من ذلك من البداية للنهاية في البيئة التجريبية (sandbox) مع عدم إمكانية الوصول إلى Redis: تعيد المصادقة 503 auth_unavailable بينما يتراجع علم الميزة ومحدد المعدل بصمت، تمامًا كما هو مخطط له.

ما ستتعلمه

  • قرار الفشل المفتوح مقابل الفشل المغلق لكل نوع من البرمجيات الوسيطة
  • كيفية كتابة قاطع دائرة (circuit breaker) صغير ومكتوب بالأنواع في TypeScript (بدون الحاجة لمكتبة)
  • كيفية إضافة مواعيد نهائية (deadlines) لكل استدعاء فوق commandTimeout الخاص بـ ioredis
  • لماذا يعد enableOfflineQueue: false هو الخيار الافتراضي الصحيح لـ ioredis في التطبيقات المرنة
  • كيفية تكوين ثلاث برمجيات وسيطة في Hono بحيث لا تتعارض السياسات مع بعضها البعض
  • التحقق باستخدام curl ضد Redis غير قابل للوصول حتى تتمكن من رؤية تفعيل كل سياسة

المتطلبات الأساسية

  • Node.js 24 (LTS نشط اعتبارًا من مايو 2026، مدعوم حتى أبريل 2028) — أو Node 22 إذا كنت لا تزال تستخدم Maintenance LTS. تم التحقق منه في البيئة التجريبية على Node 22.22.0.
  • Docker (أي إصدار حديث) — يستخدم لتشغيل Redis محليًا لاختبار المسار السعيد (happy-path)
  • npm 10+ (يأتي Node 22 مع npm 10؛ ويأتي Node 24 مع npm 11)
  • مجلد للعمل؛ سنسميه failover-demo/

لا حاجة لحساب Redis مدار — حاوية Redis:8-alpine محلية كافية لخطوة التحقق. إذا كان لديك بالفعل Upstash أو Redis Cloud، فقم بضبط REDIS_URL وفقًا لذلك.

الخطوة 1: إعداد هيكل المشروع

mkdir failover-demo && cd failover-demo
cat > package.json <<'EOF'
{ "name": "failover-demo", "version": "0.0.0", "private": true, "type": "module" }
EOF
npm install --save-exact hono@4.12.23 @hono/node-server@2.0.4 ioredis@5.11.0
npm install --save-exact --save-dev TypeScript@6.0.3 tsx@4.22.3 @types/node@25.9.1
mkdir -p src/middleware src/lib

يقوم علم --save-exact بتثبيت كل اعتماد على الإصدار المحدد الذي تم التحقق منه — بدونه يكتب npm ‏^X.Y.Z وقد يؤدي تنفيذ npm install التالي إلى جلب إصدار أحدث (درس مستفاد من دليل سابق وقع في نفس الفخ).1

أنشئ ملف tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "noEmit": true,
    "isolatedModules": true,
    "allowImportingTsExtensions": true
  },
  "include": ["src/**/*.ts"]
}

خيار allowImportingTsExtensions: true مطلوب لأننا سنكتب مسارات import مع امتدادات .ts صريحة — حيث يقوم tsx بحلها في وقت التشغيل ويحتاج المترجم إلى إذن لرؤيتها.

الخطوة 2: بناء قاطع دائرة مكتوب بالأنواع

يحتوي قاطع الدائرة على ثلاث حالات: CLOSED (الاستدعاءات تمر)، و OPEN (كل استدعاء يفشل بسرعة دون لمس الاعتماد)، و HALF_OPEN (يُسمح بمسبار واحد لاختبار التعافي). عندما يكون الاعتماد سليمًا، يظل القاطع CLOSED. عندما تتجاوز الإخفاقات حدًا معينًا، فإنه يصبح OPEN، مما يعطي النظام التابع فرصة للتعافي. بعد نافذة إعادة الضبط، يصبح HALF_OPEN ويقرر الاستدعاء التالي المسار الذي سيتخذه.2

يمكنك استخدام opossum للحصول على تنفيذ مختبر في المعارك،3 ولكن بالنسبة لدليل تعليمي يريد جعل نموذج الفشل مرئيًا في مراجعة الكود، فإن حوالي 40 سطرًا من TypeScript تكون أوضح. اكتب src/lib/circuit-breaker.ts:

export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

export class CircuitOpenError extends Error {
  readonly code = 'CIRCUIT_OPEN' as const;
  constructor(name: string) {
    super(`${name}: circuit is OPEN`);
    this.name = 'CircuitOpenError';
  }
}

export interface CircuitBreakerOptions {
  /** افتح الدائرة بعد هذا العدد من الإخفاقات المتتالية. */
  failureThreshold: number;
  /** ابقِ الدائرة مفتوحة OPEN لهذه المدة قبل السماح بمسبار. */
  resetMs: number;
  /** اسم مقروء للبشر للسجلات/الأخطاء. */
  name: string;
}

export class CircuitBreaker {
  private state: CircuitState = 'CLOSED';
  private failures = 0;
  private openedAt = 0;

  constructor(private readonly opts: CircuitBreakerOptions) {}

  getState(): CircuitState {
    if (this.state === 'OPEN' && Date.now() - this.openedAt >= this.opts.resetMs) {
      this.state = 'HALF_OPEN';
    }
    return this.state;
  }

  async fire<T>(fn: () => Promise<T>): Promise<T> {
    const state = this.getState();
    if (state === 'OPEN') {
      throw new CircuitOpenError(this.opts.name);
    }
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  private onSuccess(): void {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  private onFailure(): void {
    this.failures += 1;
    if (this.state === 'HALF_OPEN' || this.failures >= this.opts.failureThreshold) {
      this.state = 'OPEN';
      this.openedAt = Date.now();
    }
  }
}

هناك خياران في التصميم يستحقان التنويه. أولاً، هذا القاطع يحسب الإخفاقات المتتالية بدلاً من نسبة الإخفاقات في نافذة زمنية منزلقة — وهذا أسهل في الفهم وجيد بما يكفي حتى تبدأ في رؤية تذبذب (flapping). إذا بدأت في رؤية سلوك مزعج بالقرب من العتبة في الإنتاج، فهذه هي الإشارة للتحول إلى نموذج الدلاء المنزلقة (rolling-bucket) الخاص بـ opossum. ثانيًا، يتم فحص الحالة داخل fire() بدلاً من مجدول منفصل — لا يوجد مؤقت لتنظيفه، ومسبار نصف الفتح هو ببساطة الاستدعاء التالي بعد انتهاء نافذة إعادة الضبط.

الخطوة 3: إضافة مواعيد نهائية لكل استدعاء

يوفر ioredis خيار commandTimeout الذي يرفض طلب Redis واحد من جانب العميل بعد N مللي ثانية (قد يستمر الخادم في تنفيذ الأمر — التوقيت هو مجرد رفض لوعد من جانب العميل). هذا مفيد ولكنه حد لكل أمر، وبين توقف في EventLoop وسلسلة من جولات multi().incr().expire().exec()، يمكن لبرمجية وسيطة واحدة أن تقضي وقتًا أطول مما كنت تنوي داخل القاطع.

استخدام Promise.race ضد موعد نهائي صريح يمنح البرمجية الوسيطة ميزانية زمنية بغض النظر عما يفعله العميل.4 اكتب src/lib/with-deadline.ts:

export class DeadlineExceededError extends Error {
readonly code = 'DEADLINE_EXCEEDED' as const;
constructor(ms: number, label: string) {
super(`${label}: deadline of ${ms}ms exceeded`);
this.name = 'DeadlineExceededError';
}
}

export function withDeadline<T>(
p: Promise<T>,
ms: number,
label: string,
): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<never>((_) => {
timer = setTimeout(() => reject(new DeadlineExceededError(ms, label)), ms);
});
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
}

لا يمتلك Node وسيلة بدائية لإلغاء الوعود (Promise cancellation)،5 لذا يستمر استدعاء Redis الأصلي في الخلفية بعد رفض الموعد النهائي. هذا مقبول طالما أنك قمت أيضًا بتثبيت commandTimeout على العميل بحيث يكون لكل أمر قيد التنفيذ رفضه الخاص من جانب العميل — كلا الطبقتين تتكاملان. commandTimeout هو الميزانية لكل أمر على عميل Redis؛ بينما withDeadline هو ميزانيتك الزمنية للعملية بأكملها للبرمجية الوسيطة، والتي قد تتضمن عدة أوامر متسلسلة.

الخطوة 4: تكوين عميل Redis للفشل السريع

أهم خيار في ioredis للمرونة هو enableOfflineQueue: false. القيمة الافتراضية هي true، مما يعني أن الأوامر الصادرة أثناء فصل العميل يتم وضعها في قائمة انتظار في الذاكرة وإعادة تشغيلها عند إعادة الاتصال.6 يبدو هذا مفيدًا ولكنه ينطوي على خطر خفي: أثناء انقطاع مستمر، تنمو قائمة الانتظار حتى يحدث خطأ OOM (نفاذ الذاكرة)، وعندما يعود Redis، تعالج عمليات API حشدًا هائلاً من الأوامر القديمة دفعة واحدة. مع إيقاف قائمة الانتظار غير المتصلة، تفشل الأوامر فورًا أثناء الانقطاع — وهي بالضبط الإشارة التي يحتاجها قاطع الدائرة.

اكتب src/Redis.ts:

import { Redis } from 'ioredis';

/**
 * عميل Redis مشترك واحد لكل عملية. `lazyConnect: true` تعني عدم فتح مقبس TCP
 * حتى أول أمر (أو استدعاء .connect() صريح). مع
 * `enableOfflineQueue: false`، ترفض الأوامر الصادرة أثناء الانقطاع
 * فورًا بدلاً من وضعها في قائمة الانتظار — وهذا ما يسمح لقاطع الدائرة
 * برؤية الإخفاقات بسرعة.
 */
export function makeRedis(url: string) {
return new Redis(url{
lazyConnect: true,
enableOfflineQueue: falsemaxRetriesPerRequest: 1commandTimeout: 200retryStrategy: (attempt) => Math.min(1000 * 2 ** attempt30_000)reconnectOnError: () => 1});
}

القيم الافتراضية التي تقوم بتجاوزها هنا: enableOfflineQueue تكون افتراضيًا true و maxRetriesPerRequest تكون افتراضيًا 20 — وكلاهما مفاجئ إذا كنت تريد الفشل السريع.7 تعيد retryStrategy تأخير التراجع بالمللي ثانية لمحاولة إعادة الاتصال رقم N، بحد أقصى 30 ثانية حتى لا يؤدي الانقطاع الطويل إلى شل حلقة الأحداث (event loop). reconnectOnError: () => 1 تعيد محاولة الأمر الفاشل عند محاولة إعادة الاتصال بدلاً من إسقاطه.

الخطوة 5: برمجية وسيطة للمصادقة بفشل مغلق

التحقق من مفتاح API هو الحالة النموذجية للفشل المغلق. إذا لم تتمكن من الوصول إلى Redis للتأكد من أن المفتاح يعود لمستخدم حقيقي، فإن الاستجابة الآمنة الوحيدة هي رفض الطلب — فإرجاع 200 سيكون بمثابة تجاوز للمصادقة.8 اكتب src/middleware/auth.ts:

import { createMiddleware } from 'hono/factory';
import type { Redis } from 'ioredis';
import { CircuitBreaker } from '../lib/circuit-breaker.ts';
import { withDeadline } from '../lib/with-deadline.ts';

export type AuthVars = { userId: string };

export function authMiddleware(Redis: Redis: CircuitBreaker) {
return createMiddleware<{ Variables: AuthVars }>(async (c) => {
const key = c.req.header('x-API-key');
if (!key) {
return c.json({ error: 'missing_api_key' }401);
}
let userId: string | null;
try {
userId = await breaker.fire(() =>
withDeadline(Redis.get(`apikey:${key}`)150'auth.lookup'));
} catch {
// دائرة مفتوحة، تجاوز الموعد النهائي، فصل Redis، خطأ في الأمر —
// أي فشل يعني أننا لا نستطيع التحقق، لذا نرفض الطلب.
return c.json({ error: 'auth_unavailable' }503{
'Retry-After': '30'});
}
if (userId === null) {
return c.json({ error: 'invalid_api_key' }401);
}
c.set('userId');
await next();
});
}

ثلاثة تفاصيل تستحق التنويه:

  1. استخدام catch {} المجرد مقصود — بالنسبة لبرمجيات الحماية الوسيطة (security middleware)، وضع الفشل هو "تعذر التحقق"، وليس "هذا النوع المحدد من الخطأ". خطأ CircuitOpenError وخطأ في مسار Redis وانتهاء المهلة المحددة (deadline timeout) كلها تعني نفس الشيء: رفض الطلب.
  2. تحمل استجابة 503 ترويسة Retry-After: 30 حتى تعرف البرمجيات العميلة (وسياسات إعادة المحاولة في Cloudflare/ALB) مدة التوقف قبل المحاولة مرة أخرى. بدون هذه الترويسة، قد تحاول البرمجيات العميلة جيدة السلوك إعادة المحاولة فوراً وتزيد من العبء على الخلفية البرمجية المتضررة.9
  3. المهلة المحددة بـ 150 مللي ثانية هي الميزانية المخصصة لجهة الطلب لعملية المصادقة. إذا كان معدل p99 لطلب GET السليم هو 10 مللي ثانية، فإن 150 مللي ثانية تترك هامشاً قدره 15 ضعفاً — وهو صغير بما يكفي لجعل حتى الفشل الجزئي في Redis (بطيء ولكن يمكن الوصول إليه) يُفعل قاطع الدائرة (breaker) بسرعة.

الخطوة 6: علامة ميزة "الفشل المفتوح" مع افتراضي آمن

تبدو حالة "الفشل المفتوح" (fail-open) مشابهة ولكنها تعكس السياسة: إذا فشل البحث عن مفتاح الإيقاف (kill switch)، فارجع إلى قيمة افتراضية ثابتة واسمح بمرور الطلب. القرار التقديري يكمن في اختيار هذا الافتراضي. بالنسبة لمفتاح إيقاف "تعطيل الدفع" (checkout disabled)، فإن الافتراضي الآمن هو false (لا تعطل — فتعطل Redis لا ينبغي أن يؤدي أيضاً إلى إيقاف عملية الدفع). بالنسبة لعلامة "تمكين واجهة مستخدم تجريبية"، فإن الافتراضي الآمن هو أيضاً false (لا تعرض واجهة مستخدم غير مكتملة عندما لا تستطيع قراءة الإعدادات). النمط هو: اختر الافتراضي الذي يقلل من نطاق التأثير إذا كان تقديرك خاطئاً.10

اكتب src/middleware/feature-flag.ts:

import { createMiddleware } from 'hono/factory';
import type { Redis } from 'ioredis';
import { CircuitBreaker } from '../lib/circuit-breaker.ts';
import { withDeadline } from '../lib/with-deadline.ts';

export function killSwitchMiddleware(
  Redis: Redis,
  breaker: CircuitBreaker,
  flag: string,
  safeDefault: boolean,
) {
  return createMiddleware(async (c, next) => {
    let killed = safeDefault;
    try {
      const raw = await breaker.fire(() =>
        withDeadline(Redis.get(`flag:${flag}`), 100, 'flag.lookup'),
      );
      killed = raw === '1';
    } catch {
      // Fall through with safeDefault; tag the response so observability
      // tooling can count fallback events.
      c.header('x-flag-fallback', flag);
    }
    if (killed) {
      return c.json({ error: 'feature_disabled' }, 503);
    }
    await next();
  });
}

ترويسة x-flag-fallback هي استثمار صغير في قابلية المراقبة (observability) يؤتي ثماره في المرة الأولى التي تتساءل فيها "هل سلك أي شخص مسار الاحتياط (fallback) فعلياً أثناء انقطاع الخدمة؟". ابحث في سجلات الوصول عن اسم الترويسة وستحصل على عدد دقيق. نفس النمط يعمل مع تكاملات OpenFeature — أرسل عداداً عند كل تقييم للاحتياط.

الخطوة 7: محدد معدل "نافذة منزلقة" بنظام الفشل المفتوح

توجد حدود المعدل (Rate limits) لحماية API من سوء الاستخدام — وليست هي الوظيفة الأساسية التي يقوم بها API. عندما يكون Redis غير قابل للوصول، فإن السلوك المتدهور الصحيح هو "عدم فرض قيود"، وليس "إرجاع 503 لكل طلب". المقايضة صريحة: قد يسمح انقطاع قصير بمرور دفعة من الطلبات دون قيود. هذا دائماً ما يكون أفضل من إيقاف API بالكامل لأن المحدد معطل.11

اكتب src/middleware/rate-limit.ts:

import { createMiddleware } from 'hono/factory';
import { getConnInfo } from '@hono/node-server/conninfo';
import type { Redis } from 'ioredis';
import { CircuitBreaker } from '../lib/circuit-breaker.ts';
import { withDeadline } from '../lib/with-deadline.ts';

export function rateLimitMiddleware(
  Redis: Redis,
  breaker: CircuitBreaker,
  opts: { limit: number; windowSec: number },
) {
  return createMiddleware(async (c, next) => {
    const ip = getConnInfo(c).remote.address ?? 'unknown';
    const key = `rl:${ip}:${Math.floor(Date.now() / 1000 / opts.windowSec)}`;
    let count: number;
    try {
      const reply = await breaker.fire(() =>
        withDeadline(
          Redis.multi().incr(key).expire(key, opts.windowSec).exec(),
          80,
          'rl.incr',
        ),
      );
      const incrResult = reply?.[0];
      count = (incrResult?.[1] as number | undefined) ?? 0;
    } catch {
      c.header('x-ratelimit-fallback', 'open');
      await next();
      return;
    }
    c.header('x-ratelimit-limit', String(opts.limit));
    c.header('x-ratelimit-remaining', String(Math.max(0, opts.limit - count)));
    if (count > opts.limit) {
      return c.json({ error: 'rate_limited' }, 429, {
        'Retry-After': String(opts.windowSec),
      });
    }
    await next();
  });
}

تحافظ MULTI على عمليتي INCR و EXPIRE في رحلة ذهاب وإياب واحدة وتضمن استقرار TTL حتى في الطلب الأول لنافذة جديدة. ترجع exec() مصفوفة Array<[Error | null, unknown]> لكل أمر في الطابور، لذا يكون العدد عند reply[0][1]. لاحظ أنه لاستخراج عنوان IP بشكل صحيح خلف بروكسي، ستحتاج لدمج هذا مع getConnInfo بالإضافة إلى محدد قفزات X-Forwarded-For موثوق — يغطي البرنامج التعليمي الشقيق لمحدد المعدل ذلك بالتفصيل.12

الخطوة 8: التجميع في تطبيق Hono

الآن قم بربط البرمجيات الوسيطة الثلاثة بمسار واحد. اكتب src/app.ts:

import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { makeRedis } from './Redis.ts';
import { CircuitBreaker } from './lib/circuit-breaker.ts';
import { authMiddleware, type AuthVars } from './middleware/auth.ts';
import { killSwitchMiddleware } from './middleware/feature-flag.ts';
import { rateLimitMiddleware } from './middleware/rate-limit.ts';

export function createApp(redisUrl: string) {
const Redis = makeRedis(redisUrl);

// One breaker per dependency surface. A single shared breaker across all
// middleware works too — but a slow `incr` would then open the auth
// circuit, which masks which call is actually broken. Start with one
// breaker per Redis-surface and split if you need finer-grained isolation.
const breaker = new CircuitBreaker({
name: 'Redis',
failureThreshold: 5,
resetMs: 5_000,
});

const app = new Hono<{ Variables: AuthVars }>();
app.use(logger());

app.get('/healthz', (c) => c.json({ ok: true }));

app.get(
'/API/checkout',
rateLimitMiddleware(Redis, breaker, { limit: 30, windowSec: 60 }),
killSwitchMiddleware(Redis, breaker 'checkout' /*safeDefault=*/ false),
authMiddleware(Redis, breaker),
(c) => c.json({ ok: true, user: c.var.userId }),
);

return { app, Redis };
}

ترتيب البرمجيات الوسيطة مهم وهذا الترتيب هنا مقصود. يتم تشغيل محدد المعدل أولاً حتى لا ينهك فيضان من الطلبات غير المصادق عليها Redis لعمليات البحث عن المصادقة؛ ثم يتم تشغيل مفتاح الإيقاف حتى ترجع الميزة المعطلة استجابة سريعة دون رحلة ذهاب وإياب للمصادقة؛ وتعمل المصادقة أخيراً لأنها الأكثر تكلفة (البحث عن مفتاح) والأكثر حساسية (نتيجتها تحدد ما إذا كان المستخدم معروفاً أصلاً). أياً كان الترتيب الذي تختاره، قم بتوثيقه — فتجميع البرمجيات الوسيطة هو أحد أكثر المصادر شيوعاً للأخطاء الدقيقة في بيئة الإنتاج.

اكتب src/index.ts:

import { serve } from '@hono/node-server';
import { createApp } from './app.ts';

const url = process.env.REDIS_URL ?? 'Redis://127.0.0.1:6379';
const { app, Redis } = createApp(url);

// Required: ioredis emits 'error' on every reconnect failure. Without a
// listener the process crashes with `unhandledRejection` on the first
// disconnect. The middleware applies the actual fail policy per-request;
// this listener exists only so the process survives the event.
Redis.on('error', (err) => {
console.warn('[Redis] error:', err.message);
});

// Connect on boot so a typo in REDIS_URL surfaces during deploy, not at
// first request. We swallow the error — the middleware degrades correctly
// if the boot connect fails.
await Redis.connect().catch((err) => {
console.warn('[boot] Redis connect failed; serving in degraded mode:', err.message);
});

const port = Number(process.env.PORT ?? 3000);
serve({ fetch: app.fetch, port });
console.log(`listening on http://127.0.0.1:${port}`);

استخدام Redis.on('error', ...) ليس اختيارياً. بدونه، يصبح أول فشل في إعادة الاتصال unhandledRejection ويؤدي لتعطل العملية. لا يمكن للبرمجيات الوسيطة مساعدتك إذا كان وقت التشغيل (runtime) متوقفاً.

الخطوة 9: التحقق من المسار السليم مع تشغيل Redis

ابدأ تشغيل Redis والخادم في نافذتي تيرمينال:

# Terminal 1
Docker run --rm -p 6379:6379 Redis:8-alpine

# Terminal 2 — same directory as the project
SECRET_KEY=letmein
Docker exec -i $(Docker ps -qf ancestor=Redis:8-alpine) \
Redis-cli SET apikey:$SECRET_KEY user-42
node --import tsx src/index.ts

يقوم node --import tsx بتحميل tsx كخطاف لتسجيل الوحدات (module-register hook) بحيث يتم حل استيرادات .ts دون خطوة بناء منفصلة. يتطلب ذلك إصدار Node 20.6+ لـ API الخاص بـ module.register(). إذا كنت تفضل واجهة سطر الأوامر المستقلة (مع وضع المراقبة وعلامات أخرى)، فإن npx tsx src/index.ts يعمل بشكل جيد أيضاً — توصي الأسئلة الشائعة الخاصة بـ tsx بالشكل المستقل للتطوير، لأن شكل المحمل يضيف عبئاً بسيطاً لترجمة الكود عند كل طلب.

الآن اختبر API:

# Healthcheck — no Redis path
curl -s -w '\n%{http_code}\n' http://127.0.0.1:3000/healthz
# {"ok":true}
# 200

# Missing key — auth middleware short-circuits with 401
curl -s -w '\n%{http_code}\n' http://127.0.0.1:3000/API/checkout
# {"error":"missing_api_key"}
# 401

# Valid key — happy path
curl -s -i -H 'x-API-key: letmein' http://127.0.0.1:3000/API/checkout
# HTTP/1.1 200 OK
# x-ratelimit-limit: 30
# x-ratelimit-remaining: 29
# {"ok":true,"user":"user-42"}

ترويسات x-ratelimit-limit و x-ratelimit-remaining هي الطريقة التي تتعرف بها البرمجيات العميلة على ميزانيتها دون استدعاء منفصل لـ API. يصف RFC الخاص بترويسات حدود المعدل من IETF مخططاً أغنى (حقول منظمة RateLimit + RateLimit-Policy) إذا كنت تريد الامتثال الكامل للمواصفات.13

الخطوة 10: التحقق من مسار الفشل مع توقف Redis

أوقف حاوية Redis باستخدام Docker stop (أو فقط أغلق التيرمينال). يستمر الخادم في العمل. الآن اختبر API بكثافة:

# 1. Auth fails closed — 503 with Retry-After
curl -s -i -H 'x-API-key: letmein' http://127.0.0.1:3000/API/checkout
# HTTP/1.1 503 Service Unavailable
# retry-after: 30
# x-flag-fallback: checkout
# x-ratelimit-fallback: open
# {"error":"auth_unavailable"}

# 2. The breaker is shared across all three middleware, so each failing
#    request bumps its counter by up to 3 (one per middleware that calls
#    fire()). With failureThreshold: 5 the circuit opens within the first
#    2 requests, and every subsequent call short-circuits in milliseconds
#    instead of waiting for the per-middleware deadline budget:
for i in {1..8}; do
time curl -s -o /dev/null -w '%{http_code} ' \
-H 'x-API-key: letmein' http://127.0.0.1:3000/API/checkout
done
# 503 503 503 503 503 503 503 503
# (every call settles in single-digit ms once the breaker is OPEN —
#  if you want a "5 consecutive failures per operation" model instead,
#  pass a separate CircuitBreaker into each middleware factory)

هذا هو السلوك بالضبط الذي تعيد نسخة الصندوق الرملي (sandbox) من البرنامج التعليمي إنتاجه بالكامل. ترسل البرمجيات الوسيطة ذات الفشل المفتوح ترويسات الاحتياط الخاصة بها (x-flag-fallback، x-ratelimit-fallback) مما يجعل الحالة المتدهورة قابلة للبحث عنها برمجياً — يمكن لـ Datadog أو ناقل سجلات جانبي التنبيه عند حدوث ارتفاع مفاجئ في استجابات الاحتياط دون الحاجة إلى تزويد كل برمجية وسيطة بأدوات قياس.

أعد تشغيل Redis:

Docker run --rm -p 6379:6379 Redis:8-alpine

بعد نافذة resetMs: 5_000، ينتقل القاطع إلى حالة HALF_OPEN. الاستدعاء التالي مباشرة يكون بمثابة اختبار — إذا نجح يغلق القاطع، وإذا فشل يعود مباشرة إلى حالة OPEN. يتعافى API الخاص بك دون إعادة تشغيل ودون تدخل يدوي.

قائمة التحقق من الصحة

يمكنك التأكد من صحة الربط دون تشغيل اختبار الحمل عن طريق فحص الأنواع (typechecking):

npx tsc --noEmit
# (no output = clean)

ثم يحاكي اختبار سريع لمدة 10 ثوانٍ ضد منفذ لا يمكن الوصول إليه حالة تعطل Redis دون الحاجة إلى Docker على الإطلاق:

REDIS_URL=Redis://127.0.0.1:9999 node --import tsx src/index.ts &
sleep 1
curl -s -i -H 'x-API-key: t' http://127.0.0.1:3000/API/checkout
# Expect: 503 with x-flag-fallback + x-ratelimit-fallback headers
# (this is the exact response shape verified in the sandbox)
kill %1

الأخطاء الشائعة

Stream isn't writeable and enableOfflineQueue options is false. هذه هي الرسالة التي يرجعها ioredis عندما ترسل أمراً بينما المقبس (socket) مفصول وخيار طابور عدم الاتصال (offline queueing) مغلق. هذا هو السلوك المقصود — يجب أن تقوم كتلة catch {} بابتلاع هذا الخطأ. إذا كنت تراه يظهر كخطأ 500، فهذا يعني أن try/catch مغلفة في المستوى الخاطئ. تأكد من أن await breaker.fire(...) موجودة داخل try، وليس خارجها.

توقف العملية مع unhandledRejection: connect ECONNREFUSED. لقد نسيت مستمع Redis.on('error', ...) في src/index.ts. يرسل ioredis حدث 'error' عند كل فشل في إعادة الاتصال (والذي يمكن أن يحدث كل بضع ثوانٍ أثناء انقطاع طويل). لا يحتاج المستمع للقيام بأي شيء — يجب فقط أن يكون موجوداً حتى لا يتعامل Node مع الحدث كخطأ غير معالج.

قاطع الدائرة (breaker) يفتح بسرعة كبيرة أثناء حركة المرور العادية. هناك سببان محتملان. (1) ميزانيات commandTimeout و withDeadline الخاصة بك أضيق من زمن انتقال p99 الصحي، لذا تبدو الاستدعاءات البطيئة المشروعة وكأنها إخفاقات. قم بتشغيل Redis-cli --latency ضد Redis الخاص بك لتحديد خط الأساس لرحلة الذهاب والعودة، ثم اضبط الموعد النهائي (deadline) على ما يقرب من 10 أضعاف p99. (2) أنت تشارك قاطعًا واحدًا عبر أعباء عمل غير متجانسة — يمكن لفحص KEYS بطيء أن يفتح نفس القاطع الذي يستخدمه INCR. قم بالتقسيم إلى قواطع لكل عملية إذا كانت أعباء العمل تختلف بشكل ملموس.

قاطع الدائرة لا يفتح أبدًا. إما أن عتبة الفشل مرتفعة جدًا بالنسبة لنمط حركة المرور لديك، أو أن enableOfflineQueue لا يزال true، وفي هذه الحالة يتم وضع الأوامر في طابور بصمت أثناء الانقطاع ولا يتم إلقاء خطأ أبدًا. تحقق من خيارات العميل الفعلية باستخدام console.log(Redis.options.enableOfflineQueue) عند بدء التشغيل.

المصادقة (Auth) تعيد 503 حتى بعد تعافي Redis. لم تنقضِ مدة resetMs الخاصة بالقاطع بعد، لذا لا يزال في حالة OPEN (مفتوح). إما أن تقلل resetMs (5 ثوانٍ معقولة لـ API؛ و60 ثانية لعامل دفعات "batch worker") أو استدعِ المسبار (probe) في وقت أقرب — لكن لا تقلله عن وقت التعافي المعتاد، وإلا ستتذبذب حالة HALF_OPEN.

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

هذا النمط قابل للتعميم. في أي مكان يقرأ فيه برنامجك الوسيط (middleware) من تبعية لا تتحكم فيها، تنطبق نفس الأسئلة الثلاثة: ماذا يحمي هذا البرنامج الوسيط (الأمان أم السعة)؟ ماذا يحدث للمستخدم إذا اختفت التبعية؟ ما هو الافتراضي الآمن؟ البرامج الوسيطة من نوع المصادقة (البحث عن الجلسات، التحقق من JWT مقابل نقطة نهاية JWKS) تفشل دائمًا في وضع الإغلاق (fail closed). البرامج الوسيطة للقياس عن بُعد (تسجيل اللوجات، التتبع) تفشل دائمًا في وضع الفتح (fail open) — لا تسمح أبدًا لطبقة المراقبة الخاصة بك بإسقاط API. البرامج الوسيطة للأعمال (أعلام الميزات "feature flags"، تعيين اختبارات A/B، البحث الجغرافي) تعيش في المنتصف وتحتاج إلى افتراضي آمن صريح لكل علم.

إليك بعض الاتجاهات للقراءة اللاحقة على هذا الموقع:

إذا كنت تريد قاطع دائرة بمستوى الإنتاج بدلاً من النسخة التعليمية المكونة من 40 سطرًا، فاستخدم opossum3. يختلف الشكل قليلاً — يقوم opossum بتغليف دالة في وقت الإنشاء (new CircuitBreaker(fn, opts).fire(...args)) بدلاً من وقت الاستدعاء، لذا تقوم بإنشاء قاطع واحد لكل عملية Redis (أو لكل مورد منطقي) بدلاً من تمرير قاطع عام. في المقابل، تحصل على عتبات قائمة على النسبة المئوية، ودلاء متدحرجة (rolling buckets)، وخطافات أحداث (event hooks) لإصدار المقاييس. تظل سياسات fail-open مقابل fail-closed في برنامجك الوسيط كما هي تمامًا.

Footnotes

  1. توثيق npm CLI، أمر npm install (الخيار -E / --save-exact): يكتب الإصدار الذي تم حله حرفيًا في package.json بدلاً من نطاق علامة الإقحام (caret) الافتراضي. https://docs.npmjs.com/cli/v11/commands/npm-install/.

  2. مايكل نيغارد، Release It! (Pragmatic Bookshelf)، يقدم قاطع الدائرة ثلاثي الحالات؛ ملخص مارتن فاولر: https://martinfowler.com/bliki/CircuitBreaker.html.

  3. opossum على npm: https://www.npmjs.com/package/opossum — الإصدار v9.0.0 نُشر في 2025-06-05، رخصة Apache-2.0، المحركات ^20 || ^22 || ^24. الحزمة تشحن JavaScript فقط؛ يعتمد مستخدمو TypeScript حاليًا على @types/opossum (الذي يتأخر عن إصدار وقت التشغيل) أو يكتبون حشوات أنواع (type shims) محلية بسيطة. 2

  4. كتاب Google SRE (المجلد الأول، "Site Reliability Engineering: How Google Runs Production Systems")، الفصل 22 "Addressing Cascading Failures": المواعيد النهائية (deadlines) وتخفيف الحمل (load shedding) هي تقنيات من الدرجة الأولى لمنع تبعية واحدة بطيئة من إسقاط مخطط الاستدعاء بالكامل. https://sre.google/sre-book/addressing-cascading-failures/.

  5. توثيق Node.js Globals، فئة AbortController: أداة مساعدة متوافقة مع الويب-API تُستخدم للإشارة إلى الإلغاء في واجهات برمجة تطبيقات مختارة تعتمد على Promise (مثل fetch، fs، setTimeout). لا توجد أداة أساسية عامة لإلغاء Promise في اللغة. https://nodejs.org/API/globals.html#class-abortcontroller.

  6. ملف README الخاص بـ ioredis، قسم "Offline Queue" (يغطي enableOfflineQueue) وقسم "Auto-reconnect" (يغطي retryStrategy و reconnectOnError): https://GitHub.com/Redis/ioredis/blob/main/README.md#offline-queue.

  7. القيم الافتراضية لـ ioredis (تم التحقق منها في وقت التشغيل في بناء sandbox لهذا الدرس): enableOfflineQueue: true، maxRetriesPerRequest: 20. قم بتجاوز كليهما للحصول على سلوك الفشل السريع (fail-fast).

  8. معيار التحقق من أمان تطبيقات OWASP، V2 (المصادقة): النظام الذي "يفشل في وضع الفتح" (fails open) عند فشل المصادقة يعتبر ثغرة أمنية صريحة. https://owasp.org/www-project-application-security-verification-standard/.

  9. RFC 9110 §10.2.3 — قد يكون Retry-After إما تاريخ HTTP أو عددًا غير سالب من الثواني؛ يستخدم هذا الدرس تنسيق الثواني. https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3.

  • مواصفات OpenFeature، تقييم الأعلام (Flag Evaluation) API (المتطلب 1.4.10): "يجب أن تعيد استدعاءات تقييم الأعلام دائمًا الـ default value في حالة التنفيذ غير الطبيعي." تأخذ كل طريقة تقييم محددة النوع وسيطًا إلزاميًا هو default value يعيده SDK عندما لا يمكن الوصول إلى المزود. https://openfeature.dev/specification/sections/flag-evaluation/.

  • خيار الـ timeout الموثق في SDK الخاص بـ Upstash rate-limit يفشل بالفتح (fails open) حسب التصميم لنفس هذا السبب. https://upstash.com/docs/oss/sdks/ts/ratelimit/overview.

  • نيرد ليفل تك، "API تحديد معدل الطلبات (Rate Limiting) مع Upstash Redis: دليل Node لعام 2026" — يغطي جانب استخراج IP وترويسات IETF لمحدد المعدل بالتفصيل: https://nerdleveltech.com/API-rate-limiting-upstash-Redis-sliding-window-tutorial.

  • مسودة IETF رقم draft-ietf-httpapi-ratelimit-headers — حقول منظمة لـ RateLimit + RateLimit-Policy. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/.


  • نشرة أسبوعية مجانية

    ابقَ على مسار النيرد

    بريد واحد أسبوعياً — دورات، مقالات معمّقة، أدوات، وتجارب ذكاء اصطناعي.

    بدون إزعاج. إلغاء الاشتراك في أي وقت.