أساسيات JavaScript

معالجة الأخطاء في الكود غير المتزامن

45 دقيقة الدرس 44 من 60

لماذا تهم معالجة الأخطاء غير المتزامنة

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

في هذا الدرس، ستتعلم كيفية تحديد والتقاط والتعافي من كل نوع من الأخطاء التي يمكن أن تحدث في كود JavaScript غير المتزامن. سنبني أنماطاً متزايدة التعقيد، من كتل try/catch الأساسية إلى منطق إعادة المحاولة وقواطع الدوائر الجاهزة للإنتاج.

أنواع الأخطاء في الكود غير المتزامن

قبل أن تتمكن من معالجة الأخطاء بفعالية، تحتاج إلى فهم الفئات المختلفة للأخطاء التي ستواجهها عند العمل مع واجهات API والعمليات غير المتزامنة.

  • أخطاء الشبكة -- لم يصل الطلب أبداً إلى الخادم. يحدث هذا عندما يكون المستخدم غير متصل بالإنترنت أو يفشل بحث DNS أو لا يمكن الوصول إلى الخادم. يرفض وعد fetch() بخطأ TypeError.
  • أخطاء المهلة الزمنية -- استغرق الخادم وقتاً طويلاً للاستجابة. تم إرسال الطلب لكن الاستجابة لم تصل ضمن الإطار الزمني المتوقع.
  • أخطاء HTTP -- استلم الخادم الطلب لكنه أعاد رمز حالة خطأ (4xx أو 5xx). لاحظ أن fetch() لا ترفض لأخطاء HTTP -- يجب التحقق من response.ok يدوياً.
  • أخطاء التحقق -- رفض الخادم الطلب لأن البيانات المرسلة غير صالحة. تعود عادةً كرموز حالة 400 أو 422 مع تفاصيل حول الحقول الفاشلة.
  • أخطاء الخادم -- حدث خطأ ما على جانب الخادم (500، 502، 503). هذه ليست خطأك، لكن لا يزال عليك التعامل معها بأناقة.
  • أخطاء التحليل -- لا يمكن تحليل جسم الاستجابة كـ JSON. يحدث هذا عندما يعيد الخادم صفحات HTML بدلاً من JSON أو عندما تكون الاستجابة تالفة.
  • أخطاء المصادقة -- انتهت صلاحية رمزك أو أنه غير صالح (401)، أو تفتقر إلى الإذن للمورد المطلوب (403).

مثال: تحديد أنواع الأخطاء

async function identifyError(url) {
    try {
        const response = await fetch(url);

        // أخطاء HTTP -- fetch لا ترفض لهذه
        if (response.status === 401) {
            throw new Error('AUTHENTICATION: يرجى تسجيل الدخول مرة أخرى');
        }
        if (response.status === 403) {
            throw new Error('PERMISSION: ليس لديك صلاحية الوصول لهذا المورد');
        }
        if (response.status === 404) {
            throw new Error('NOT_FOUND: المورد المطلوب غير موجود');
        }
        if (response.status === 422) {
            const details = await response.json();
            throw new Error('VALIDATION: ' + JSON.stringify(details.errors));
        }
        if (response.status === 429) {
            throw new Error('RATE_LIMIT: طلبات كثيرة جداً، تمهل');
        }
        if (response.status >= 500) {
            throw new Error('SERVER: واجه الخادم خطأ');
        }
        if (!response.ok) {
            throw new Error('HTTP_ERROR: الحالة ' + response.status);
        }

        // أخطاء التحليل
        try {
            const data = await response.json();
            return data;
        } catch (parseError) {
            throw new Error('PARSE: الاستجابة ليست JSON صالحة');
        }
    } catch (error) {
        // أخطاء الشبكة -- fetch ترفض لهذه
        if (error instanceof TypeError) {
            throw new Error('NETWORK: لا يمكن الوصول للخادم. تحقق من اتصالك.');
        }
        throw error;
    }
}

Try/Catch مع Async/Await

عبارة try/catch هي الطريقة الأساسية لمعالجة الأخطاء في كود async/await. عندما يُرفض وعد منتظر، يتم طرح الرفض كاستثناء يمكن التقاطه بواسطة كتلة catch المحيطة. هذا يمنح الكود غير المتزامن نفس نمط معالجة الأخطاء المألوف في الكود المتزامن.

مثال: نمط Try/Catch الأساسي

// النمط الأساسي -- التقاط جميع الأخطاء من العمليات غير المتزامنة
async function fetchUserProfile(userId) {
    try {
        const response = await fetch(
            'https://jsonplaceholder.typicode.com/users/' + userId
        );

        if (!response.ok) {
            throw new Error('الخادم أعاد الحالة ' + response.status);
        }

        const user = await response.json();
        console.log('تم تحميل المستخدم:', user.name);
        return user;

    } catch (error) {
        console.error('فشل تحميل ملف المستخدم:', error.message);
        return null;
    }
}

