API error handling is one of the most important parts of building a reliable Flutter application.
A basic API request may look like this:
try {
final response = await dio.get('/users');
} catch (e) {
print(e);
}
Technically, this catches an error.
However, it does not provide a good production architecture.
Your application still needs to understand:
- Was there no internet connection?
- Did the request time out?
- Did the server return
401 Unauthorized? - Did the access token expire?
- Was validation rejected with
422? - Did the server fail with
500? - Is the response malformed?
- What message should the user see?
- Should the request be retried?
- Should the user be logged out?
- What information should be logged for debugging?
A better Flutter application converts low-level networking failures into predictable application errors.
A clean flow looks like this:
API Request
↓
Dio / HTTP Client
↓
Network or Server Error
↓
Error Mapper
↓
Typed App Exception
↓
Repository
↓
Controller / State
↓
User-Friendly UI
Let’s build this architecture step by step.
Why Proper API Error Handling Matters
Imagine a login request fails because the user’s internet connection disappears.
Showing:
DioException [connection error]
to the user is useless.
The UI should display something meaningful:
No internet connection. Please check your network and try again.
Now imagine the server returns:
401 Unauthorized
The application may need to:
Refresh access token
↓
Retry request
or, when authentication can no longer be recovered:
Clear session
↓
Redirect to login
Different failures require different behavior.
Therefore, avoid treating every error as:
catch (e) {
showError('Something went wrong');
}
Types of API Errors in Flutter
Before designing the architecture, separate errors into useful categories.
Common API failures include:
| Error | Example |
|---|---|
| Connection | Internet unavailable |
| Timeout | Server takes too long |
| Authentication | 401 |
| Permission | 403 |
| Not Found | 404 |
| Validation | 400 / 422 |
| Rate Limit | 429 |
| Server | 500 / 502 / 503 |
| Parsing | Unexpected JSON structure |
| Cancellation | Request intentionally cancelled |
| Unknown | Unexpected application/network failure |
Your application should be able to distinguish between these cases.
Don’t Put All Error Handling in the UI
A common mistake is writing this inside a screen:
try {
final response = await Dio().get(
'/products',
);
// Parse response.
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
// ...
} else if (
e.response?.statusCode == 404) {
// ...
} else if (
e.type ==
DioExceptionType
.connectionTimeout) {
// ...
}
}
Then another screen repeats the same logic.
Eventually:
Login Screen
→ Dio error handling
Home Screen
→ Dio error handling
Profile Screen
→ Dio error handling
Orders Screen
→ Dio error handling
Cart Screen
→ Dio error handling
This creates duplicated and inconsistent code.
Instead, centralize networking concerns.
Recommended Flutter Architecture
A practical feature-first structure could look like:
lib/
├── core/
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── api_endpoints.dart
│ │ ├── api_exception.dart
│ │ ├── api_error_mapper.dart
│ │ └── interceptors/
│ │ ├── auth_interceptor.dart
│ │ └── logging_interceptor.dart
│ │
│ └── utils/
│ └── error_message.dart
│
├── features/
│ ├── auth/
│ │ ├── controller/
│ │ │ └── login_controller.dart
│ │ ├── model/
│ │ │ └── user_model.dart
│ │ ├── repository/
│ │ │ └── auth_repository.dart
│ │ ├── services/
│ │ │ └── auth_service.dart
│ │ └── screens/
│ │ └── login_screen.dart
│ │
│ └── products/
│ ├── controller/
│ ├── model/
│ ├── repository/
│ ├── services/
│ └── screens/
│
└── main.dart
The important separation is:
API Client
→ Network configuration
Service
→ Endpoint call
Repository
→ Application/data logic
Controller
→ UI state
Screen
→ Presentation
Step 1: Configure Dio Properly
Create one centralized Dio client instead of repeatedly writing:
Dio().get(...)
For example:
import 'package:dio/dio.dart';
class ApiClient {
ApiClient()
: dio = Dio(
BaseOptions(
baseUrl:
'https://api.example.com',
connectTimeout:
const Duration(
seconds: 15,
),
receiveTimeout:
const Duration(
seconds: 15,
),
sendTimeout:
const Duration(
seconds: 15,
),
headers: {
'Accept':
'application/json',
'Content-Type':
'application/json',
},
),
);
final Dio dio;
}
Now the entire application uses consistent:
Base URL
Headers
Timeouts
Interceptors
Authentication
Error handling
Step 2: Create Typed Exceptions
Do not pass raw DioException objects throughout your application.
Create application-level exceptions.
sealed class AppException
implements Exception {
const AppException(
this.message,
);
final String message;
@override
String toString() => message;
}
Now create specific exceptions.
class NoInternetException
extends AppException {
const NoInternetException()
: super(
'No internet connection.',
);
}
class RequestTimeoutException
extends AppException {
const RequestTimeoutException()
: super(
'The request timed out.',
);
}
class UnauthorizedException
extends AppException {
const UnauthorizedException()
: super(
'Your session has expired.',
);
}
class ForbiddenException
extends AppException {
const ForbiddenException()
: super(
'You do not have permission '
'to perform this action.',
);
}
class NotFoundException
extends AppException {
const NotFoundException()
: super(
'The requested resource '
'was not found.',
);
}
class ValidationException
extends AppException {
const ValidationException(
super.message, {
this.errors,
});
final Map<String, dynamic>? errors;
}
class ServerException
extends AppException {
const ServerException()
: super(
'Server error. '
'Please try again later.',
);
}
class ParsingException
extends AppException {
const ParsingException()
: super(
'Unable to process '
'the server response.',
);
}
class UnknownAppException
extends AppException {
const UnknownAppException()
: super(
'Something went wrong.',
);
}
Now your application can reason about errors instead of strings.
Why Typed Exceptions Are Better
Without typed exceptions:
if (error.toString().contains(
'401',
)) {
// ...
}
This is fragile.
With typed exceptions:
if (error
is UnauthorizedException) {
// Handle session expiration.
}
Much clearer.
Your architecture becomes:
DioException
↓
Error Mapper
↓
UnauthorizedException
instead of spreading Dio-specific logic everywhere.
Step 3: Create a Central Error Mapper
Create:
api_error_mapper.dart
The job of this class is to convert Dio errors into application exceptions.
import 'package:dio/dio.dart';
class ApiErrorMapper {
static AppException map(
DioException error,
) {
switch (error.type) {
case DioExceptionType
.connectionTimeout:
case DioExceptionType
.sendTimeout:
case DioExceptionType
.receiveTimeout:
return const
RequestTimeoutException();
case DioExceptionType
.connectionError:
return const
NoInternetException();
case DioExceptionType.cancel:
return const AppExceptionImpl(
'Request cancelled.',
);
case DioExceptionType
.badResponse:
return _handleResponse(
error.response,
);
case DioExceptionType
.badCertificate:
return const AppExceptionImpl(
'Secure connection failed.',
);
case DioExceptionType.unknown:
return const
UnknownAppException();
}
}
static AppException _handleResponse(
Response<dynamic>? response,
) {
final statusCode =
response?.statusCode;
final message =
_extractMessage(
response?.data,
);
switch (statusCode) {
case 400:
return ValidationException(
message ??
'Invalid request.',
);
case 401:
return const
UnauthorizedException();
case 403:
return const
ForbiddenException();
case 404:
return const
NotFoundException();
case 422:
return ValidationException(
message ??
'Please check the '
'provided information.',
errors:
_extractValidationErrors(
response?.data,
),
);
case 429:
return AppExceptionImpl(
message ??
'Too many requests. '
'Please try again later.',
);
case 500:
case 502:
case 503:
case 504:
return const
ServerException();
default:
return AppExceptionImpl(
message ??
'Unexpected server error.',
);
}
}
static String? _extractMessage(
dynamic data,
) {
if (data
is Map<String, dynamic>) {
final message = data['message'];
if (message is String &&
message.isNotEmpty) {
return message;
}
}
return null;
}
static Map<String, dynamic>?
_extractValidationErrors(
dynamic data,
) {
if (data
is Map<String, dynamic>) {
final errors = data['errors'];
if (errors
is Map<String, dynamic>) {
return errors;
}
}
return null;
}
}
class AppExceptionImpl
extends AppException {
const AppExceptionImpl(
super.message,
);
}
Now Dio-specific failure handling is centralized.
HTTP Status Codes You Should Handle
You do not need custom logic for every HTTP status code, but several codes commonly affect application behavior.
400 – Bad Request
Usually means:
Invalid request
Missing parameter
Incorrect request body
Example response:
{
"message": "Invalid request"
}
401 – Unauthorized
Usually indicates an authentication problem.
Possible reasons:
Missing token
Expired access token
Invalid token
Revoked session
The correct action depends on your authentication system.
Often:
401
↓
Try refresh token
↓
Refresh succeeds?
├── Yes → Retry request
└── No → Logout / Login required
403 – Forbidden
The server understands the request but refuses permission.
For example:
Normal User
↓
Delete Admin Account
↓
403 Forbidden
Do not automatically treat 403 as 401.
They mean different things.
404 – Not Found
Examples:
User not found
Product not found
Endpoint not found
Deleted resource
Your UI might show:
This product is no longer available.
rather than:
Something went wrong.
422 – Validation Error
Common for forms.
For example:
{
"message": "Validation failed",
"errors": {
"email": [
"Email is already registered"
],
"password": [
"Password must contain at least 8 characters"
]
}
}
This error should often be displayed next to individual form fields rather than only as a snackbar.
429 – Too Many Requests
The client is being rate limited.
The UI may display:
Too many attempts. Please try again later.
If the server supplies retry metadata such as Retry-After, your networking layer can use it when designing retry behavior.
500–599 – Server Errors
These usually represent server-side problems.
For example:
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
Do not expose internal backend error details directly to normal users.
A safer message is:
We're having trouble connecting to the server. Please try again.
Step 4: Create the Service Layer
Suppose we have a login API.
class AuthService {
AuthService(this._dio);
final Dio _dio;
Future<Map<String, dynamic>>
login({
required String email,
required String password,
}) async {
try {
final response =
await _dio.post(
'/auth/login',
data: {
'email': email,
'password': password,
},
);
return response.data
as Map<String, dynamic>;
} on DioException catch (error) {
throw ApiErrorMapper.map(
error,
);
}
}
}
Now the service does not leak raw DioException to higher layers.
Step 5: Parse Responses Safely
Network success does not guarantee valid application data.
Imagine the application expects:
{
"id": 1,
"name": "Ankit"
}
but the backend accidentally returns:
{
"user_id": 1
}
Your parsing may fail.
Therefore:
Future<User> getUser() async {
try {
final response =
await _dio.get(
'/profile',
);
return User.fromJson(
response.data
as Map<String, dynamic>,
);
} on DioException catch (error) {
throw ApiErrorMapper.map(
error,
);
} on FormatException {
throw const ParsingException();
} on TypeError {
throw const ParsingException();
}
}
Now you distinguish:
Network Error
from:
Invalid Server Response
Step 6: Use a Repository
The repository provides a clean API to the rest of your application.
class AuthRepository {
AuthRepository(
this._authService,
);
final AuthService _authService;
Future<User> login({
required String email,
required String password,
}) async {
final data =
await _authService.login(
email: email,
password: password,
);
try {
return User.fromJson(data);
} on FormatException {
throw const ParsingException();
} on TypeError {
throw const ParsingException();
}
}
}
The controller does not need to know:
Dio
HTTP headers
Base URL
Response object
Status mapping
It simply asks:
repository.login(...)
Step 7: Handle Errors in a GetX Controller
Suppose you use GetX.
class LoginController
extends GetxController {
LoginController(
this._repository,
);
final AuthRepository _repository;
final isLoading = false.obs;
final errorMessage = ''.obs;
Future<void> login({
required String email,
required String password,
}) async {
if (isLoading.value) return;
try {
isLoading.value = true;
errorMessage.value = '';
final user =
await _repository.login(
email: email,
password: password,
);
// Save user/session.
Get.offAllNamed('/home');
} on ValidationException catch (e) {
errorMessage.value =
e.message;
} on UnauthorizedException catch (e) {
errorMessage.value =
e.message;
} on NoInternetException catch (e) {
errorMessage.value =
e.message;
} on RequestTimeoutException catch (e) {
errorMessage.value =
e.message;
} on AppException catch (e) {
errorMessage.value =
e.message;
} catch (_) {
errorMessage.value =
'Something went wrong.';
} finally {
isLoading.value = false;
}
}
}
The controller deals with application-level errors.
It does not inspect DioExceptionType.
That belongs to the network layer.
Keep UI Error Handling Simple
The screen should not contain complicated networking logic.
For example:
Obx(
() {
if (controller
.errorMessage
.isEmpty) {
return const SizedBox();
}
return Padding(
padding:
const EdgeInsets.only(
top: 12,
),
child: Text(
controller
.errorMessage.value,
),
);
},
)
Your screen only understands:
Loading
Success
Error
It does not need to understand:
DioExceptionType.connectionError
That is good separation of concerns.
Better State Than Multiple Booleans
As an application grows, this:
bool loading;
bool hasError;
String error;
List<Product> products;
can become difficult to manage.
Instead, consider explicit states.
For example:
sealed class ProductState {
const ProductState();
}
class ProductInitial
extends ProductState {
const ProductInitial();
}
class ProductLoading
extends ProductState {
const ProductLoading();
}
class ProductSuccess
extends ProductState {
const ProductSuccess(
this.products,
);
final List<Product> products;
}
class ProductFailure
extends ProductState {
const ProductFailure(
this.exception,
);
final AppException exception;
}
Now impossible combinations such as:
loading = true
hasError = true
success = true
are avoided.
Error Messages: Developer vs User
Do not confuse debugging information with user-facing information.
A developer may need:
POST /auth/login
Status: 503
Duration: 12.4 seconds
Request ID: abc123
DioExceptionType.badResponse
The user needs:
The server is temporarily unavailable. Please try again.
Keep these two concerns separate.
Never Show Raw Exceptions to Users
Avoid:
Text(
error.toString(),
)
A raw error might contain:
DioException [bad response]
statusCode: 500
RequestOptions(...)
or implementation details that are confusing and potentially inappropriate to expose.
Instead:
Internal Error
↓
Error Mapper
↓
Safe User Message
Handling Validation Errors Properly
Validation deserves special handling.
Suppose the API returns:
{
"message": "Validation failed",
"errors": {
"email": [
"The email has already been taken."
],
"phone": [
"The phone number is invalid."
]
}
}
Your exception already supports:
class ValidationException
extends AppException {
const ValidationException(
super.message, {
this.errors,
});
final Map<String, dynamic>? errors;
}
The controller can extract fields:
final emailError = ''.obs;
final phoneError = ''.obs;
Then:
on ValidationException catch (e) {
final errors = e.errors;
emailError.value =
_firstError(
errors?['email'],
) ??
'';
phoneError.value =
_firstError(
errors?['phone'],
) ??
'';
}
Helper:
String? _firstError(
dynamic value,
) {
if (value is List &&
value.isNotEmpty) {
return value.first.toString();
}
if (value is String &&
value.isNotEmpty) {
return value;
}
return null;
}
Now the UI can display the error underneath the correct input.
That is much better UX than:
Validation failed.
Handle Timeouts Separately
Dio can distinguish different timeout scenarios:
DioExceptionType
.connectionTimeout
DioExceptionType
.sendTimeout
DioExceptionType
.receiveTimeout
They may all map to a user-friendly timeout exception:
return const
RequestTimeoutException();
However, internally you can still log which timeout occurred.
This helps diagnose whether problems happen during:
Connecting
Uploading request
Receiving response
No Internet vs Server Down
These are not necessarily the same thing.
No internet:
Device
X
Internet
Server unavailable:
Device
↓
Internet
↓
Server
X
Both may feel similar to users, but they can require different logging and retry strategies.
Do not rely only on a connectivity indicator to decide whether an API request will succeed.
The request itself remains the final source of truth.
Handling Expired Tokens
Authentication errors deserve centralized handling.
Suppose:
Access Token
↓
Expires
↓
API returns 401
A robust flow may be:
Request
↓
401
↓
Refresh token available?
↓
Request new access token
↓
Refresh succeeds?
├── Yes
│ ↓
│ Save token
│ ↓
│ Retry original request
│
└── No
↓
Clear session
↓
Login
This logic should generally not be repeated inside every controller.
An interceptor is a better location for centralized authentication behavior.
Dio Auth Interceptor Example
A simplified interceptor might start like this:
class AuthInterceptor
extends Interceptor {
AuthInterceptor(
this._tokenStorage,
);
final TokenStorage _tokenStorage;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token =
await _tokenStorage
.getAccessToken();
if (token != null) {
options.headers[
'Authorization'] =
'Bearer $token';
}
handler.next(options);
}
}
Now individual requests do not need:
headers: {
'Authorization':
'Bearer $token',
}
everywhere.
Be Careful with Automatic Token Refresh
A production token-refresh implementation needs to handle concurrency.
Imagine five requests execute simultaneously:
Request A → 401
Request B → 401
Request C → 401
Request D → 401
Request E → 401
You do not want:
5 simultaneous refresh-token requests
in most architectures.
Instead:
First 401
↓
Start one refresh operation
↓
Other failed requests wait
↓
New token received
↓
Retry waiting requests
Token refresh therefore requires more than simply adding another dio.post() inside onError.
Retry Only When It Makes Sense
Automatic retry can improve reliability, but careless retry logic can make things worse.
Reasonable retry candidates may include transient failures such as:
Connection interruption
Temporary server failure
503 Service Unavailable
Some timeouts
429 with server-directed delay
Usually do not blindly retry:
400
401 without refresh logic
403
404
422
These errors often require a change in request, authentication, permissions, or user input.
Retry and HTTP Method Safety
You should also consider what the request does.
Retrying:
GET /products
is usually much safer than blindly retrying:
POST /payment
A payment request may have reached the server even if the client did not receive the response.
Retrying could create duplicate operations unless the backend supports idempotency.
The same concern applies to:
Create order
Transfer money
Create booking
Submit purchase
Retry behavior should be designed together with backend semantics.
Exponential Backoff
Instead of retrying immediately:
Retry
Retry
Retry
Retry
a better transient-error strategy can be:
Failure
↓
Wait
↓
Retry
↓
Longer wait
↓
Retry
This is known as exponential backoff.
Conceptually:
Attempt 1
↓
1 second
Attempt 2
↓
2 seconds
Attempt 3
↓
4 seconds
Real implementations often also add jitter so many clients do not retry at exactly the same moment.
Avoid Infinite Retry Loops
This is dangerous:
Request fails
↓
Retry
↓
Fails
↓
Retry
↓
Fails
↓
Retry forever
Always define limits.
For example:
Maximum retries = 3
After that:
Return error
↓
Let user decide whether to retry
Manual Retry in the UI
For recoverable errors, a retry button can provide good UX.
Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Text(
'Unable to load products.',
),
const SizedBox(height: 12),
ElevatedButton(
onPressed:
controller.loadProducts,
child: const Text(
'Try Again',
),
),
],
)
This is often better than leaving the user on a permanent error screen.
Prevent Duplicate API Requests
Suppose the user taps:
Login
five times quickly.
Without protection:
POST /login
POST /login
POST /login
POST /login
POST /login
may be sent.
A simple controller guard:
if (isLoading.value) {
return;
}
followed by:
try {
isLoading.value = true;
await repository.login();
} finally {
isLoading.value = false;
}
can prevent many duplicate submissions.
Also disable the button while loading.
Request Cancellation
Some requests become irrelevant.
Search is a good example.
The user types:
f
fl
flu
flut
flutter
Older requests may no longer matter.
Dio supports cancellation using:
CancelToken
Example:
CancelToken? _cancelToken;
Future<void> search(
String query,
) async {
_cancelToken?.cancel(
'New search started',
);
_cancelToken =
CancelToken();
try {
await dio.get(
'/search',
queryParameters: {
'q': query,
},
cancelToken:
_cancelToken,
);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
return;
}
rethrow;
}
}
Now obsolete search requests can be cancelled.
Handle Stale Responses Too
Cancellation alone should not always be your only protection.
Imagine:
Search "flutter"
↓
Request A
Search "dart"
↓
Request B
B might finish first.
Then A might finish later and overwrite the newer result.
You can track the latest request:
int _requestId = 0;
Future<void> search(
String query,
) async {
final requestId =
++_requestId;
final result =
await repository.search(
query,
);
if (requestId != _requestId) {
return;
}
searchResults.value =
result;
}
This prevents stale responses from updating state.
Handle Empty Data Separately from Errors
These are different states:
API failed
and:
API succeeded but returned no products
Do not show:
Something went wrong
when the response was successful but empty.
Your UI should distinguish:
Loading
Success with data
Success with empty data
Error
For example:
Products loaded successfully
but list is empty
↓
"No products found."
Loading, Empty, Error and Success States
A robust API screen generally needs:
Initial
↓
Loading
↓
┌──────────────┐
│ │
▼ ▼
Success Error
│
├── Data
│
└── Empty
This produces a much cleaner user experience than using only:
bool isLoading;
Logging API Errors
During development, useful information can include:
HTTP method
Endpoint
Status code
Request duration
Error category
Backend request ID
Response message
Stack trace
However, avoid logging sensitive information such as:
Passwords
Access tokens
Refresh tokens
Authorization headers
OTP values
Payment details
Personal sensitive data
Logging should help debugging without creating a security problem.
Dio Logging Interceptor
During development, Dio interceptors can help inspect requests.
For example:
dio.interceptors.add(
LogInterceptor(
requestBody: true,
responseBody: true,
),
);
However, verbose network logging should be handled carefully in production.
Do not accidentally expose authentication or personal data through logs.
Global Dio Interceptor
You can also observe errors centrally.
class ApiInterceptor
extends Interceptor {
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
// Log safe diagnostic information.
handler.next(err);
}
}
Then:
dio.interceptors.add(
ApiInterceptor(),
);
Interceptors are useful for cross-cutting concerns such as:
Authentication
Logging
Token refresh
Request IDs
Common headers
Metrics
But they should not become one giant class containing all business logic.
Backend Error Response Design Matters
Flutter error handling becomes much easier when the backend uses a consistent response structure.
For example:
{
"success": false,
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"errors": {
"email": [
"Email is already registered"
]
}
}
Then Flutter can reliably parse:
message
code
errors
Instead of dealing with:
Endpoint A → "message"
Endpoint B → "error"
Endpoint C → "error_message"
Endpoint D → "detail"
Frontend and backend teams should agree on a consistent error contract.
Prefer Stable Error Codes Over Message Matching
Suppose the backend sends:
{
"code": "EMAIL_ALREADY_EXISTS",
"message": "This email is already registered."
}
Your Flutter app can use:
EMAIL_ALREADY_EXISTS
for application logic.
Do not write:
if (message ==
'This email is already registered.') {
// ...
}
Messages can change.
Stable machine-readable error codes are much safer.
Error Mapping by Backend Code
For example:
switch (errorCode) {
case 'EMAIL_ALREADY_EXISTS':
return const
ValidationException(
'This email is already registered.',
);
case 'ACCOUNT_BLOCKED':
return const
AppExceptionImpl(
'Your account is currently unavailable.',
);
default:
return const
UnknownAppException();
}
Now application behavior is not dependent on human-readable backend wording.
Do You Need a Result Class?
Exceptions are not the only architecture.
Some teams prefer returning explicit result types.
For example:
sealed class Result<T> {
const Result();
}
class Success<T>
extends Result<T> {
const Success(this.data);
final T data;
}
class Failure<T>
extends Result<T> {
const Failure(
this.exception,
);
final AppException exception;
}
Repository:
Future<Result<User>> login() async {
try {
final user =
await service.login();
return Success(user);
} on AppException catch (e) {
return Failure(e);
}
}
Controller:
final result =
await repository.login();
switch (result) {
case Success<User>(
data: final user,
):
// Handle success.
case Failure<User>(
exception: final error,
):
// Handle error.
}
This makes failure part of the method’s explicit return model.
Exceptions vs Result Types
Both approaches can work.
Exceptions
Service
↓
throw AppException
↓
Controller catches it
Simple and familiar.
Result Type
Repository
↓
Result<T>
├── Success
└── Failure
More explicit.
The important thing is consistency.
Avoid one repository returning:
null
another throwing:
Exception
and another returning:
false
for unrelated failure scenarios.
Avoid Returning null for Every Failure
Bad:
Future<User?> login() async {
try {
// ...
} catch (_) {
return null;
}
}
Now the caller sees:
null
but has no idea whether:
Password was wrong
Internet failed
Server failed
JSON parsing failed
Request timed out
That information has been destroyed.
Prefer structured errors.
Avoid Empty catch Blocks
Never silently swallow errors like:
try {
await api.getData();
} catch (_) {}
Now:
Request failed
↓
Error disappears
↓
UI may remain loading forever
At minimum, errors should be intentionally:
Handled
Mapped
Logged
Returned
or Rethrown
Always Reset Loading State
A common bug:
isLoading.value = true;
try {
await repository.load();
isLoading.value = false;
} catch (e) {
showError();
}
If the request fails:
isLoading = true
may remain forever.
Use:
try {
isLoading.value = true;
await repository.load();
} catch (e) {
// Handle error.
} finally {
isLoading.value = false;
}
The finally block runs whether the request succeeds or fails.
Don’t Catch Errors Too Early
Suppose the service does:
try {
// Request
} catch (_) {
throw Exception(
'Something went wrong',
);
}
You just converted every useful error into one generic exception.
Higher layers can no longer distinguish:
401
404
422
Timeout
No Internet
500
Map errors while preserving useful meaning.
A Better End-to-End Flow
Suppose a product request fails.
Product Screen
↓
Product Controller
↓
Product Repository
↓
Product Service
↓
Dio
↓
503 Service Unavailable
↓
ApiErrorMapper
↓
ServerException
↓
Repository
↓
Controller
↓
ProductFailure
↓
UI
↓
"Server is temporarily unavailable."
↓
Try Again button
Every layer has one clear responsibility.
Complete Product Example
Service:
class ProductService {
ProductService(this._dio);
final Dio _dio;
Future<List<dynamic>>
fetchProducts() async {
try {
final response =
await _dio.get(
'/products',
);
final data =
response.data;
if (data is! List) {
throw const
ParsingException();
}
return data;
} on DioException catch (e) {
throw ApiErrorMapper.map(e);
}
}
}
Repository:
class ProductRepository {
ProductRepository(
this._service,
);
final ProductService _service;
Future<List<Product>>
getProducts() async {
final data =
await _service
.fetchProducts();
try {
return data
.map(
(item) =>
Product.fromJson(
item as
Map<String, dynamic>,
),
)
.toList();
} on TypeError {
throw const
ParsingException();
} on FormatException {
throw const
ParsingException();
}
}
}
Controller:
class ProductController
extends GetxController {
ProductController(
this._repository,
);
final ProductRepository
_repository;
final products =
<Product>[].obs;
final isLoading = false.obs;
final errorMessage = ''.obs;
Future<void>
loadProducts() async {
if (isLoading.value) return;
try {
isLoading.value = true;
errorMessage.value = '';
final result =
await _repository
.getProducts();
products.assignAll(result);
} on AppException catch (e) {
errorMessage.value =
e.message;
} catch (_) {
errorMessage.value =
'Something went wrong.';
} finally {
isLoading.value = false;
}
}
}
Now your networking implementation remains separate from your UI.
Testing API Error Handling
Error handling should be tested just like success behavior.
At minimum, test scenarios such as:
200 → Valid response
200 → Invalid response structure
400 → Bad request
401 → Unauthorized
403 → Forbidden
404 → Not found
422 → Validation error
429 → Rate limit
500 → Server error
Timeout
Connection failure
Cancelled request
For example, your mapper test might verify:
expect(
ApiErrorMapper.map(error),
isA<UnauthorizedException>(),
);
This makes networking behavior predictable.
Common API Error Handling Mistakes
1. Using catch (e) Everywhere
This loses error specificity.
Prefer catching meaningful exception types where appropriate.
2. Showing Raw Errors
Never make users understand Dio internals.
Convert technical failures into actionable messages.
3. Returning null on Failure
null usually does not explain why the request failed.
Use structured failures.
4. Handling Status Codes in Every Screen
Centralize HTTP/network error mapping.
5. Retrying Every Failure
Validation and authorization errors generally do not become successful just because you immediately send the same request again.
6. Infinite Token Refresh
Make sure the refresh request itself does not trigger an endless refresh cycle.
7. Logging Tokens
Never expose access tokens, refresh tokens, passwords, OTPs, or sensitive request data through logs.
8. Forgetting finally
Loading indicators should reset after both success and failure.
9. Treating Empty Data as an Error
An empty successful response can be a valid application state.
10. Using Backend Messages as Business Logic
Prefer stable error codes.
Recommended Production Architecture
For a scalable Flutter application:
Flutter UI
│
▼
Controller/State
│
▼
Repository
│
▼
Service
│
▼
API Client
│
▼
Dio
│
┌───────┴────────┐
│ │
Success Failure
│ │
│ ▼
│ ApiErrorMapper
│ │
│ ▼
│ AppException
│ │
└───────┬────────┘
▼
Repository
│
▼
Controller
│
┌─────────┴─────────┐
▼ ▼
Success Error
│ │
▼ ▼
Data Friendly Message
This keeps networking details out of your widgets and makes error handling reusable across features.
API Error Handling Checklist
Before considering your Flutter networking layer production-ready, verify that you handle:
- Connection failures
- Connection timeout
- Send timeout
- Receive timeout
400bad requests401authentication failures403permission failures404missing resources422validation errors429rate limiting500+server errors- Invalid response structures
- Request cancellation
- Token expiration
- Token refresh failure
- Duplicate requests
- Stale search responses
- Loading state cleanup
- Empty states
- Retry behavior
- Sensitive logging
- User-friendly error messages
You may not need custom behavior for every case, but the architecture should make these cases predictable.
Frequently Asked Questions
What is the best way to handle API errors in Flutter?
Centralize low-level networking errors, convert them into application-specific exceptions or result types, and let your state layer decide how the UI should react.
Should I use try-catch for every API request?
Failures need to be handled, but you should not duplicate the same large try-catch and status-code logic throughout every screen.
Centralize common error mapping.
How should I handle DioException?
Map DioException into application-level errors such as:
NoInternetException
RequestTimeoutException
UnauthorizedException
ValidationException
ServerException
Then higher layers do not depend directly on Dio.
How should I handle 401 in Flutter?
If your authentication architecture supports refresh tokens, a 401 may trigger a centralized refresh-and-retry flow.
If refresh fails or is unavailable, clear the invalid session and require authentication again.
How should I handle 422?
Parse validation errors and display them near the relevant form fields when possible.
Should I automatically retry failed requests?
Only when the failure is likely transient and retrying the operation is safe.
Do not blindly retry every request.
How many times should an API request retry?
There is no universal number. A small bounded retry count with backoff is common for selected transient failures, but the exact policy depends on the API and operation.
Should the UI know about DioException?
Ideally, no.
The UI should deal with application state and user-facing errors rather than HTTP-client implementation details.
Should repositories throw exceptions or return Result?
Either approach can work.
Choose one consistent architecture based on your project’s needs.
Should I check internet connectivity before every request?
Usually, the request itself should remain the source of truth. A connectivity status can improve UX, but network availability does not guarantee that your server is reachable.
Final Thoughts
Proper API error handling in Flutter is not:
try {
await apiCall();
} catch (e) {
print(e);
}
A production application needs a clear error pipeline:
Request
↓
HTTP Client
↓
Network / HTTP Failure
↓
Central Error Mapper
↓
Typed Application Error
↓
Repository
↓
Controller / State
↓
User-Friendly UI
Keep the responsibilities separate:
Dio
→ Performs network requests
Interceptor
→ Handles cross-cutting networking concerns
Error Mapper
→ Converts technical failures
Service
→ Calls endpoints
Repository
→ Provides application-friendly data operations
Controller
→ Manages loading, success and failure state
UI
→ Shows meaningful feedback
Then handle special cases intentionally:
401
→ Authentication recovery
422
→ Field validation
429
→ Rate limiting
500+
→ Server failure
Timeout
→ Retry when appropriate
No connection
→ Network feedback
Malformed response
→ Parsing failure
The goal is not to eliminate API failures. Network requests will always fail sometimes.
The goal is to make those failures predictable, recoverable, debuggable, and understandable to the user.
That is what turns basic Flutter networking code into a production-ready API architecture.




