simple_network_handler 1.2.0 copy "simple_network_handler: ^1.2.0" to clipboard
simple_network_handler: ^1.2.0 copied to clipboard

A package for handling network errors with error registry and interceptors. Supports Dio and Supabase.

Simple Network Handler - Complete Usage Guide #

A Flutter package that simplifies network error handling with automatic HTTP response mapping to Either<Failure, Success>.

What if you could handle all http errors from one single place? This package does exactly that, allowing you to achieve something like this:

  ErrorModelRegistry get endpointRegistry => {
  '*': {
    422: (json) => Left(InvalidDataResponseFailure.fromJson(json)),
    202: (json) => Left(ParameterRequiredResponse.fromJson(json)),
    500: (json) => Left(ServerFailure.fromJson(json)),
    504: (json) => Left(TimeoutFailure.fromJson(json)),
  },
  AccountApiPath.sendVerificationCode: {
    403: (json) => Left(CodeSendForbiddenFailure.fromJson(json)),
  },
  AccountApiPath.resendVerificationCode: {
    403: (json) => Left(ResendTimeErrorFailure.fromJson(json)),
  },
  AuthApiPath.login: {
    404: (json) => Left(UserNotFoundFailure.fromJson(json)),
    400: (json) => Left(IncorrectPasswordFailure.fromJson(json)),
  },
};

🚀 Features #

  • Automatic Error Mapping: HTTP status codes → Custom failure types
  • Endpoint-Specific Handling: Different errors for different endpoints
  • Automatic Token Refresh: Queued 401 handling with single-flight refresh and request replay
  • Dio Integration: Built-in interceptors for seamless integration
  • Clean Architecture: Perfect for repository pattern and dependency injection

📦 Installation #

dependencies:
  simple_network_handler: ^1.2.0
  dio: ^5.8.0+1

  ############################
  # For advanced usage (see examples below)
  retrofit: ^4.4.2
  flutter_bloc: ^9.1.1
  injectable: ^2.5.0
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.15
  retrofit_generator: ^9.2.0
  json_serializable: ^6.9.5
  injectable_generator: ^2.7.0
  ##########################

🚀 Quick Start #

1. Create Custom Failure Classes #

class UserNotFoundFailure extends Failure {
  const UserNotFoundFailure();

  @override
  String getTitle(BuildContext context) => 'User not found';

  @override
  String getSubtitle(BuildContext context) => 'The user does not exist.';
}

2. Set Up Error Registry #

class MyErrorRegistry extends ErrorRegistry {
  @override
  ErrorModelRegistry get endpointRegistry =>
      {
        '*': {
          500: (json) => Left(ServerFailure.fromJson(json)),
        },
        '/api/users/{id}': {
          404: (json) => const Left(UserNotFoundFailure()),
        },
      };

  @override
  Failure get genericError => const NetworkFailure();

  @override
  DioErrorRegistry get dioRegistry =>
      {
        DioExceptionType.connectionError: const NoInternetFailure(),
        DioExceptionType.connectionTimeout: const TimeoutFailure(),
      };
}

3. Initialize in Main #

void main() {
  SimpleNetworkHandler.setErrorRegistry(MyErrorRegistry());
  runApp(MyApp());
}

4. Configure Dio with Interceptor #

main() {
  final dio = Dio();
  dio.interceptors.add(ErrorMappingInterceptor(errorRegistry: MyErrorRegistry()));
}

5. Make Safe Network Calls #

Future<Either<Failure, User>> getUser(int id) async {
  return SimpleNetworkHandler.safeCall(
        () => apiClient.getUserById(id),
  );
}

🔐 Automatic Token Refresh #

RefreshTokenInterceptor transparently refreshes an expired access token and replays the failed request. It is built on Dio's QueuedInterceptorsWrapper, so concurrent 401s are queued while a single refresh runs (single-flight) — queued requests are then replayed with the new token without triggering additional refreshes.

1. Implement a Token Store #

The package stays storage-agnostic — back this with secure storage, shared preferences, memory, etc.

class MyTokenStore implements TokenStore {
  final FlutterSecureStorage _storage;

  MyTokenStore(this._storage);

  @override
  Future<String?> readAccessToken() => _storage.read(key: 'access_token');

  @override
  Future<void> writeAccessToken(String token) =>
      _storage.write(key: 'access_token', value: token);
}

2. Wire the Interceptors (order matters!) #

Add RefreshTokenInterceptor before ErrorMappingInterceptor, so the refresh runs before error mapping turns the 401 into a Failure:

final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

dio.interceptors.addAll([
  // 1. Refresh first: recovers expired sessions before errors are mapped
  RefreshTokenInterceptor(
    tokenStore: MyTokenStore(storage),
    refreshToken: () async {
      // Perform the refresh on a separate bare Dio instance (no interceptors!)
      final response = await Dio().post(
        'https://api.example.com/auth/refresh',
        data: {'refresh_token': await readRefreshToken()},
      );
      return response.data['access_token'] as String?;
    },
    excludedPaths: ['/auth/login', '/auth/refresh'],
    onRefreshFailed: (error) {
      // Session can no longer be refreshed: force logout
    },
  ),
  // 2. Error mapping second: maps whatever is left (including unrecoverable 401s)
  ErrorMappingInterceptor(errorRegistry: MyErrorRegistry()),
]);