// استدعاءات await متعددة في كتلة try واحدة
async function fetchUserWithPosts(userId) {
    try {
        const userResponse = await fetch(
            'https://jsonplaceholder.typicode.com/users/' + userId
        );
        if (!userResponse.ok) {
            throw new Error('فشل جلب المستخدم');
        }
        const user = await userResponse.json();

        const postsResponse = await fetch(
            'https://jsonplaceholder.typicode.com/posts?userId=' + userId
        );
        if (!postsResponse.ok) {
            throw new Error('فشل جلب المنشورات');
        }
        const posts = await postsResponse.json();

        return { user: user, posts: posts };

    } catch (error) {
        console.error('خطأ:', error.message);
        return { user: null, posts: [] };
    }
}

// كتل try/catch منفصلة للعمليات المستقلة
async function loadDashboard() {
    let user = null;
    let notifications = [];
    let stats = null;

    try {
        const response = await fetch('/api/user/profile');
        user = await response.json();
    } catch (error) {
        console.error('فشل تحميل الملف الشخصي:', error.message);
    }

    try {
        const response = await fetch('/api/notifications');
        notifications = await response.json();
    } catch (error) {
        console.error('فشل تحميل الإشعارات:', error.message);
    }

    try {
        const response = await fetch('/api/stats');
        stats = await response.json();
    } catch (error) {
        console.error('فشل تحميل الإحصائيات:', error.message);
    }

    // لوحة المعلومات لا تزال تعمل حتى لو فشلت بعض الأقسام
    return { user, notifications, stats };
}
ملاحظة: عندما يكون لديك عمليات غير متزامنة مستقلة متعددة، اغلف كلاً منها في كتلة try/catch خاصة بها. إذا وضعتها جميعاً في كتلة try واحدة، فإن أول فشل سيتخطى جميع العمليات اللاحقة. الكتل المنفصلة تسمح بالنجاح الجزئي -- يمكن للوحة المعلومات أن تعرض ملف المستخدم حتى لو فشل تحميل الإشعارات.

كتلة Finally

كتلة finally تنفذ بغض النظر عما إذا نجحت كتلة try أو فشلت. هذا المكان المثالي لتنظيف الموارد وإخفاء مؤشرات التحميل أو إعادة تعيين الحالة. تعمل بعد كل من الإكمال الناجح وبعد معالجة الأخطاء.

مثال: استخدام Finally للتنظيف

async function submitForm(formData) {
    const submitButton = document.getElementById('submit-btn');
    const spinner = document.getElementById('spinner');

    // تعطيل الزر وإظهار المؤشر الدوار قبل الطلب
    submitButton.disabled = true;
    spinner.style.display = 'block';

    try {
        const response = await fetch('/api/forms/submit', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(formData),
        });

        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.message || 'فشل الإرسال');
        }

        const result = await response.json();
        showSuccess('تم إرسال النموذج بنجاح!');
        return result;

    } catch (error) {
        showError('فشل الإرسال: ' + error.message);
        return null;

    } finally {
        // إعادة تفعيل الزر وإخفاء المؤشر دائماً
        submitButton.disabled = false;
        spinner.style.display = 'none';
    }
}

function showSuccess(message) {
    console.log('نجاح:', message);
}

function showError(message) {
    console.error('خطأ:', message);
}

معالجة أخطاء Promise باستخدام .catch() و .then()

عند العمل مع الوعود مباشرةً (بدون async/await)، تتم معالجة الأخطاء باستخدام طريقة .catch(). كل سلسلة وعود يجب أن تنتهي بـ .catch() لمنع الرفض غير المعالج. يمكنك أيضاً تمرير وسيط ثانٍ لـ .then() لمعالجة الأخطاء المضمنة.

مثال: معالجة أخطاء سلسلة الوعود

// .catch() في نهاية سلسلة الوعود
fetch('https://jsonplaceholder.typicode.com/posts/1')
    .then(function(response) {
        if (!response.ok) {
            throw new Error('خطأ HTTP: ' + response.status);
        }
        return response.json();
    })
    .then(function(post) {
        console.log('عنوان المنشور:', post.title);
        return fetch(
            'https://jsonplaceholder.typicode.com/posts/' + post.id + '/comments'
        );
    })
    .then(function(response) {
        return response.json();
    })
    .then(function(comments) {
        console.log('التعليقات:', comments.length);
    })
    .catch(function(error) {
        // يلتقط الأخطاء من أي خطوة في السلسلة
        console.error('حدث خطأ ما:', error.message);
    })
    .finally(function() {
        console.log('اكتمل الطلب (نجاح أو فشل)');
    });

// Promise.allSettled -- لا ترفض أبداً، تبلغ عن جميع النتائج
Promise.allSettled([
    fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json()),
    fetch('https://api.invalid-domain.com/fail').then(r => r.json()),
    fetch('https://jsonplaceholder.typicode.com/todos/1').then(r => r.json()),
])
.then(function(results) {
    results.forEach(function(result, index) {
        if (result.status === 'fulfilled') {
            console.log('الطلب ' + index + ' نجح:', result.value);
        } else {
            console.error('الطلب ' + index + ' فشل:', result.reason.message);
        }
    });
});
نصيحة احترافية: استخدم Promise.allSettled() بدلاً من Promise.all() عندما تريد إكمال جميع الطلبات بغض النظر عن الإخفاقات الفردية. مع Promise.all()، رفض واحد يتسبب في فشل الدفعة بأكملها. مع Promise.allSettled()، تحصل على نتيجة كل وعد -- سواء نجح أو فشل -- ويمكنك التعامل مع كل منها بشكل فردي.

