http_network_client 1.0.0
http_network_client: ^1.0.0 copied to clipboard
A Flutter network package built on Dio with intelligent AES-256 encrypted caching, automatic retry, SSL pinning, file uploads, and type-safe Either error handling.
http_network_client #
A Flutter HTTP client package built on Dio that bundles everything a production app needs in a single, zero-codegen package:
- Encrypted cache — AES-256-CBC with per-write random IV, SHA-256 integrity check, versioned Hive storage
- Offline support — automatic fallback to cached data on network/5xx errors
- Automatic retry — configurable attempts with clean counter management
- SSL pinning — PEM string or raw bytes, production-safe (no bypass in non-dev mode)
- File uploads — single and multiple multipart helpers
- Type-safe errors —
Either<Response, FailureState>via dartz, no exception catching in business logic - Request cancellation — per-URL cancel token lifecycle
- Global auth callback — register once at startup, override per-request if needed
- Zero codegen — no
build_runner, no annotations, works out of the box
Why http_network_client? #
Most Flutter teams assemble a network layer from multiple packages. This package ships the full stack as one cohesive unit so you don't have to wire them together.
| Need | Typical approach | This package |
|---|---|---|
| HTTP requests | dio |
Built-in |
| Response caching | dio_cache_interceptor (no encryption) |
Built-in, AES-256 encrypted |
| Offline fallback | Manual interceptor | Built-in |
| Retry on error | dio_smart_retry |
Built-in |
| SSL pinning | Manual HttpClient config |
Built-in, production-safe |
| Type-safe error handling | Try/catch everywhere | Either<Response, FailureState> |
| File upload | Manual FormData |
Helper methods |
| Auth token expiry | Manual interceptor | Global + per-request callback |
Unlike chopper or retrofit, there is no code generation step. Unlike dio alone, you get caching, encryption, pinning, and error handling included.
Table of Contents #
- Architecture
- Installation
- Setup
- Making Requests
- Handling Responses
- Caching
- Cache Warming
- Cache Lifecycle
- Cache Encryption
- Retry Logic
- File Uploads
- SSL Pinning
- Request Cancellation
- Dynamic Headers After Login
- API Reference
- Error Status Codes
Architecture #
┌───────────────────────────────────────────────────────┐
│ Your Application │
│ NetworkService.apiRequest.getResponse() │
└───────────────────┬───────────────────────────────────┘
│
┌───────────▼───────────┐
│ ApiRequestImpl │ ← handles method routing,
│ (ApiRequest impl) │ retry logic, cache pre-check,
└───────────┬───────────┘ optional auth callback
│ Dio call
┌───────────▼───────────┐
│ ApiInterceptor │ ← Dio interceptor layer
│ (Dio Interceptor) │ onRequest: serve fresh cache
└───────┬───────┬───────┘ onResponse: save to cache
│ │ onError: fallback to cache
Network │ │ Cache hit
request │ │
┌────────────▼─┐ ┌─▼──────────────────┐
│ Dio (HTTP) │ │ NetworkCacheService │ ← Hive-backed store
│ + SSL/TLS │ │ (Hive + SHA-256 │ SHA-256 keyed entries
└─────────┬────┘ │ key hashing) │ AES-256-CBC encrypted
│ └─────────┬───────────┘
┌─────────▼────┐ │
│ Remote API │ ┌─────────▼──────────────┐
└──────────────┘ │ CacheEncryptDecryptSvc │ ← AES-256-CBC
│ (per-call random IV, │ random IV per write
│ SHA-256 integrity) │ SHA-256 tamper check
└────────────────────────┘
Package structure #
lib/
http_network_client.dart <- public barrel (import this)
export.dart <- backward-compatible alias
src/
http_network_client.dart <- NetworkService entry point
api_request_impl.dart
api_manager_impl.dart
api_interceptors.dart
cache_config.dart
cache_data_model.dart
failure_state.dart
retry_config.dart
interface/
api_request.dart
api_manager.dart
service/
network_cache_service.dart
cache_encrypt_decrypt_service.dart
constant/
enums.dart
utils/
network_utils.dart
Component responsibilities #
| Component | Role |
|---|---|
NetworkService |
Singleton entry point. Initialises Dio, versioned Hive cache (ns_cache_v{version}), and the global auth callback. |
ApiRequestImpl |
Routes HTTP methods, two-phase cache pre-check, retry counter with guaranteed cleanup, optional-callback resolution. |
ApiInterceptor |
Dio-level interceptor. Serves fresh cache on onRequest, persists responses on onResponse, falls back to cache on network/5xx errors. |
ApiManagerImpl |
Wraps Dio with base options, SSL/TLS pinning, and IOHttpClientAdapter. TLS bypass only active when isDevEnv: true. |
NetworkCacheService |
Versioned Hive box. SHA-256 hashed keys prevent cache poisoning. Endpoint-indexed entries enable pagination-aware invalidation. |
CacheEncryptDecryptService |
AES-256-CBC with a fresh random 16-byte IV per write. SHA-256 integrity check on every read. |
Request flow #
getResponse()is called.- If
isToLoadDataFromCache: trueand cache is fresh → return cached data immediately (no network call). - If
isToRefresh: trueor cache is stale/absent → make a Dio HTTP request. ApiInterceptor.onRequestchecks cache once more (for interceptor-managed requests).- On success (200/201)
ApiInterceptor.onResponsesaves the encrypted response to Hive. - On network error or 5xx,
ApiInterceptor.onErrorfalls back to any available cached data. decodeHttpRequestResponsemaps the Dio response toEither<Response, FailureState>.- If retry is enabled and the result is a failure,
getResponserecurses up toretryTimes.
Installation #
Add to your pubspec.yaml:
dependencies:
http_network_client: ^1.0.0
Then run:
flutter pub get
Setup #
Call NetworkService.configureNetworkService once at app startup, before any HTTP call (e.g., inside main() or your DI setup).
import 'package:http_network_client/http_network_client.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
cacheEncryptionKey: 'your-32-character-secret-key-here', // required
onUnauthorized: () => AppRouter.goToLogin(), // global 401/403 handler
);
runApp(const MyApp());
}
cacheEncryptionKeyis required — a 32-character string. All cached responses are stored encrypted with AES-256-CBC. AnArgumentErroris thrown at startup if the key is missing or wrong-length, so misconfiguration is caught early in every build.
Configuration parameters #
| Parameter | Type | Required | Description |
|---|---|---|---|
baseURL |
String |
— | Base URL prepended to every endpoint |
cacheEncryptionKey |
String |
required | Exactly 32 characters — AES-256 key for cache encryption |
cacheVersion |
int |
— | Hive box version (default 1). Increment to abandon the old cache and start fresh — no migration code needed. |
onUnauthorized |
void Function()? |
— | Global callback fired on every 401/403. Individual requests can override this. |
connectTimeout |
Duration |
— | Max time to establish a connection (default 15 s) |
receiveTimeout |
Duration |
— | Max time to receive a response (default 25 s) |
contentType |
String |
— | Default Content-Type header (default application/json) |
headers |
Map<String, dynamic> |
— | Additional default headers for every request |
pemCertificate |
String? |
— | PEM certificate string for SSL pinning |
localCertificateBytes |
Uint8List? |
— | Raw certificate bytes for SSL pinning |
isDevEnv |
bool |
— | Disables TLS certificate verification in development only |
Making Requests #
All requests go through NetworkService.apiRequest.getResponse(...).
The unAuthorizedCallBack is optional on every request. When omitted, the global onUnauthorized registered in configureNetworkService is used automatically. Only pass a per-request callback when you need different behaviour for a specific endpoint.
GET #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/users',
apiMethods: ApiMethods.get,
queryParams: {'page': 1, 'limit': 20},
// unAuthorizedCallBack omitted — global callback is used
);
POST #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/users',
apiMethods: ApiMethods.post,
body: {'name': 'John Doe', 'email': 'john@example.com'},
);
PUT / PATCH #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/users/1',
apiMethods: ApiMethods.put, // or ApiMethods.patch
body: {'name': 'Updated Name'},
);
DELETE #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/users/1',
apiMethods: ApiMethods.delete,
);
Per-request callback override #
Only needed when a specific request must handle 401 differently from the global default:
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/refresh-token',
apiMethods: ApiMethods.post,
unAuthorizedCallBack: () => _showSessionExpiredDialog(),
);
getResponse parameters #
| Parameter | Type | Required | Description |
|---|---|---|---|
endPoint |
String |
required | Path appended to baseURL |
apiMethods |
ApiMethods |
required | HTTP method |
unAuthorizedCallBack |
void Function()? |
— | Per-request 401/403 override. Falls back to the global onUnauthorized. |
queryParams |
Map<String, dynamic>? |
— | URL query parameters |
body |
dynamic |
— | Request body (Map, FormData, etc.) |
options |
Options? |
— | Override Dio options for this request |
cacheConfig |
CacheConfig |
— | Caching behaviour (default: no cache) |
retryConfig |
RetryConfig |
— | Retry behaviour (default: no retry) |
hasInternet |
bool |
— | Pass false to force cache-only lookup |
Handling Responses #
getResponse returns Either<Response<dynamic>, FailureState>:
Left(response)— success (status 200/201 or served from cache)Right(failureState)— error with message and status code
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/profile',
apiMethods: ApiMethods.get,
unAuthorizedCallBack: () => _logout(),
);
result.fold(
(response) {
// Success
final data = response.data as Map<String, dynamic>;
print(data['name']);
},
(failure) {
// statusCode is always set — switch on it for localised messages.
switch (failure.statusCode) {
case 401:
case 403:
_showDialog('Session expired. Please log in again.');
case 404:
_showDialog('Resource not found.');
case 422:
_showDialog(failure.message ?? 'Validation failed.');
case 500:
_showDialog('Server error. Please try again later.');
default:
_showDialog(failure.message ?? 'An unexpected error occurred.');
}
},
);
FailureState fields #
| Field | Type | Description |
|---|---|---|
statusCode |
int? |
HTTP status code — always set when an HTTP response was received. null for network-level failures (timeout, no connection). |
message |
String? |
Server-provided message from the response body. null when the server did not return one — your UI should provide the display text. |
data |
dynamic |
Raw response body on error |
messageis never hardcoded by the package. It comes directly from the server response or isnull. Always handle thenullcase in your UI so error display stays in the presentation layer where it belongs.
Caching #
Configure caching per request using CacheConfig.
Cache and load from cache #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/products',
apiMethods: ApiMethods.get,
unAuthorizedCallBack: _handleUnauth,
cacheConfig: CacheConfig(
isToCache: true, // Save response to cache
isToLoadDataFromCache: true, // Return cache immediately if fresh
cacheDuration: Duration(hours: 6),
),
);
Stale-while-revalidate pattern #
Use additionCallback to know whether a network call was made and whether the cache was stale, so you can show a refresh indicator:
cacheConfig: CacheConfig(
isToCache: true,
isToLoadDataFromCache: true,
cacheDuration: Duration(days: 1),
additionCallback: (willFetchFromNetwork, isCacheStale) {
if (isCacheStale) showRefreshBanner();
},
),
Force refresh #
cacheConfig: CacheConfig(
isToCache: true,
isToRefresh: true, // Bypass cache, overwrite with fresh data
),
Offline-only lookup #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/profile',
apiMethods: ApiMethods.get,
hasInternet: false, // Only read from cache
unAuthorizedCallBack: _handleUnauth,
cacheConfig: CacheConfig(isToLoadDataFromCache: true),
);
CacheConfig reference #
| Property | Type | Default | Description |
|---|---|---|---|
isToCache |
bool |
false |
Save the response to cache |
isToLoadDataFromCache |
bool |
false |
Serve from cache when fresh |
cacheDuration |
Duration |
3 days |
Cache TTL |
isToRefresh |
bool |
false |
Force network call and overwrite cache |
additionCallback |
Function(bool, bool)? |
null |
(willFetchNetwork, isCacheStale) |
Retry Logic #
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/orders',
apiMethods: ApiMethods.get,
unAuthorizedCallBack: _handleUnauth,
retryConfig: RetryConfig(
isToRetryOnError: true,
retryTimes: 3,
),
);
| Property | Type | Default | Description |
|---|---|---|---|
isToRetryOnError |
bool |
false |
Enable automatic retry |
retryTimes |
int |
3 |
Maximum retry attempts |
File Uploads #
Single file #
import 'dart:io';
import 'package:dio/dio.dart';
final file = File('/path/to/image.jpg');
final result = await NetworkService.apiRequest.uploadAnySingleFile(
endPoint: '/upload/avatar',
file: file,
formData: FormData.fromMap({'userId': '123'}),
uploadType: UploadType.jpg,
unAuthorizedCallBack: _handleUnauth,
);
Multiple files #
final files = [File('/path/a.jpg'), File('/path/b.jpg')];
final result = await NetworkService.apiRequest.uploadAnyMultipleFile(
endPoint: '/upload/photos',
files: files,
formData: FormData.fromMap({'albumId': '42'}),
uploadType: UploadType.jpg,
options: Options(headers: {'X-Custom': 'value'}),
unAuthorizedCallBack: _handleUnauth,
);
UploadType values #
| Value | MIME type |
|---|---|
UploadType.jpg |
image/jpg |
UploadType.png |
image/png |
UploadType.pdf |
pdf |
UploadType.audio |
audio |
SSL Pinning #
PEM certificate (string) #
const pemCert = '''
-----BEGIN CERTIFICATE-----
MIIBIjANBgkqhkiG9w0BAQEFAAOC...
-----END CERTIFICATE-----
''';
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
pemCertificate: pemCert,
);
Certificate bytes #
final certBytes = (await rootBundle.load('assets/cert.pem')).buffer.asUint8List();
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
localCertificateBytes: certBytes,
);
Note: Set
isDevEnv: trueto bypass SSL verification during development.
Request Cancellation #
Cancel in-flight requests by their endpoint URL:
// Start a cancellable request
NetworkService.apiRequest.getResponse(
endPoint: '/search',
apiMethods: ApiMethods.get,
queryParams: {'q': query},
unAuthorizedCallBack: _handleUnauth,
);
// Cancel it (e.g., when the user types a new query)
NetworkService.apiRequest.cancelRequest('/search');
Dynamic Headers After Login #
At app start there is no token yet, so configureNetworkService is called without one.
After a successful login, inject the token into every subsequent request using setAuthToken — no re-initialisation required.
// App startup — no token yet
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
cacheEncryptionKey: encryptionKey,
onUnauthorized: () => AppRouter.goToLogin(),
);
// After login succeeds
final token = await authRepo.login(username, password);
NetworkService.setAuthToken(token); // all requests now carry Authorization: Bearer <token>
// On logout
NetworkService.clearAuthToken(); // strips the header
await NetworkService.clearCache(); // wipe user-specific cached data
AppRouter.goToLogin();
Update arbitrary headers at runtime #
Use updateHeaders when you need to change any header (e.g. locale, tenant ID) without touching the token:
NetworkService.updateHeaders({
'Accept-Language': selectedLocale,
'X-Tenant-Id': tenantId,
});
Existing keys are overwritten; all other headers are preserved.
Per-request header override #
Only needed when a single request requires a different header from the global default:
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/download',
apiMethods: ApiMethods.get,
options: Options(headers: {'Accept': 'application/octet-stream'}),
);
API Reference #
NetworkService #
| Member | Type | Description |
|---|---|---|
configureNetworkService(...) |
static Future<void> |
Initialize before first request |
apiRequest |
static ApiRequest |
All HTTP request operations |
apiManager |
static ApiManager |
Direct Dio access |
ApiRequest #
| Method | Description |
|---|---|
getResponse(...) |
Execute GET/POST/PUT/PATCH/DELETE |
uploadAnySingleFile(...) |
Upload a single file as multipart |
uploadAnyMultipleFile(...) |
Upload multiple files as multipart |
cancelRequest(url) |
Cancel in-flight request by URL key |
decodeHttpRequestResponse(...) |
Low-level response decoder |
Error Status Codes #
| HTTP Code | Behaviour |
|---|---|
200 / 201 |
Left(response) — success |
101 (internal) |
Cache hit — normalised to 200 before returning |
400 / 422 |
Right(FailureState) with validation details |
401 / 403 |
Right(FailureState) + unAuthorizedCallBack fired |
404 / 406 |
Right(FailureState) with message and raw data |
429 / 500 |
Right(FailureState) with server error message |
| Connection error | Falls back to cache if available, otherwise Right(FailureState) |
Cache Warming #
Pre-populate the cache at app startup so data is instantly available on first render — no loading spinner needed.
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
cacheEncryptionKey: key,
onUnauthorized: () => AppRouter.goToLogin(),
);
// Fire all requests concurrently; each failure is independent.
await NetworkService.warmCache([
WarmCacheRequest(endPoint: '/profile', method: ApiMethods.get),
WarmCacheRequest(endPoint: '/dashboard', method: ApiMethods.get),
WarmCacheRequest(
endPoint: '/products',
method: ApiMethods.get,
queryParams: {'limit': 20},
cacheDuration: Duration(hours: 6),
),
]);
Then in your screen, read from cache first with no UI delay:
final result = await NetworkService.apiRequest.getResponse(
endPoint: '/profile',
apiMethods: ApiMethods.get,
cacheConfig: CacheConfig(isToLoadDataFromCache: true, isToCache: true),
);
WarmCacheRequest fields #
| Field | Type | Default | Description |
|---|---|---|---|
endPoint |
String |
required | Endpoint path |
method |
ApiMethods |
required | HTTP method |
queryParams |
Map? |
null | URL query parameters |
body |
dynamic |
null | Request body for POST/PUT |
cacheDuration |
Duration |
3 days | TTL for warmed data |
Cache Lifecycle #
Clear on logout #
Call NetworkService.clearCache() when a user logs out so no sensitive data remains on disk:
Future<void> logout() async {
await NetworkService.clearCache();
AppRouter.goToLogin();
}
Schema migration (cacheVersion) #
When a new app release changes the response structure of cached endpoints, increment cacheVersion. The old Hive box is abandoned and a fresh one starts — no migration code required:
// v1 → v2: old ns_cache_v1 box is left behind, ns_cache_v2 starts clean.
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
cacheEncryptionKey: key,
cacheVersion: 2,
);
Inspect the active box name (debugging) #
print(NetworkCacheService.currentBoxName); // e.g. ns_cache_v2
Cache Encryption #
All cached responses are stored using AES-256-CBC with SHA-256 integrity verification:
- Key: 32 bytes (required —
ArgumentErrorthrown at startup if absent or wrong length) - IV: 16 random bytes generated fresh per write (never reused across entries)
- Storage format per entry:
ivBase64::encryptedBase64::sha256Hash - Hive keys are SHA-256 hashed — user-controlled request data cannot be used as raw storage keys
Encryption key rotation #
When rotating the cacheEncryptionKey (e.g. for a security policy change), also increment cacheVersion. The old encrypted data is unreachable under the new key and the new box starts fresh:
await NetworkService.configureNetworkService(
baseURL: 'https://api.example.com',
cacheEncryptionKey: newKey, // new 32-char key
cacheVersion: 3, // bump version to abandon old encrypted entries
);