الشبكات وتكامل REST API

معالجة أخطاء API وإدارة الاستثناءات

16 دقيقة الدرس 7 من 13

معالجة أخطاء API وإدارة الاستثناءات

كود الشبكات القوي يجب أن يتوقع الفشل. في العالم الحقيقي، تفشل طلبات HTTP لأسباب مختلفة كثيرة: يُعيد الخادم 404 أو 500، أو يفقد الجهاز اتصال Wi-Fi أثناء الطلب، أو يستغرق الخادم وقتاً طويلاً جداً في الاستجابة. معاملة هذه الحالات جميعها كخطأ عام واحد يُنتج تطبيقات غير قابلة للاستخدام. يعلّمك هذا الدرس التمييز بين فئات الأخطاء، وصيد أنواع DioException المحددة، وبناء طبقة معالجة أخطاء قابلة لإعادة الاستخدام تعرض رسائل ذات معنى لواجهة المستخدم.

فئتان متمايزتان من الأعطال

قبل كتابة أي كود، افهم الانقسام الجوهري:

  • أخطاء مستوى الشبكة — لا يصل استجابة HTTP أبداً. أمثلة: الجهاز غير متصل بالإنترنت، فشل البحث في DNS، أو يغلق الخادم اتصال TCP، أو انتهاء مهلة الطلب في انتظار الاستجابة.
  • رموز حالة HTTP للأخطاء — استجاب الخادم، لكن برمز حالة غير 2xx. أمثلة: 401 Unauthorized، 403 Forbidden، 404 Not Found، 422 Unprocessable Entity، 500 Internal Server Error.

يُنمذج Dio هذا الانقسام بشكل مثالي. الأعطال على مستوى الشبكة ترمي DioException من نوع DioExceptionType.connectionError أو connectionTimeout أو receiveTimeout. أخطاء HTTP ترمي DioException من نوع DioExceptionType.badResponse مع خاصية response غير فارغة تحتوي على رمز الحالة والجسم.

ملاحظة: عندما يُترك validateStatus على افتراضيه، يرمي Dio تلقائياً DioException لأي استجابة يقع رمز حالتها خارج 2xx. لا تحتاج أبداً للتحقق من response.statusCode يدوياً بعد await ناجح — إن أعاد قيمة، فالحالة كانت صحيحة.

أنواع DioException التي يجب معالجتها

  • DioExceptionType.connectionTimeout — استغرق وقتاً طويلاً لـإنشاء الاتصال.
  • DioExceptionType.sendTimeout — استغرق وقتاً طويلاً لـرفع جسم الطلب.
  • DioExceptionType.receiveTimeout — اتصل بنجاح لكن الخادم كان بطيئاً جداً في إرسال البيانات.
  • DioExceptionType.connectionError — الشبكة غير متاحة، أو فشل DNS، أو أُعيد ضبط الاتصال.
  • DioExceptionType.badResponse — ردّ الخادم برمز حالة غير 2xx؛ افحص e.response!.statusCode.
  • DioExceptionType.cancel — أُلغي الطلب برمجياً عبر CancelToken.
  • DioExceptionType.unknown — أي شيء آخر (مثل خطأ تحليل JSON داخل معترض).

مثال 1 — صيد أنواع DioException مباشرةً

import 'package:dio/dio.dart';

Future<void> fetchUser(int id) async {
  final dio = Dio(BaseOptions(
    baseUrl: 'https://api.example.com',
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 15),
  ));

  try {
    final response = await dio.get('/users/$id');
    final user = response.data as Map<String, dynamic>;
    print('Got user: ${user['name']}');
  } on DioException catch (e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        print('Request timed out. Check your connection.');
        break;
      case DioExceptionType.connectionError:
        print('No internet connection.');
        break;
      case DioExceptionType.badResponse:
        final status = e.response?.statusCode;
        if (status == 401) {
          print('Unauthorised — please log in again.');
        } else if (status == 404) {
          print('User not found.');
        } else if (status != null && status >= 500) {
          print('Server error ($status). Try again later.');
        }
        break;
      case DioExceptionType.cancel:
        print('Request was cancelled.');
        break;
      default:
        print('Unexpected error: ${e.message}');
    }
  }
}

بناء طبقة معالجة أخطاء قابلة لإعادة الاستخدام