معالجات الأخطاء العامة

حتى مع المعالجة الدقيقة للأخطاء، يمكن أن تتسلل الرفضات غير المعالجة. يوفر JavaScript معالجات أحداث عامة تعمل كشبكة أمان لالتقاط الأخطاء التي فاتتك. يجب استخدامها كملاذ أخير -- وليس كبديل لكتل try/catch الصحيحة.

مثال: معالجات الأخطاء العامة

// التقاط رفض الوعود غير المعالجة
window.addEventListener('unhandledrejection', function(event) {
    console.error(
        'رفض وعد غير معالج:',
        event.reason
    );

    // تسجيل في خدمة مراقبة الأخطاء
    logErrorToService({
        type: 'unhandled_rejection',
        message: event.reason.message || String(event.reason),
        stack: event.reason.stack || 'لا يوجد تتبع مكدس',
        timestamp: new Date().toISOString(),
        url: window.location.href,
    });

    event.preventDefault();
});

// التقاط جميع أخطاء JavaScript غير الملتقطة
window.onerror = function(message, source, lineno, colno, error) {
    console.error('خطأ غير ملتقط:', {
        message: message,
        source: source,
        line: lineno,
        column: colno,
        error: error,
    });

    logErrorToService({
        type: 'uncaught_error',
        message: message,
        source: source,
        line: lineno,
        column: colno,
        stack: error ? error.stack : 'لا يوجد تتبع مكدس',
        timestamp: new Date().toISOString(),
        url: window.location.href,
    });

    return true;
};

function logErrorToService(errorData) {
    console.log('[خدمة الأخطاء] تسجيل خطأ:', errorData);
}
تحذير: لا تعتمد فقط على معالجات الأخطاء العامة. إنها شبكة أمان لالتقاط الأخطاء التي فاتتك، وليست بديلاً لكتل try/catch الصحيحة. الرفض غير المعالج يشير إلى خلل في الكود -- عندما ترى واحداً مسجلاً، ارجع وأضف معالجة أخطاء صحيحة عند المصدر.

فئات الأخطاء المخصصة

إنشاء فئات أخطاء مخصصة يسمح لك بالتمييز بين أنواع الأخطاء المختلفة برمجياً. هذا يجعل من الممكن التعامل مع كل نوع خطأ بشكل مختلف -- مثلاً إعادة المحاولة عند أخطاء الشبكة لكن عرض رسالة عند أخطاء التحقق.

مثال: تسلسل أخطاء مخصص

// فئة خطأ API الأساسية
class ApiError extends Error {
    constructor(message, status, data) {
        super(message);
        this.name = 'ApiError';
        this.status = status;
        this.data = data;
    }
}

class NetworkError extends ApiError {
    constructor(message) {
        super(message || 'فشل اتصال الشبكة', 0, null);
        this.name = 'NetworkError';
        this.isRetryable = true;
    }
}

class TimeoutError extends ApiError {
    constructor(message, timeoutMs) {
        super(message || 'انتهت مهلة الطلب', 0, null);
        this.name = 'TimeoutError';
        this.timeoutMs = timeoutMs;
        this.isRetryable = true;
    }
}

class ValidationError extends ApiError {
    constructor(message, fieldErrors) {
        super(message || 'فشل التحقق', 422, fieldErrors);
        this.name = 'ValidationError';
        this.fieldErrors = fieldErrors;
        this.isRetryable = false;
    }
}

class AuthenticationError extends ApiError {
    constructor(message) {
        super(message || 'المصادقة مطلوبة', 401, null);
        this.name = 'AuthenticationError';
        this.isRetryable = false;
    }
}

class ServerError extends ApiError {
    constructor(message, status) {
        super(message || 'حدث خطأ في الخادم', status || 500, null);
        this.name = 'ServerError';
        this.isRetryable = true;
    }
}

class RateLimitError extends ApiError {
    constructor(retryAfterSeconds) {
        super('تم تجاوز حد المعدل', 429, null);
        this.name = 'RateLimitError';
        this.retryAfter = retryAfterSeconds;
        this.isRetryable = true;
    }
}

// استخدام الأخطاء المخصصة
async function fetchWithErrorHandling(url, options) {
    let response;

    try {
        response = await fetch(url, options);
    } catch (fetchError) {
        throw new NetworkError('لا يمكن الوصول للخادم: ' + fetchError.message);
    }

    if (response.status === 401) {
        throw new AuthenticationError();
    }
    if (response.status === 422) {
        const body = await response.json();
        throw new ValidationError('بيانات غير صالحة', body.errors);
    }
    if (response.status === 429) {
        const retryAfter = response.headers.get('Retry-After');
        throw new RateLimitError(retryAfter ? parseInt(retryAfter) : 60);
    }
    if (response.status >= 500) {
        throw new ServerError('الخادم أعاد ' + response.status, response.status);
    }
    if (!response.ok) {
        throw new ApiError('فشل الطلب', response.status, null);
    }

    return await response.json();
}

