Networking & REST API Integration

API Error Handling & Exception Management

16 min Lesson 7 of 13

API Error Handling & Exception Management

Robust networking code must anticipate failure. In the real world, HTTP requests fail for many different reasons: the server returns a 404 or 500, the device loses Wi-Fi mid-request, or the server simply takes too long to respond. Treating all of these as the same generic error produces unusable apps. This lesson teaches you to distinguish error categories, catch specific DioException types, and build a reusable error-handling layer that surfaces meaningful messages to the UI.

Two Distinct Failure Categories

Before writing any code, understand the fundamental split:

  • Network-level errors — the HTTP response never arrives. Examples: device is offline, DNS lookup fails, the server closes the TCP connection, or the request times out waiting for a response.
  • HTTP error status codes — the server did respond, but with a non-2xx status. Examples: 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable Entity, 500 Internal Server Error.

Dio models this split perfectly. A network-level failure throws a DioException with type DioExceptionType.connectionError, connectionTimeout, or receiveTimeout. An HTTP error throws a DioException with type DioExceptionType.badResponse and a non-null response property containing the status code and body.

Note: When validateStatus is left at its default, Dio automatically throws a DioException for any response whose status code falls outside 2xx. You never have to check response.statusCode manually after a successful await — if it returned, the status was valid.

DioException Types You Must Handle

  • DioExceptionType.connectionTimeout — took too long to establish the connection.
  • DioExceptionType.sendTimeout — took too long to upload the request body.
  • DioExceptionType.receiveTimeout — connected successfully but the server was too slow sending back data.
  • DioExceptionType.connectionError — network is unreachable, DNS failed, or the connection was reset.
  • DioExceptionType.badResponse — server replied with a non-2xx status; inspect e.response!.statusCode.
  • DioExceptionType.cancel — the request was cancelled programmatically via a CancelToken.
  • DioExceptionType.unknown — anything else (e.g. a JSON parse error thrown inside an interceptor).

Example 1 — Catching DioException Types Inline

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}');
    }
  }
}

Building a Reusable Error-Handling Layer

Scattering switch statements across every repository method is fragile and impossible to maintain. The better pattern is a central mapping function — often called ApiException or NetworkException — that converts a DioException into a clean, app-specific domain exception. Your repository then catches DioException once and rethrows a domain exception; the UI layer only ever sees domain exceptions.

Example 2 — Reusable Error Layer with Domain Exceptions

// 1. Define domain exception types
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. Central converter function
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. Repository wraps every call
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);  // rethrow as domain exception
    }
  }
}

Surfacing Errors in the UI

With domain exceptions in place, the UI layer simply catches ApiException and maps each subtype to a user-friendly message. A common pattern is to hold either data or an error message in your state class:

  • On NetworkException — show "Check your connection" with a retry button.
  • On TimeoutException — show "Server is slow, try again" with a retry button.
  • On UnauthorisedException — redirect to the login screen.
  • On NotFoundException — show a 404 illustration.
  • On ServerException — show a generic error with a support contact.
Tip: Use Dart sealed classes (Dart 3+) for your domain exceptions. The compiler will warn you if a switch or pattern-match is missing a subtype, making it impossible to silently swallow new error cases.
Warning: Never expose raw Dio error messages or stack traces to end users. Log them with a service like Sentry or Firebase Crashlytics, but show only human-readable strings in the UI. Raw error text can expose internal API paths and server details.

Summary

A professional error-handling strategy in Flutter follows these steps:

  • Configure realistic timeouts in BaseOptions so requests do not hang forever.
  • Catch DioException at the repository layer and examine e.type to distinguish network errors from HTTP errors.
  • For badResponse, inspect e.response!.statusCode to handle 401, 404, 5xx, etc. individually.
  • Convert DioException into domain exceptions immediately, keeping higher layers free of Dio imports.
  • Display meaningful, friendly messages in the UI based on the exception type — and always provide a way for the user to retry.