DEV Community

vmodal_ai
vmodal_ai

Posted on

Building a Production-Ready REST API Client with Dio

Building a Production-Ready REST API Client with Dio

A production API client needs more than dio.get().

A robust client should define:

  • Base configuration
  • Timeouts
  • Authentication
  • Interceptors
  • Error mapping
  • Serialization
  • Cancellation
  • Retry behavior
  • Logging
  • Testability

Dio provides features such as global configuration, interceptors, cancellation, uploads/downloads, timeouts, and custom adapters. citeturn0search7turn0search13

1. Add Dio

Add Dio to pubspec.yaml.

dependencies:
  dio: ^5.11.0
Enter fullscreen mode Exit fullscreen mode

Check the current package version before publishing or upgrading because package versions can change. citeturn0search9

2. Create a dedicated API client

import 'package:dio/dio.dart';

class ApiClient {
  final Dio dio;

  ApiClient({
    required String baseUrl,
  }) : dio = Dio(
          BaseOptions(
            baseUrl: baseUrl,
            connectTimeout: const Duration(seconds: 10),
            receiveTimeout: const Duration(seconds: 20),
            sendTimeout: const Duration(seconds: 20),
            headers: {
              'Accept': 'application/json',
            },
          ),
        );
}
Enter fullscreen mode Exit fullscreen mode

Keep the configuration centralized.

3. Add authentication with an interceptor

class AuthInterceptor extends Interceptor {
  final Future<String?> Function() getAccessToken;

  AuthInterceptor(this.getAccessToken);

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await getAccessToken();

    if (token != null && token.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $token';
    }

    handler.next(options);
  }
}
Enter fullscreen mode Exit fullscreen mode

Register it:

apiClient.dio.interceptors.add(
  AuthInterceptor(tokenStore.getAccessToken),
);
Enter fullscreen mode Exit fullscreen mode

4. Add structured error handling

Do not expose DioException throughout the entire application.

Create an application-level error:

sealed class ApiFailure {
  const ApiFailure();
}

final class NetworkFailure extends ApiFailure {
  const NetworkFailure();
}

final class UnauthorizedFailure extends ApiFailure {
  const UnauthorizedFailure();
}

final class ServerFailure extends ApiFailure {
  final int? statusCode;

  const ServerFailure(this.statusCode);
}

final class UnknownFailure extends ApiFailure {
  const UnknownFailure();
}
Enter fullscreen mode Exit fullscreen mode

Map Dio exceptions:

ApiFailure mapDioException(DioException error) {
  return switch (error.type) {
    DioExceptionType.connectionTimeout ||
    DioExceptionType.sendTimeout ||
    DioExceptionType.receiveTimeout ||
    DioExceptionType.connectionError =>
      const NetworkFailure(),

    DioExceptionType.badResponse =>
      switch (error.response?.statusCode) {
        401 => const UnauthorizedFailure(),
        final code => ServerFailure(code),
      },

    _ => const UnknownFailure(),
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the rest of the application can reason about application failures rather than transport-specific details.

5. Use typed models

Suppose the API returns:

{
  "id": 42,
  "name": "Flutter"
}
Enter fullscreen mode Exit fullscreen mode

Create a model:

class User {
  final int id;
  final String name;

  const User({
    required this.id,
    required this.name,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Then:

Future<User> getUser(int id) async {
  final response = await dio.get<Map<String, dynamic>>(
    '/users/$id',
  );

  return User.fromJson(response.data!);
}
Enter fullscreen mode Exit fullscreen mode

6. Add cancellation

Dio supports request cancellation through CancelToken. citeturn0search7

final cancelToken = CancelToken();

Future<void> search(String query) async {
  await dio.get(
    '/search',
    queryParameters: {'q': query},
    cancelToken: cancelToken,
  );
}
Enter fullscreen mode Exit fullscreen mode

Cancel when the operation is no longer relevant:

cancelToken.cancel('Search screen closed');
Enter fullscreen mode Exit fullscreen mode

This is especially useful for:

  • Search
  • Autocomplete
  • Large downloads
  • Long-running requests
  • Screen-scoped operations

7. Logging

Development logging is useful:

dio.interceptors.add(
  LogInterceptor(
    requestBody: true,
    responseBody: true,
  ),
);
Enter fullscreen mode Exit fullscreen mode

Be careful in production.

Never log:

  • Access tokens
  • Passwords
  • Refresh tokens
  • Sensitive personal information
  • Payment data

Use a sanitized logger if production request tracing is required.

8. Retry carefully

Retries should not blindly repeat every request.

For example:

GET /products
→ timeout
→ retry
Enter fullscreen mode Exit fullscreen mode

may be safe depending on the API.

But:

POST /payments
→ timeout
→ blindly retry
Enter fullscreen mode Exit fullscreen mode

could create duplicate operations.

Prefer server-supported idempotency keys for operations where duplicate execution would be dangerous.

9. Refresh expired access tokens

A typical flow is:

Request
   ↓
401
   ↓
Refresh token
   ↓
Update access token
   ↓
Retry original request
Enter fullscreen mode Exit fullscreen mode

Production implementations should also prevent multiple simultaneous requests from triggering multiple refresh operations.

A token-refresh coordinator or lock is useful.

10. Repository boundary

Do not let widgets directly call Dio.

Prefer:

Widget
  ↓
BLoC / Cubit
  ↓
Repository
  ↓
ApiClient
  ↓
Dio
Enter fullscreen mode Exit fullscreen mode

Example:

class UserRepository {
  final ApiClient api;

  UserRepository(this.api);

  Future<User> getUser(int id) {
    return api.getUser(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps networking replaceable and testable.

11. Testing

Mock the HTTP layer rather than requiring a real backend for unit tests.

Test:

  • Successful responses
  • Malformed JSON
  • 401 responses
  • 403 responses
  • 404 responses
  • 500 responses
  • Timeouts
  • Cancellation
  • Retry behavior
  • Token refresh
  • Empty responses

The goal is to prove your client behaves predictably under failure.

12. Suggested project structure

lib/
├── core/
│   └── network/
│       ├── api_client.dart
│       ├── api_failure.dart
│       ├── auth_interceptor.dart
│       └── network_module.dart
├── features/
│   └── users/
│       ├── data/
│       ├── domain/
│       └── presentation/
└── main.dart
Enter fullscreen mode Exit fullscreen mode

Production checklist

  • Centralize Dio configuration.
  • Set realistic connection, send, and receive timeouts.
  • Add authentication through an interceptor.
  • Map transport exceptions into application failures.
  • Use typed models.
  • Support cancellation where useful.
  • Sanitize logs.
  • Design retries around idempotency.
  • Serialize token refresh operations.
  • Keep repositories above the HTTP layer.
  • Test network failures, not only successful requests.

Conclusion

A production API client is an infrastructure component, not a collection of HTTP calls.

Dio gives Flutter applications a strong foundation with configuration, interceptors, cancellation, timeouts, and other networking capabilities. citeturn0search7

The production-quality part comes from the architecture around it: predictable errors, secure authentication, controlled retries, cancellation, testing, and clean boundaries.

Useful Links

Website: www.v-modal.com
SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutterter
SDK Android: https://github.com/v-modal/vmodal_sdk_androidoid
Discord: https://discord.gg/K72z28KUx

Top comments (0)