منطق إعادة المحاولة مع التراجع الأسي

عندما يفشل طلب بسبب مشكلة مؤقتة (خلل في الشبكة، تحميل زائد على الخادم)، إعادة محاولة الطلب بعد تأخير غالباً ما تنجح. التراجع الأسي يعني أن كل إعادة محاولة تنتظر أطول من السابقة، مما يمنح الخادم وقتاً للتعافي. إضافة عشوائية تمنع العديد من العملاء من إعادة المحاولة في نفس الوقت بالضبط.

مثال: إعادة المحاولة مع التراجع الأسي

async function fetchWithRetry(url, options, maxRetries, baseDelay) {
    maxRetries = maxRetries || 3;
    baseDelay = baseDelay || 1000;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const response = await fetch(url, options);

            // لا تعيد المحاولة لأخطاء العميل (باستثناء 429)
            if (response.status >= 400 && response.status < 500 && response.status !== 429) {
                const errorBody = await response.text();
                throw new Error('خطأ عميل ' + response.status + ': ' + errorBody);
            }

            if (!response.ok) {
                if (attempt < maxRetries) {
                    const delay = calculateBackoff(attempt, baseDelay);
                    console.log(
                        'المحاولة ' + (attempt + 1) + ' فشلت بالحالة ' +
                        response.status + '. إعادة المحاولة خلال ' + delay + 'ms...'
                    );
                    await sleep(delay);
                    continue;
                }
                throw new Error('فشل بعد ' + (maxRetries + 1) + ' محاولات: ' + response.status);
            }

            return await response.json();

        } catch (error) {
            if (error instanceof TypeError && attempt < maxRetries) {
                const delay = calculateBackoff(attempt, baseDelay);
                console.log(
                    'خطأ شبكة في المحاولة ' + (attempt + 1) +
                    '. إعادة المحاولة خلال ' + delay + 'ms...'
                );
                await sleep(delay);
                continue;
            }

            if (attempt === maxRetries) {
                throw new Error(
                    'فشلت جميع المحاولات ' + (maxRetries + 1) + '. آخر خطأ: ' + error.message
                );
            }

            throw error;
        }
    }
}

function calculateBackoff(attempt, baseDelay) {
    const exponentialDelay = baseDelay * Math.pow(2, attempt);
    const jitter = Math.random() * exponentialDelay * 0.5;
    return Math.min(exponentialDelay + jitter, 30000);
}

function sleep(ms) {
    return new Promise(function(resolve) {
        setTimeout(resolve, ms);
    });
}

نمط قاطع الدائرة

نمط قاطع الدائرة يمنع تطبيقك من استدعاء خدمة فاشلة بشكل متكرر. مثل قاطع الدائرة الكهربائية، "ينقطع" بعد حد معين من الإخفاقات ويتوقف عن إجراء الطلبات لفترة تبريد. هذا يحمي تطبيقك والخادم الفاشل من الإغراق بطلبات محكوم عليها بالفشل.

لقاطع الدائرة ثلاث حالات:

  • مغلق -- التشغيل العادي. الطلبات تمر. يتم عد الإخفاقات.
  • مفتوح -- إخفاقات كثيرة جداً. جميع الطلبات ترفض فوراً بدون استدعاء الخادم.
  • نصف مفتوح -- بعد فترة التبريد، يسمح بطلب اختبار واحد. إذا نجح، يغلق القاطع. إذا فشل، يفتح القاطع مرة أخرى.

مثال: تطبيق قاطع الدائرة

class CircuitBreaker {
    constructor(options) {
        this.failureThreshold = options.failureThreshold || 5;
        this.resetTimeout = options.resetTimeout || 30000;
        this.state = 'CLOSED';
        this.failureCount = 0;
        this.lastFailureTime = null;
        this.successCount = 0;
    }

