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. citeturn0search7turn0search13
1. Add Dio
Add Dio to pubspec.yaml.
dependencies:
dio: ^5.11.0
Check the current package version before publishing or upgrading because package versions can change. citeturn0search9
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',
},
),
);
}
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);
}
}
Register it:
apiClient.dio.interceptors.add(
AuthInterceptor(tokenStore.getAccessToken),
);
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();
}
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(),
};
}
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"
}
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,
);
}
}
Then:
Future<User> getUser(int id) async {
final response = await dio.get<Map<String, dynamic>>(
'/users/$id',
);
return User.fromJson(response.data!);
}
6. Add cancellation
Dio supports request cancellation through CancelToken. citeturn0search7
final cancelToken = CancelToken();
Future<void> search(String query) async {
await dio.get(
'/search',
queryParameters: {'q': query},
cancelToken: cancelToken,
);
}
Cancel when the operation is no longer relevant:
cancelToken.cancel('Search screen closed');
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,
),
);
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
may be safe depending on the API.
But:
POST /payments
→ timeout
→ blindly retry
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
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
Example:
class UserRepository {
final ApiClient api;
UserRepository(this.api);
Future<User> getUser(int id) {
return api.getUser(id);
}
}
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
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. citeturn0search7
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)