Here's a confession: my first production Flutter app had 47 try-catch blocks scattered across 23 files. Every API call looked like this:
try {
final response = await dio.get('/users');
if (response.statusCode == 200) {
if (response.data['status'] == 'success') {
// finally, the actual code
} else {
// handle API error
}
} else {
// handle HTTP error
}
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout) {
// handle timeout
} else if (e.type == DioExceptionType.connectionError) {
// handle no internet
}
// ... 15 more lines
} catch (e) {
// handle unknown error
}
Copy. Paste. Repeat. Every. Single. Endpoint.
Six months later, a junior dev joined. They asked why the same error handling code existed in 47 places. I didn't have a good answer.
This tutorial is what I wish someone had shown me on day one.
What We're Building
By the end of this 3-part series, your API calls will look like this:
final response = await userRepository.getProfile();
if (response.success) {
final user = response.data;
// done
} else {
showError(response.message);
// also done
}
No try-catch. No status code checking. No "what if the internet dies" handling at the call site.
All that complexity? It still exists. But it lives in one place, tested once, and never thought about again.
Here's what we'll build across the 3 parts:
| Part | What We Build | Problem It Solves |
|---|---|---|
| Part 1 (this one) | ApiConfig, ApiResponse, ErrorHandler | Configuration mess, inconsistent responses, cryptic errors |
| Part 2 | TokenManager, TokenRefreshLock, DIO Interceptors | Auth token chaos, race conditions, random logouts |
| Part 3 | BaseApiService, Repository pattern | Repetitive code, type-unsafe responses |
Let's start.
Dependencies
# pubspec.yaml
dependencies:
dio: ^5.9.0
flutter_riverpod: ^2.6.1
flutter_secure_storage: ^9.2.2
Not using Riverpod? That's fine. The concepts work with any state management. I'll show Riverpod providers, but you can adapt to GetIt, Provider, or even manual dependency injection.
Step 1: API Configuration
First problem — hardcoded values everywhere:
// Scattered across your codebase
await dio.get('https://api.example.com/api/v1/users');
await dio.post('https://api.example.com/api/v1/login');
// Oops, someone typed 'v2' by mistake
await dio.get('https://api.example.com/api/v2/products');
One typo. One wrong environment. Hours of debugging.
Here's the fix:
// lib/core/network/api_config.dart
class ApiConfig {
// Environment URLs — change once, affects everything
static const String _devBaseUrl = 'https://dev-api.example.com';
static const String _prodBaseUrl = 'https://api.example.com';
// Dart's compile-time environment variables
// Run with: flutter run --dart-define=APP_ENV=production
static const String _environment = String.fromEnvironment(
'APP_ENV',
defaultValue: 'development',
);
static bool get isProduction => _environment == 'production';
static String get baseUrl => isProduction ? _prodBaseUrl : _devBaseUrl;
static String get apiVersion => '/api/v1';
static String get fullBaseUrl => '$baseUrl$apiVersion';
// Timeouts — be generous on mobile networks
static const Duration connectTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sendTimeout = Duration(seconds: 30);
// Default headers for every request
static const Map<String, String> defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
"Why 30 seconds? That feels long."
Because your users aren't always on WiFi. They're on the subway. In elevators. At that one coffee shop with terrible internet.
I've watched users stare at loading spinners for 25 seconds on 3G — and the request eventually succeeded. A 10-second timeout would have shown them an error for something that was actually working.
30 seconds is the patience threshold. After that, even the most patient user has given up anyway.
"What's String.fromEnvironment doing?"
It reads values passed at compile time. You can build different versions:
# Development (default)
flutter run
# Production
flutter run --dart-define=APP_ENV=production
# Or in your CI/CD
flutter build apk --dart-define=APP_ENV=production
No more commenting/uncommenting URLs. No more "accidentally pushed dev config to production" disasters.
Step 2: Generic API Response
Second problem — every API returns data differently:
// Endpoint A
{"status": "success", "data": {...}}
// Endpoint B
{"success": true, "result": {...}}
// Endpoint C
{"data": {...}, "message": "OK"}
// Endpoint D (after the backend dev had a bad day)
{...} // just raw data, no wrapper
Your parsing code becomes a mess of if statements checking which format this particular endpoint uses.
Here's a response wrapper that handles all of them:
// lib/core/network/api_response.dart
class ApiResponse<T> {
final bool success;
final String message;
final T? data;
final Map<String, dynamic>? errors;
ApiResponse({
required this.success,
required this.message,
this.data,
this.errors,
});
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic)? fromJson, {
int? statusCode,
}) {
// HTTP 2xx = success, regardless of body content
final isHttpSuccess = statusCode != null &&
statusCode >= 200 &&
statusCode < 300;
// Also check body for explicit status
final isBodySuccess = json['status'] == 'success' ||
json['success'] == true;
return ApiResponse<T>(
success: isHttpSuccess || isBodySuccess,
message: json['message'] ?? 'Request completed',
data: fromJson != null ? fromJson(json['data'] ?? json) : json['data'],
errors: json['errors'],
);
}
factory ApiResponse.error(String message, {Map<String, dynamic>? errors}) {
return ApiResponse<T>(
success: false,
message: message,
errors: errors,
);
}
factory ApiResponse.success(T? data, {String? message}) {
return ApiResponse<T>(
success: true,
message: message ?? 'Success',
data: data,
);
}
}
"What's that T Function(dynamic)? fromJson parameter?"
That's how we get type-safe responses. Watch:
// Without type safety — you're guessing
final data = response.data; // dynamic, could be anything
// With ApiResponse<User>
final response = ApiResponse<User>.fromJson(
json,
(data) => User.fromJson(data), // Now response.data is User, not dynamic
);
Your IDE autocompletes. Your compiler catches mistakes. Life is better.
"Why check both HTTP status AND body status?"
Because APIs are inconsistent. Some return HTTP 200 with {"status": "error"} in the body. Some return HTTP 400 with useful data you still need.
This approach says: "I'll consider it successful if the HTTP status says so, OR if the body says so." It's defensive, and it's saved me from "works on my machine" bugs multiple times.
Step 3: API Response With Headers
Sometimes you need response headers — typically when tokens come back in headers instead of the body:
// lib/core/network/api_response.dart (add this to the same file)
class ApiResponseWithHeaders<T> {
final ApiResponse<T> response;
final Map<String, List<String>>? headers;
ApiResponseWithHeaders({required this.response, this.headers});
// Convenience getters — no need to dig through response.response.success
bool get success => response.success;
String get message => response.message;
T? get data => response.data;
// Helper for extracting tokens
String? getHeader(String name) {
return headers?[name.toLowerCase()]?.first;
}
}
Usage:
final result = await authService.login(email, password);
if (result.success) {
// Some APIs return token in header
final token = result.getHeader('authorization');
// Others return in body
final token = result.data?.accessToken;
}
Step 4: Error Handler
Third problem — your users see this:
DioException [connection timeout]: The connection errored:
Connection timed out after 30000ms
Or worse, the app crashes because you forgot to catch that one specific error type.
Here's an error handler that turns DIO's technical errors into messages humans understand:
// lib/core/network/api_error_handler.dart
import 'package:dio/dio.dart';
import 'api_response.dart';
class ApiErrorHandler {
static ApiResponse<T> handleError<T>(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ApiResponse.error(
'Connection timed out. Check your internet and try again.',
);
case DioExceptionType.connectionError:
return ApiResponse.error(
'No internet connection. Please check your network.',
);
case DioExceptionType.badResponse:
return _handleHttpError<T>(error);
case DioExceptionType.cancel:
return ApiResponse.error('Request was cancelled.');
default:
return ApiResponse.error(
'Something went wrong. Please try again.',
);
}
}
static ApiResponse<T> _handleHttpError<T>(DioException error) {
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
// Try to get the server's error message first
String serverMessage = _extractErrorMessage(responseData);
switch (statusCode) {
case 400:
return ApiResponse.error(
serverMessage.isNotEmpty
? serverMessage
: 'Invalid request. Please check your input.',
);
case 401:
// Don't say "unauthorized" — users don't know what that means
return ApiResponse.error('Please log in to continue.');
case 403:
return ApiResponse.error('You don\'t have permission to do this.');
case 404:
return ApiResponse.error('The requested item was not found.');
case 422:
// Validation errors — server message is usually helpful here
return ApiResponse.error(
serverMessage.isNotEmpty
? serverMessage
: 'Please check your input and try again.',
errors: responseData is Map<String, dynamic>
? responseData['errors']
: null,
);
case 429:
return ApiResponse.error(
'Too many requests. Please wait a moment and try again.',
);
case 500:
case 502:
case 503:
return ApiResponse.error(
'Our servers are having issues. Please try again later.',
);
default:
return ApiResponse.error(
serverMessage.isNotEmpty
? serverMessage
: 'An unexpected error occurred.',
);
}
}
static String _extractErrorMessage(dynamic responseData) {
if (responseData == null) return '';
if (responseData is Map<String, dynamic>) {
// Format: {"message": "Error message"}
if (responseData.containsKey('message')) {
final message = responseData['message'];
if (message is String) return message;
}
// Format: {"error": "Error message"}
if (responseData.containsKey('error')) {
final error = responseData['error'];
if (error is String) return error;
}
// Format: {"errors": {"email": ["Email is invalid"]}}
if (responseData.containsKey('errors')) {
final errors = responseData['errors'];
if (errors is Map) {
for (final value in errors.values) {
if (value is List && value.isNotEmpty) {
return value.first.toString();
}
if (value is String) return value;
}
}
}
}
if (responseData is String) {
return responseData;
}
return '';
}
}
"Why not show technical details?"
Because your users don't care that it was a 502 Bad Gateway. They care that it doesn't work and want to know if they should try again or give up.
| Technical Error | What User Sees |
|---|---|
| HTTP 500 Internal Server Error | "Our servers are having issues. Please try again later." |
| DioExceptionType.connectionTimeout | "Connection timed out. Check your internet and try again." |
| HTTP 422 with validation errors | The actual validation message from your server |
"What about logging the technical details?"
Good question. You should log them — just not show them to users:
static ApiResponse<T> handleError<T>(DioException error) {
// Log for debugging (in non-production)
if (!ApiConfig.isProduction) {
print('API Error: ${error.type}');
print('Status: ${error.response?.statusCode}');
print('Data: ${error.response?.data}');
}
// Return user-friendly message
switch (error.type) {
// ...
}
}
In production, send these to your crash reporting tool (Sentry, Crashlytics, etc.) instead of printing.
What We Have So Far
lib/
└── core/
└── network/
├── api_config.dart # Environment, URLs, timeouts
├── api_response.dart # Generic response wrapper
└── api_error_handler.dart # DIO errors → human messages
Three files. Clear responsibilities:
| File | One-Line Purpose |
|---|---|
api_config.dart |
"Where do I send requests and with what defaults?" |
api_response.dart |
"What shape does every API response take?" |
api_error_handler.dart |
"What do I tell users when things break?" |
Quick Test
Before moving on, let's verify this works. Create a simple test:
void main() {
// Test error handling
final timeoutError = DioException(
type: DioExceptionType.connectionTimeout,
requestOptions: RequestOptions(path: '/test'),
);
final response = ApiErrorHandler.handleError<String>(timeoutError);
print(response.success); // false
print(response.message); // "Connection timed out..."
// Test response parsing
final json = {
'status': 'success',
'message': 'User loaded',
'data': {'id': 1, 'name': 'John'}
};
final parsed = ApiResponse<Map<String, dynamic>>.fromJson(
json,
(data) => data as Map<String, dynamic>,
statusCode: 200,
);
print(parsed.success); // true
print(parsed.data); // {id: 1, name: John}
}
If that runs without errors, you're ready for Part 2.
What's Next
In Part 2, we tackle the hard stuff:
- Token storage that's actually secure (not SharedPreferences)
- Automatic token refresh that doesn't spam your server
- Race condition prevention when multiple requests fail simultaneously
That last one is where most tutorials stop. We won't.
Checklist Before Moving On
- [ ]
ApiConfigcompiles andfullBaseUrlreturns expected value - [ ]
ApiResponse.fromJsonparses your API's actual response format - [ ]
ApiErrorHandlerreturns friendly messages for timeout, no internet, and 500 errors - [ ] You've decided where to log technical errors (console for now, crash reporting later)
See you in Part 2.
This is Part 1 of a 3-part series:
- Part 1: Foundation (you're here) — ApiConfig, ApiResponse, ErrorHandler
- Part 2: Token Refresh — TokenManager, TokenRefreshLock, DIO Interceptors
- Part 3: Clean API Calls — BaseApiService, Repository pattern