    async execute(asyncFunction) {
        if (this.state === 'OPEN') {
            var timeSinceFailure = Date.now() - this.lastFailureTime;
            if (timeSinceFailure > this.resetTimeout) {
                this.state = 'HALF_OPEN';
                console.log('قاطع الدائرة: نصف مفتوح (اختبار)');
            } else {
                var waitTime = this.resetTimeout - timeSinceFailure;
                throw new Error(
                    'قاطع الدائرة مفتوح. أعد المحاولة خلال ' +
                    Math.ceil(waitTime / 1000) + ' ثوانٍ.'
                );
            }
        }

        try {
            var result = await asyncFunction();
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }

    onSuccess() {
        if (this.state === 'HALF_OPEN') {
            console.log('قاطع الدائرة: مغلق (الخدمة تعافت)');
        }
        this.failureCount = 0;
        this.state = 'CLOSED';
        this.successCount++;
    }

    onFailure() {
        this.failureCount++;
        this.lastFailureTime = Date.now();

        if (this.failureCount >= this.failureThreshold) {
            this.state = 'OPEN';
            console.log(
                'قاطع الدائرة: مفتوح بعد ' +
                this.failureCount + ' إخفاقات'
            );
        }
    }
}

تنفيذ المهلة الزمنية

واجهة fetch() لا تحتوي على آلية مهلة زمنية مدمجة افتراضياً. إذا توقف الخادم، يمكن أن ينتظر طلبك إلى ما لا نهاية. تحتاج إلى تنفيذ المهلات بنفسك باستخدام AbortController، الذي يسمح لك بإلغاء طلب fetch بعد مدة محددة.

مثال: Fetch مع مهلة زمنية

async function fetchWithTimeout(url, options, timeoutMs) {
    timeoutMs = timeoutMs || 10000;

    var controller = new AbortController();
    var signal = controller.signal;

    var timeoutId = setTimeout(function() {
        controller.abort();
    }, timeoutMs);

    try {
        var fetchOptions = Object.assign({}, options || {}, {
            signal: signal,
        });

        var response = await fetch(url, fetchOptions);
        clearTimeout(timeoutId);

        if (!response.ok) {
            throw new Error('خطأ HTTP: ' + response.status);
        }

        return await response.json();

    } catch (error) {
        clearTimeout(timeoutId);

        if (error.name === 'AbortError') {
            throw new TimeoutError(
                'انتهت مهلة الطلب بعد ' + timeoutMs + 'ms',
                timeoutMs
            );
        }

        throw error;
    }
}

// الاستخدام -- مهلة 5 ثوانٍ
fetchWithTimeout(
    'https://jsonplaceholder.typicode.com/posts',
    {},
    5000
)
.then(function(data) {
    console.log('تم تحميل', data.length, 'منشورات');
})
.catch(function(error) {
    if (error instanceof TimeoutError) {
        console.error('استغرق الخادم وقتاً طويلاً للاستجابة.');
    } else {
        console.error('فشل الطلب:', error.message);
    }
});

التدهور الأنيق

التدهور الأنيق يعني أن تطبيقك يستمر في العمل حتى عندما تفشل بعض الميزات. بدلاً من عرض صفحة فارغة أو التعطل، تقدم بيانات احتياطية أو محتوى مخزن مؤقتاً أو وظائف مخفضة. هذا يحافظ على تجربة المستخدم مقبولة حتى في ظل الظروف السيئة.

مثال: استراتيجيات التدهور الأنيق

// الاستراتيجية 1: بيانات احتياطية
async function getUserProfile(userId) {
    var defaultProfile = {
        id: userId,
        name: 'مستخدم غير معروف',
        email: 'غير متاح',
        avatar: '/images/default-avatar.png',
    };

    try {
        var response = await fetchWithTimeout(
            '/api/users/' + userId, {}, 5000
        );
        return response;
    } catch (error) {
        console.warn('استخدام الملف الشخصي الافتراضي للمستخدم ' + userId);
        return defaultProfile;
    }
}

// الاستراتيجية 2: بيانات مخزنة مع مؤشر قدم
class CachedFetcher {
    constructor() {
        this.cache = new Map();
    }