Alternatively, describe the refresh call declaratively with RefreshRequest — it is executed on a separate bare Dio instance automatically:

RefreshTokenInterceptor(
  tokenStore: MyTokenStore(storage),
  refreshRequest: RefreshRequest(
    path: 'https://api.example.com/auth/refresh',
    buildData: () async => {'refresh_token': await readRefreshToken()},
    extractAccessToken: (response) =>
        (response.data as Map<String, dynamic>)['access_token'] as String?,
  ),
)

Configuration #

Parameter Default Description
tokenStore required Reads/persists the access token (storage-agnostic)
refreshToken / refreshRequest exactly one required Callback returning the new token, or a declarative refresh request executed on a bare Dio instance
triggerStatusCodes [401] Response status codes that trigger a refresh
excludedPaths [] Paths that never trigger a refresh and never get the token header (refresh endpoint, public endpoints)
applyHeader Authorization: Bearer <token> How the token is applied to outgoing requests
onRefreshFailed Called when the refresh fails or returns null (force-logout hook), then the original error proceeds
httpClient Dio() Bare Dio instance (no interceptors) used for the refresh request and request replays

How It Works #

  1. Every request gets the current token from the TokenStore applied via applyHeader.
  2. On a 401 (or any configured status), a single refresh runs — concurrent 401s are queued.
  3. The new token is persisted, the original request is replayed with the new header, and the caller receives the replay response as if nothing happened.
  4. Queued requests detect the token already changed and are replayed without another refresh.
  5. If the refresh fails or returns null, onRefreshFailed fires and the original error is propagated — ErrorMappingInterceptor then maps it to a Failure as usual.

⚠️ Never run the refresh call through the Dio instance the interceptor is attached to: the queued interceptor would deadlock (dio issue #1612), and an interceptor-visible refresh call can cause infinite 401 loops. Use a bare Dio in your refreshToken callback, or use RefreshRequest, which does this for you.


🏗️ Advanced Usage - Production Example #

This section shows a complete production-ready implementation with clean architecture.

File Structure #

lib/example/
├── example_models.dart      # Data models with JSON serialization
├── example_failure.dart     # Custom failure classes
├── example_api.dart         # Retrofit API client
├── example_network_module.dart # Dio wiring: RefreshTokenInterceptor → ErrorMappingInterceptor
├── example_error_registry.dart # HTTP error → Failure mapping
├── example_repository.dart  # Business logic layer
└── example_cubit.dart       # State management

Key Components #

1. API Client with Retrofit

class ExampleApiPath {
  static const String getUserById = '/api/users/{id}';
}

@RestApi()
@singleton
abstract class ExampleApi {
  @GET(ExampleApiPath.getUserById)
  Future<UserResponse> getUserById(@Path('id') int id);
}

2. Error Registry with Mappings

class ExampleErrorRegistry extends ErrorRegistry {
  @override
  ErrorModelRegistry get endpointRegistry =>
      {
        '*': {
          500: (json) => Left(ServerFailure.fromJson(json)),
        },
        ExampleApiPath.getUserById: {
          404: (json) => const Left(UserNotFoundFailure()),
        },
      };
}

3. Repository with Safe Calls

@Singleton(as: ExampleRepository)
class ExampleRepositoryImpl implements ExampleRepository {
  @override
  Future<Either<Failure, UserResponse>> getUserById(int id) async {
    return SimpleNetworkHandler.safeCall(() => _apiClient.getUserById(id));
  }
}

4. Cubit State Management

Future<void> loadUser(int userId) async {
  emit(state.copyWith(status: ExampleStatus.loading));

  final result = await _repository.getUserById(userId);
  result.fold(
        (failure) => emit(state.copyWith(status: ExampleStatus.failure, error: failure)),
        (user) => emit(state.copyWith(status: ExampleStatus.success, user: user)),
  );
}

Custom Endpoint Logic #

requestExample() {
  SimpleNetworkHandler.safeCall(
        () => _apiClient.getUserById(id), onEndpointError: (error) {
    if (error.response?.statusCode == 403) {
      // Custom logic here
    }
    return null; // Let registry handle it
  },
  );
}

2
likes
160
points
240
downloads

Documentation

API reference

Publisher

verified publisherkeep-it-simple.dev

Weekly Downloads

A package for handling network errors with error registry and interceptors. Supports Dio and Supabase.

Repository (GitHub)
View/report issues

Topics

#dio #network #error-handling #supabase

License

MIT (license)

Dependencies

dartz, dio, flutter, supabase_flutter

More

Packages that depend on simple_network_handler