تفريق جمل switch عبر كل طريقة في المستودع هشّ ومستحيل الصيانة. النمط الأفضل هو دالة تحويل مركزية — تُسمى غالباً ApiException أو NetworkException — تحوّل DioException إلى استثناء نطاق تطبيق نظيف. يصيد المستودع بعدها DioException مرة واحدة ويُعيد رمي استثناء نطاق؛ طبقة واجهة المستخدم لا ترى سوى استثناءات النطاق.

مثال 2 — طبقة أخطاء قابلة لإعادة الاستخدام مع استثناءات النطاق

// 1. تعريف أنواع استثناءات النطاق
sealed class ApiException implements Exception {
  const ApiException(this.message);
  final String message;
}

class NetworkException extends ApiException {
  const NetworkException() : super('No internet connection.');
}

class TimeoutException extends ApiException {
  const TimeoutException() : super('The request timed out.');
}

class UnauthorisedException extends ApiException {
  const UnauthorisedException() : super('Session expired. Please log in.');
}

class NotFoundException extends ApiException {
  const NotFoundException(String resource)
      : super('$resource was not found.');
}

class ServerException extends ApiException {
  const ServerException(int code) : super('Server error ($code).');
}

// 2. دالة التحويل المركزية
ApiException dioToApiException(DioException e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.sendTimeout:
    case DioExceptionType.receiveTimeout:
      return const TimeoutException();
    case DioExceptionType.connectionError:
      return const NetworkException();
    case DioExceptionType.badResponse:
      final status = e.response?.statusCode ?? 0;
      if (status == 401) return const UnauthorisedException();
      if (status == 404) return const NotFoundException('Resource');
      return ServerException(status);
    default:
      return ApiException('Unknown error: ${e.message}');
  }
}

// 3. المستودع يلفّ كل طلب
class UserRepository {
  final Dio _dio;
  UserRepository(this._dio);

  Future<Map<String, dynamic>> getUser(int id) async {
    try {
      final res = await _dio.get('/users/$id');
      return res.data as Map<String, dynamic>;
    } on DioException catch (e) {
      throw dioToApiException(e);  // إعادة الرمي كاستثناء نطاق
    }
  }
}

عرض الأخطاء في واجهة المستخدم

مع استثناءات النطاق في مكانها، تصيد طبقة واجهة المستخدم ببساطة ApiException وتُحوّل كل نوع فرعي إلى رسالة مألوفة للمستخدم. نمط شائع هو الاحتفاظ إما بـبيانات أو رسالة خطأ في فئة الحالة:

  • عند NetworkException — اعرض "تحقق من اتصالك" مع زر إعادة المحاولة.
  • عند TimeoutException — اعرض "الخادم بطيء، حاول مجدداً" مع زر إعادة المحاولة.
  • عند UnauthorisedException — انتقل إلى شاشة تسجيل الدخول.
  • عند NotFoundException — اعرض رسم توضيحي لخطأ 404.
  • عند ServerException — اعرض خطأ عام مع معلومات التواصل للدعم.
نصيحة: استخدم كلاسات Dart sealed (Dart 3+) لاستثناءات النطاق. سيحذّرك المُترجم إذا كان switch أو مطابقة النمط تفتقد نوعاً فرعياً، مما يجعل من المستحيل ابتلاع حالات خطأ جديدة بصمت.
تحذير: لا تكشف أبداً رسائل خطأ Dio الخام أو آثار المكدس للمستخدمين النهائيين. سجّلها مع خدمة مثل Sentry أو Firebase Crashlytics، لكن اعرض فقط سلاسل مقروءة للإنسان في واجهة المستخدم. نص الخطأ الخام يمكنه كشف مسارات API الداخلية وتفاصيل الخادم.

الخلاصة

استراتيجية معالجة الأخطاء الاحترافية في Flutter تتبع هذه الخطوات:

  • اضبط مهلاً واقعية في BaseOptions حتى لا تتعلق الطلبات إلى الأبد.
  • اصطد DioException في طبقة المستودع وافحص e.type للتمييز بين أخطاء الشبكة وأخطاء HTTP.
  • لـbadResponse، افحص e.response!.statusCode للتعامل مع 401 و404 و5xx وما إلى ذلك بشكل فردي.
  • حوّل DioException إلى استثناءات نطاق فوراً، محافظاً على الطبقات الأعلى خالية من استيرادات Dio.
  • اعرض رسائل ودية ذات معنى في واجهة المستخدم بناءً على نوع الاستثناء — وقدّم دائماً طريقة للمستخدم لإعادة المحاولة.