    async fetch(url, maxAge) {
        maxAge = maxAge || 300000;
        var cached = this.cache.get(url);

        try {
            var response = await fetchWithTimeout(url, {}, 5000);
            this.cache.set(url, {
                data: response,
                timestamp: Date.now(),
            });
            return { data: response, isStale: false };
        } catch (error) {
            if (cached) {
                var age = Date.now() - cached.timestamp;
                console.warn(
                    'استخدام بيانات مخزنة (' +
                    Math.round(age / 1000) + ' ثانية) لـ: ' + url
                );
                return { data: cached.data, isStale: true };
            }
            throw error;
        }
    }
}

// الاستراتيجية 3: التحميل التدريجي مع إخفاقات جزئية
async function loadPageData() {
    var results = await Promise.allSettled([
        fetch('/api/hero-content').then(function(r) { return r.json(); }),
        fetch('/api/featured-posts').then(function(r) { return r.json(); }),
        fetch('/api/sidebar-ads').then(function(r) { return r.json(); }),
        fetch('/api/recommendations').then(function(r) { return r.json(); }),
    ]);

    return {
        hero: results[0].status === 'fulfilled'
            ? results[0].value
            : { error: true, message: 'المحتوى غير متاح' },
        posts: results[1].status === 'fulfilled'
            ? results[1].value
            : [],
        ads: results[2].status === 'fulfilled'
            ? results[2].value
            : null,
        recommendations: results[3].status === 'fulfilled'
            ? results[3].value
            : null,
    };
}
ملاحظة: رتب أولويات استدعاءات API حسب الأهمية. البيانات الحرجة (ملف المستخدم، المحتوى الرئيسي) يجب أن تحتوي على منطق إعادة محاولة قوي. البيانات الاختيارية (الإعلانات، التوصيات) يجب أن تفشل بصمت مع حالات احتياطية. هذا يضمن أن التجربة الأساسية تعمل دائماً، حتى عندما تتعطل بعض الخدمات.

رسائل خطأ سهلة الاستخدام

رسائل الخطأ التقنية تربك المستخدمين. حوّل الأخطاء الداخلية إلى رسائل واضحة وقابلة للتنفيذ تخبر المستخدمين بما حدث وما يمكنهم فعله حيال ذلك. لا تعرض أبداً تتبعات المكدس أو رموز الحالة أو تفاصيل الأخطاء الداخلية للمستخدمين النهائيين.

مثال: ربط الأخطاء برسائل سهلة الاستخدام

var errorMessages = {
    NetworkError: {
        title: 'مشكلة في الاتصال',
        message: 'يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.',
        action: 'retry',
    },
    TimeoutError: {
        title: 'استجابة بطيئة',
        message: 'الخادم يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى بعد لحظة.',
        action: 'retry',
    },
    AuthenticationError: {
        title: 'انتهت الجلسة',
        message: 'انتهت صلاحية جلستك. يرجى تسجيل الدخول مرة أخرى للمتابعة.',
        action: 'login',
    },
    ValidationError: {
        title: 'بيانات غير صالحة',
        message: 'يرجى التحقق من مدخلاتك وتصحيح الحقول المحددة.',
        action: 'fix',
    },
    RateLimitError: {
        title: 'طلبات كثيرة جداً',
        message: 'أنت ترسل طلبات بسرعة كبيرة. يرجى الانتظار لحظة والمحاولة مرة أخرى.',
        action: 'wait',
    },
    ServerError: {
        title: 'مشكلة في الخادم',
        message: 'حدث خطأ ما من جانبنا. تم إخطارنا ونعمل على إصلاحه.',
        action: 'retry',
    },
    default: {
        title: 'حدث خطأ ما',
        message: 'حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم.',
        action: 'retry',
    },
};

function getUserFriendlyError(error) {
    var errorInfo = errorMessages[error.name] || errorMessages.default;
    return errorInfo;
}

function displayError(error, containerId) {
    var info = getUserFriendlyError(error);
    var container = document.getElementById(containerId);

    var html = '<div class="error-card">';
    html += '  <h3>' + info.title + '</h3>';
    html += '  <p>' + info.message + '</p>';

    if (info.action === 'retry') {
        html += '  <button class="retry-btn">حاول مرة أخرى</button>';
    } else if (info.action === 'login') {
        html += '  <button class="login-btn">تسجيل الدخول</button>';
    }

    html += '</div>';
    container.innerHTML = html;

    console.error('[تفاصيل الخطأ]', {
        name: error.name,
        message: error.message,
        status: error.status,
        stack: error.stack,
    });
}

تسجيل الأخطاء ومراقبتها

في الإنتاج، تحتاج إلى معرفة متى تحدث الأخطاء حتى تتمكن من إصلاحها. خدمة تسجيل الأخطاء تجمع بيانات الأخطاء من جميع المستخدمين وتنبهك بالمشاكل. ابنِ مسجلاً مركزياً يلتقط معلومات السياق جنباً إلى جنب مع الخطأ نفسه.

مثال: مسجل الأخطاء

class ErrorLogger {
    constructor(options) {
        this.endpoint = options.endpoint || '/api/errors';
        this.appVersion = options.appVersion || '1.0.0';
        this.buffer = [];
        this.bufferSize = options.bufferSize || 10;
        this.flushInterval = options.flushInterval || 30000;

        var self = this;
        setInterval(function() {
            self.flush();
        }, this.flushInterval);
    }

    log(error, context) {
        var entry = {
            timestamp: new Date().toISOString(),
            appVersion: this.appVersion,
            url: window.location.href,
            userAgent: navigator.userAgent,
            error: {
                name: error.name || 'Error',
                message: error.message,
                stack: error.stack || 'لا يوجد تتبع مكدس',
                status: error.status || null,
            },
            context: context || {},
        };

        this.buffer.push(entry);
        console.error('[مسجل]', entry.error.name + ':', entry.error.message);

        if (this.buffer.length >= this.bufferSize) {
            this.flush();
        }
    }

    async flush() {
        if (this.buffer.length === 0) return;

        var entries = this.buffer.slice();
        this.buffer = [];

        try {
            await fetch(this.endpoint, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ errors: entries }),
            });
        } catch (sendError) {
            this.buffer = entries.concat(this.buffer);
            console.warn('فشل إرسال سجلات الأخطاء. سيتم إعادة المحاولة.');
        }
    }
}

مفهوم حدود الأخطاء

حد الأخطاء هو نمط حيث تلف أقسام تطبيقك في حاويات وقائية تلتقط الأخطاء وتمنعها من تعطيل الصفحة بالكامل. بينما يرتبط هذا المفهوم بشكل شائع بـ React، فإن المبدأ الأساسي ينطبق على أي تطبيق JavaScript. كل قسم من صفحتك يجب أن يكون معزولاً بحيث لا يؤدي خطأ في قسم واحد إلى تعطيل الأقسام الأخرى.

مثال: نمط حدود الأخطاء في JavaScript الصرف

class WidgetErrorBoundary {
    constructor(containerId, widgetName) {
        this.container = document.getElementById(containerId);
        this.widgetName = widgetName;
        this.hasError = false;
    }

