Calling an API directly from a BLoC is easy:
final response = await Dio().get('/courses');
It also creates a new set of decisions in every feature: base URLs, timeouts, authentication, logging, error messages, retries, cancellation, and JSON parsing.
I use Dio as infrastructure behind one HTTP boundary. Features should request data, not configure a network client.
Configure one client#
Create the Dio instance during application setup:
Dio buildDio(AppEnvironment environment) {
return Dio(
BaseOptions(
baseUrl: environment.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
headers: const {
'accept': 'application/json',
},
),
);
}
Do not construct Dio inside every service. A shared instance provides consistent policy and connection reuse.
Attach authentication in an interceptor#
Feature services should not load tokens:
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._tokens);
final TokenProvider _tokens;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokens.currentAccessToken();
options.headers['authorization'] = 'Bearer ' + token;
handler.next(options);
}
}
Public endpoints can use metadata to skip authentication rather than creating token logic in the service.
Token refresh needs coordination. If five requests receive 401 together, the app should normally perform one refresh and replay eligible requests after it completes. Use a queued interceptor or a shared refresh future, and never retry endlessly.
Translate transport errors at the boundary#
A widget should not switch on DioExceptionType:
sealed class NetworkFailure {
const NetworkFailure();
}
final class NoConnection extends NetworkFailure {}
final class RequestTimedOut extends NetworkFailure {}
final class Unauthorized extends NetworkFailure {}
final class ServerUnavailable extends NetworkFailure {}
final class UnexpectedResponse extends NetworkFailure {}
Map Dio failures once:
NetworkFailure mapDioFailure(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return RequestTimedOut();
}
return switch (error.response?.statusCode) {
401 => Unauthorized(),
>= 500 => ServerUnavailable(),
_ => UnexpectedResponse(),
};
}
Preserve the original error and stack trace for logging. The domain failure is for application decisions, not for erasing diagnostic information.
Keep services small#
A service owns endpoints and transport models:
class CoursesService {
CoursesService(this._dio);
final Dio _dio;
Future<List<CourseModel>> getCourses() async {
final response = await _dio.get<Map<String, dynamic>>('/courses');
final items = response.data!['items'] as List<dynamic>;
return items
.map((item) => CourseModel.fromJson(
item as Map<String, dynamic>,
))
.toList();
}
}
The repository catches known infrastructure errors, maps models to entities, and returns typed outcomes. The BLoC never imports Dio.
Retry only safe operations#
Automatic retries are useful for temporary timeouts and some server failures. They are dangerous for payment, registration, and creation requests.
Before retrying, ask:
- Is the operation idempotent?
- Does the server support an idempotency key?
- Is the failure temporary?
- How many attempts are acceptable?
- Should the user control the retry?
Use bounded backoff with jitter. Never retry authentication failures before fixing authentication, and do not retry validation failures.
Cancel obsolete requests#
Search and changing filters can make an earlier request irrelevant:
CancelToken? activeSearch;
Future<Response<dynamic>> search(String query) {
activeSearch?.cancel('New query');
activeSearch = CancelToken();
return dio.get(
'/search',
queryParameters: {'q': query},
cancelToken: activeSearch,
);
}
Cancellation saves bandwidth when supported. State management should still prevent an old result from overwriting a new one.
Log carefully#
Network logs help development, but authorization headers, cookies, personal data, and request bodies can contain secrets. Redact them before logs reach a device console or remote service.
Production monitoring should record endpoint templates, status, duration, retry count, and a request identifier. It should not record access tokens.
Test interceptors, concurrent refresh, error mapping, cancellation, and retry limits. A reliable HTTP layer is less about making requests and more about making every feature follow the same safe rules.