    async render(renderFunction) {
        try {
            this.hasError = false;
            await renderFunction(this.container);
        } catch (error) {
            this.hasError = true;
            console.error(
                'خطأ في القطعة "' + this.widgetName + '":',
                error.message
            );
            this.renderFallback(error);
        }
    }

    renderFallback(error) {
        this.container.innerHTML =
            '<div class="widget-error">' +
            '  <p>لا يمكن تحميل هذا القسم.</p>' +
            '  <button class="widget-retry">إعادة المحاولة</button>' +
            '</div>';

        var self = this;
        this.container.querySelector('.widget-retry')
            .addEventListener('click', function() {
                self.render(self.lastRenderFunction);
            });

        this.lastRenderFunction = null;
    }

    wrap(renderFunction) {
        this.lastRenderFunction = renderFunction;
        return this.render(renderFunction);
    }
}

// الاستخدام: كل قسم من الصفحة معزول
async function initializeDashboard() {
    var profileBoundary = new WidgetErrorBoundary(
        'profile-section', 'ملف المستخدم'
    );
    var postsBoundary = new WidgetErrorBoundary(
        'posts-section', 'المنشورات الأخيرة'
    );
    var statsBoundary = new WidgetErrorBoundary(
        'stats-section', 'الإحصائيات'
    );

    // كل قسم يحمل بشكل مستقل
    await Promise.allSettled([
        profileBoundary.wrap(async function(container) {
            var response = await fetch('/api/profile');
            var profile = await response.json();
            container.innerHTML = '<h2>' + profile.name + '</h2>';
        }),

        postsBoundary.wrap(async function(container) {
            var response = await fetch('/api/posts');
            var posts = await response.json();
            var html = '';
            posts.forEach(function(post) {
                html += '<div>' + post.title + '</div>';
            });
            container.innerHTML = html;
        }),

        statsBoundary.wrap(async function(container) {
            var response = await fetch('/api/stats');
            var stats = await response.json();
            container.innerHTML = '<p>المشاهدات: ' + stats.views + '</p>';
        }),
    ]);
}

بناء غلاف API قوي

لنجمع جميع الأنماط من هذا الدرس في غلاف API جاهز للإنتاج. هذه الفئة تجمع الأخطاء المخصصة ومنطق إعادة المحاولة والمهلات الزمنية وقاطع الدائرة والتسجيل ومعالجة الأخطاء السهلة للمستخدم في وحدة واحدة قابلة لإعادة الاستخدام.

مثال: غلاف API جاهز للإنتاج

class RobustApiClient {
    constructor(baseUrl, options) {
        this.baseUrl = baseUrl;
        this.timeout = (options && options.timeout) || 10000;
        this.maxRetries = (options && options.maxRetries) || 3;
        this.baseDelay = (options && options.baseDelay) || 1000;
        this.breaker = new CircuitBreaker({
            failureThreshold: (options && options.breakerThreshold) || 5,
            resetTimeout: (options && options.breakerReset) || 30000,
        });
        this.defaultHeaders = {
            'Content-Type': 'application/json',
        };
    }

    setAuthToken(token) {
        this.defaultHeaders['Authorization'] = 'Bearer ' + token;
    }

    async request(method, endpoint, body, options) {
        var url = this.baseUrl + endpoint;
        var self = this;

        return this.breaker.execute(async function() {
            return await self.executeWithRetry(method, url, body, options);
        });
    }

    async executeWithRetry(method, url, body, options) {
        var lastError;
        var retries = (options && options.retries !== undefined)
            ? options.retries : this.maxRetries;

        for (var attempt = 0; attempt <= retries; attempt++) {
            try {
                return await this.executeSingleRequest(method, url, body, options);
            } catch (error) {
                lastError = error;
                if (!error.isRetryable) throw error;
                if (attempt < retries) {
                    var delay = this.calculateBackoff(attempt);
                    console.log(
                        'إعادة محاولة ' + (attempt + 1) + '/' + retries +
                        ' خلال ' + delay + 'ms...'
                    );
                    await this.sleep(delay);
                }
            }
        }
        throw lastError;
    }

    async executeSingleRequest(method, url, body, options) {
        var controller = new AbortController();
        var timeout = (options && options.timeout) || this.timeout;

        var timeoutId = setTimeout(function() {
            controller.abort();
        }, timeout);

        try {
            var fetchOptions = {
                method: method,
                headers: Object.assign({}, this.defaultHeaders),
                signal: controller.signal,
            };

            if (body && method !== 'GET') {
                fetchOptions.body = JSON.stringify(body);
            }

            var response;
            try {
                response = await fetch(url, fetchOptions);
            } catch (fetchError) {
                if (fetchError.name === 'AbortError') {
                    throw new TimeoutError('انتهت المهلة بعد ' + timeout + 'ms', timeout);
                }
                throw new NetworkError(fetchError.message);
            }

            return await this.processResponse(response);
        } finally {
            clearTimeout(timeoutId);
        }
    }

    async processResponse(response) {
        if (response.status === 204) return null;
        if (response.status === 401) throw new AuthenticationError('انتهت الجلسة');
        if (response.status === 422) {
            var body = await response.json();
            throw new ValidationError('بيانات غير صالحة', body.errors);
        }
        if (response.status === 429) {
            var retryHeader = response.headers.get('Retry-After');
            throw new RateLimitError(retryHeader ? parseInt(retryHeader) : 60);
        }
        if (response.status >= 500) {
            throw new ServerError('خطأ خادم: ' + response.status, response.status);
        }
        if (!response.ok) {
            var errorText = await response.text();
            var apiError = new ApiError(errorText || 'فشل الطلب', response.status, null);
            apiError.isRetryable = false;
            throw apiError;
        }
        return await response.json();
    }

    calculateBackoff(attempt) {
        var delay = this.baseDelay * Math.pow(2, attempt);
        var jitter = Math.random() * delay * 0.5;
        return Math.min(delay + jitter, 30000);
    }

    sleep(ms) {
        return new Promise(function(resolve) { setTimeout(resolve, ms); });
    }

    async get(endpoint, options) {
        return this.request('GET', endpoint, null, options);
    }

    async post(endpoint, body, options) {
        return this.request('POST', endpoint, body, options);
    }

    async put(endpoint, body, options) {
        return this.request('PUT', endpoint, body, options);
    }

    async patch(endpoint, body, options) {
        return this.request('PATCH', endpoint, body, options);
    }

    async delete(endpoint, options) {
        return this.request('DELETE', endpoint, null, options);
    }
}

// الاستخدام
var api = new RobustApiClient('https://jsonplaceholder.typicode.com', {
    timeout: 8000,
    maxRetries: 3,
    baseDelay: 1000,
    breakerThreshold: 5,
    breakerReset: 30000,
});

async function loadData() {
    try {
        var posts = await api.get('/posts');
        console.log('تم تحميل', posts.length, 'منشورات');

        var newPost = await api.post('/posts', {
            title: 'منشور جديد',
            body: 'المحتوى هنا.',
            userId: 1,
        });
        console.log('تم إنشاء منشور:', newPost.id);

    } catch (error) {
        var friendlyError = getUserFriendlyError(error);
        console.log(friendlyError.title + ':', friendlyError.message);
    }
}

loadData();
مهم: لا تبالغ في هندسة معالجة الأخطاء للمشاريع البسيطة. غلاف API القوي المعروض أعلاه مصمم لتطبيقات الإنتاج التي بها العديد من المستخدمين. للمشاريع الصغيرة أو النماذج الأولية، كتلة try/catch بسيطة مع رسالة خطأ سهلة الاستخدام كافية. أضف التعقيد فقط عندما يحتاج تطبيقك لذلك فعلاً.
نصيحة احترافية: اختبر معالجة الأخطاء بنفس دقة اختبار المسارات الناجحة. استخدم أدوات مطور المتصفح لمحاكاة وضع عدم الاتصال وتقييد سرعة الشبكة وحظر طلبات محددة. يمكنك أيضاً استخدام أدوات مثل https://httpstat.us/500 لاختبار رموز حالة HTTP محددة. كود معالجة الأخطاء الذي لا يُختبر أبداً هو كود معالجة أخطاء لا يعمل.

تمرين عملي

ابنِ تطبيق قارئ أخبار قوي يجلب المنشورات من واجهة JSONPlaceholder API ويوضح كل نمط معالجة أخطاء تمت تغطيته في هذا الدرس. يجب أن يتضمن تطبيقك ما يلي: أنشئ فئات أخطاء مخصصة لـ NetworkError و TimeoutError و ServerError، كل منها بخاصية isRetryable؛ نفذ دالة fetchWithTimeout باستخدام AbortController التي ترمي TimeoutError المخصص بعد 5 ثوانٍ؛ ابنِ دالة إعادة محاولة مع تراجع أسي تعيد المحاولة حتى 3 مرات بتأخيرات 1 ثانية و 2 ثانية و 4 ثوانٍ، لكن تعيد المحاولة فقط للأخطاء المحددة كقابلة لإعادة المحاولة؛ نفذ فئة CircuitBreaker التي تنفتح بعد 3 إخفاقات وتعيد التعيين بعد 15 ثانية؛ أنشئ CachedFetcher يعيد بيانات مخزنة قديمة عندما تفشل الطلبات الجديدة؛ ابنِ فئة ErrorLogger تخزن الأخطاء وتسجلها في وحدة التحكم مع طوابع زمنية؛ اغلف صفحتك في ثلاثة أقسام WidgetErrorBoundary مستقلة (رأس مع معلومات المستخدم، محتوى رئيسي مع منشورات، شريط جانبي مع إحصائيات) بحيث لا يؤثر فشل في قسم واحد على الأقسام الأخرى؛ اربط جميع أنواع الأخطاء برسائل سهلة الاستخدام مع أزرار إجراء مناسبة (إعادة المحاولة، تسجيل الدخول، أو الانتظار)؛ وأخيراً، اختبر تطبيقك باستخدام أدوات مطور المتصفح لمحاكاة عدم الاتصال وتقييد الشبكة إلى 3G البطيء وحظر نقاط نهاية API محددة للتحقق من أن كل نمط معالجة أخطاء يعمل بشكل صحيح.