app_smart_network 1.3.0
app_smart_network: ^1.3.0 copied to clipboard
A smart Flutter network package with built-in error handling, configurable retry, optional SSL certificate pinning, and multilingual error messages (en/ar).
app_smart_network #
A smart Flutter network package built on Dio with:
- Configurable retry – retries idempotent requests (GET, PUT, DELETE) by default; tune or disable it app-wide, or override it on a single call
- Certificate pinning – optional SPKI pinning per host, with backup pins and staged rollout
- Connectivity guard – checks internet before every request; throws a clear offline error
- Mobile timeout – extends receive-timeout automatically on cellular networks
- Locale-aware errors – all error messages respect the active language (built-in English & Arabic, extensible)
- Separate upload / download services – progress callbacks included
- Zero context dependency – no
BuildContextneeded for error messages
Installation #
Add to your pubspec.yaml:
dependencies:
app_smart_network: ^1.3.0
Setup #
Call ApiService.initialize() once in main() before runApp():
void main() {
ApiService.initialize(
NetworkConfig(
baseUrl: 'https://api.example.com',
// Called when the server returns HTTP 401
onUnauthorized: () {
ApiService.instance.removeAuthToken();
// navigate to login
},
),
);
runApp(const MyApp());
}
Important: Accessing
ApiService.instancebefore callinginitialize()throws aStateError. Always callinitialize()first.
Use ApiService.isInitialized to guard conditional access:
if (ApiService.isInitialized) {
ApiService.instance.setAuthToken(token);
}
NetworkConfig options #
| Parameter | Type | Default | Description |
|---|---|---|---|
baseUrl |
String |
required | Base URL for all requests |
connectTimeout |
Duration |
30 s | Connection timeout |
receiveTimeout |
Duration |
30 s | Response timeout |
defaultHeaders |
Map<String, String>? |
null |
Extra headers added to every request |
allowBadCertificate |
bool |
false |
Bypass SSL validation (debug only) |
onUnauthorized |
OnUnauthorizedCallback? |
null |
Invoked on HTTP 401 |
interceptors |
List<Interceptor> |
const [] |
Custom Dio interceptors added to the chain |
retry |
RetryPolicy? |
RetryPolicy() |
App-wide retry behaviour; null disables retry |
certificatePinning |
CertificatePinningConfig? |
null |
SSL public-key pinning; null disables pinning |
Retry #
By default every idempotent request (GET, PUT, DELETE, HEAD, OPTIONS) is
retried up to three times with a 1 s / 2 s / 3 s backoff. Change that with a
RetryPolicy on NetworkConfig:
ApiService.initialize(NetworkConfig(
baseUrl: 'https://api.example.com',
retry: const RetryPolicy(
attempts: 2, // 2 retries, 3 tries total
delays: [Duration(seconds: 1), Duration(seconds: 5)],
statuses: {500, 502, 503, 504},
),
));
Pass retry: null to turn retry off for the whole app.
RetryPolicy options #
| Parameter | Type | Default | Description |
|---|---|---|---|
attempts |
int |
3 |
Retries after the first try; 0 disables retry (max 10) |
delays |
List<Duration> |
[1 s, 2 s, 3 s] |
Backoff between attempts; the last entry repeats |
methods |
Set<String> |
{GET, PUT, DELETE, HEAD, OPTIONS} |
Methods eligible for retry |
statuses |
Set<int> |
defaultRetryableStatuses |
Response codes treated as retryable |
Per-request retry #
Any single call can override the app-wide policy — request(), download()
and uploadFile() all take a retry: argument:
// Never retry this payment, whatever the app-wide policy says.
await api.request(HttpMethod.post, '/payments', retry: RetryPolicy.off);
// Retry this POST five times — it carries an idempotency key.
await api.request(
HttpMethod.post,
'/sync',
data: payload,
retry: const RetryPolicy(attempts: 5),
);
// Treat 409 as retryable for this call only.
await api.request(HttpMethod.get, '/lock', retry: const RetryPolicy(statuses: {409}));
Omitting retry: uses the app-wide policy, so existing code keeps its current
behaviour.
Two fields are app-wide only.
delayscannot vary per request — the backoff schedule is fixed when the client is built.methodsis the allowlist for calls that didn't ask for anything: attaching a policy to a request is itself your statement that the call is safe to replay, so the allowlist is bypassed there. That is whyretry: RetryPolicy(attempts: 5)retries a POST even though POST is not in the default allowlist.
Retrying non-idempotent requests is your call. A retried POST can create duplicate records if the first attempt reached the server but the response was lost. Only opt one in when the endpoint is idempotent — for example when it accepts an idempotency key.
Certificate pinning #
Pinning is off by default. When enabled, the package additionally requires a host's leaf certificate to carry a known public key, so a forged certificate is rejected even if some CA in the device's trust store signed it.
ApiService.initialize(NetworkConfig(
baseUrl: 'https://api.example.com',
certificatePinning: CertificatePinningConfig(
pins: {
'api.example.com': [
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // current key
'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // backup key
],
},
),
));
Generating a pin #
A pin is the base64 SHA-256 of the certificate's SubjectPublicKeyInfo — the
same sha256/… format used by HPKP and OkHttp's CertificatePinner, so
existing tooling and runbooks apply:
openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
The digest covers the public key, not the whole certificate. A pin therefore keeps working across certificate renewal as long as the key pair is reused — unlike a whole-certificate hash, which breaks on every routine rotation.
⚠️ Always ship a backup pin #
A single pin is an outage waiting to happen. If that key is lost or has to be rotated in a hurry, every installed copy of the app stops reaching your API and no server-side change can fix it — only a new app release can, at store review speed.
Generate a second key pair, compute its pin, and keep the key offline. Ship both pins from day one so a rotation is a server-side change.
The package enforces this: fewer than two distinct pins for a host throws
ArgumentErrorat startup.
Options #
| Parameter | Type | Default | Description |
|---|---|---|---|
pins |
Map<String, List<String>> |
required | Host → accepted sha256/… pins (min. 2 per host) |
enforce |
bool |
true |
When false, mismatches are reported but allowed |
includeSubdomains |
bool |
false |
Extend a host's pins to its subdomains |
onPinFailure |
OnPinFailureCallback? |
null |
Called on every mismatch, for telemetry |
Which hosts are pinned #
Only the hosts you list. Everything else — analytics, crash reporting, Firebase, image CDNs — falls through to normal TLS validation untouched. Pinning does not fail closed on unknown hosts, because doing so would break every third-party service the app talks to.
With includeSubdomains: true, example.com also covers api.example.com and
a.b.example.com, but never notexample.com. An exact host entry always wins
over an inherited one.
Staged rollout #
Set enforce: false to measure impact before enforcing. Mismatches are reported
to onPinFailure and the connection proceeds:
CertificatePinningConfig(
pins: {'api.example.com': [currentPin, backupPin]},
enforce: false, // report only — never ship this as the final state
onPinFailure: (host, presentedPins) {
analytics.log('pin_mismatch', {'host': host, 'pins': presentedPins});
},
)
Watch the failure rate, confirm it is zero for legitimate traffic, then flip
enforce back to true.
Handling a pin failure #
A pin failure is a security event, not a connectivity blip, and it arrives as a distinct exception type:
try {
await ApiService.instance.request(HttpMethod.get, '/me');
} on CertificatePinningException catch (e) {
security.report('pin_failure', host: e.host); // e.host is the pinned host
showBlockingSecurityWarning();
} on ApiException catch (e) {
showSnackBar(e.message);
}
CertificatePinningException extends ApiException, so existing handlers keep
working. Its message is deliberately generic and locale-aware; the pins the
server actually presented go to onPinFailure for telemetry and are never put
in a user-facing message.
Pin failures are never retried — replaying a rejected handshake re-presents the same certificate for the same verdict.
Shielded and unshielded builds #
To ship one build that pins and one that does not, vary only the config:
const isShielded = bool.fromEnvironment('SHIELDED', defaultValue: true);
ApiService.initialize(NetworkConfig(
baseUrl: 'https://api.example.com',
certificatePinning: isShielded
? CertificatePinningConfig(pins: {'api.example.com': [currentPin, backupPin]})
: null,
));
allowBadCertificate: true cannot be combined with certificatePinning —
one disables validation while the other tightens it. The pair throws
ArgumentError at startup rather than producing an app that looks pinned but is
not.
What is validated, and when #
Pinning runs through Dio's validateCertificate hook, which evaluates the leaf
certificate on every connection — not badCertificateCallback, which fires
only after chain validation has already failed and so could never add pinning on
top of an otherwise-valid certificate.
Malformed or unparseable certificates are rejected, never treated as a pass.
Custom interceptors #
Pass your own Dio interceptors to NetworkConfig. They are registered
before the built-in retry interceptor, the 401 handler, and the debug
logger:
your interceptors → retry → 401 handler → debug logger
That position matters for two reasons:
- Your
onErrorsees a 401 beforeonUnauthorizedfires (a 401 is not retried, so it reaches your interceptor untouched). - For a request that genuinely gets retried, each retry restarts the whole
chain, so your
onErrorfires once per real attempt — never a duplicate replay of the same failure, which is what happens if an interceptor sits after retry instead.
Headers you set in onRequest still show up in the debug logs, since the
logger stays last.
class TraceInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['X-Trace-Id'] = 'trace-id'; // your implementation
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// your implementation, e.g. report to a crash-reporting tool
handler.next(err);
}
}
void main() {
ApiService.initialize(
NetworkConfig(
baseUrl: 'https://api.example.com',
interceptors: [TraceInterceptor()],
),
);
runApp(const MyApp());
}
Interceptor, InterceptorsWrapper, QueuedInterceptor,
QueuedInterceptorsWrapper, RequestOptions, RequestInterceptorHandler,
ResponseInterceptorHandler, ErrorInterceptorHandler, DioException,
DioExceptionType and ResponseType are all exported from
package:app_smart_network, so you don't need a direct dio dependency to
write most interceptors.
Interceptors are captured at
initialize(). To change them, callApiService.initialize()again with a newNetworkConfig— and pass fresh interceptor instances when you do, since the package closes the old Dio client but does not dispose your interceptors.
Token refresh on 401 #
Since the package's Dio instance isn't exposed, replay a request after a
token refresh through ApiService.instance.request(...):
class TokenRefreshInterceptor extends Interceptor {
bool _isRefreshing = false;
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401 || _isRefreshing) {
handler.next(err);
return;
}
_isRefreshing = true;
try {
final newToken = await _refreshToken(); // your implementation
ApiService.instance.setAuthToken(newToken);
final requestOptions = err.requestOptions;
final response = await ApiService.instance.request<dynamic>(
HttpMethod.values.byName(requestOptions.method.toLowerCase()),
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
);
handler.resolve(response);
} catch (_) {
// Refresh or replay failed — fall through to onUnauthorized.
handler.next(err);
} finally {
_isRefreshing = false;
}
}
Future<String> _refreshToken() async {
// your implementation: call your refresh endpoint and return the new
// access token.
throw UnimplementedError();
}
}
Re-entrancy warning:
ApiService.instance.request(...)re-enters the full interceptor chain, including this interceptor's ownonErrorif the replayed request also fails with a 401. The_isRefreshingguard above prevents that from recursing into another refresh attempt, but a repeated 401 after a successful refresh will still fall through toonErroronce more with_isRefreshingfalse again — make sure your refresh logic can't loop indefinitely (e.g. cap retries or bail out if the new token is rejected immediately).
Caveats #
onRequestfires once per retry attempt, not just the first. Prefer assignment (options.headers['X'] = v) over accumulation (options.path = '$prefix${options.path}') — accumulation compounds on every retried attempt.- The connectivity pre-check in
ensureConnected()throws before the request ever reaches Dio, so consumer interceptors never observe offline failures; only failures that occur after a request is actually dispatched reach youronError.
Making requests #
final api = ApiService.instance;
// GET
final response = await api.request<Map<String, dynamic>>(
HttpMethod.get,
'/users/me',
);
// POST with body
final response = await api.request<Map<String, dynamic>>(
HttpMethod.post,
'/posts',
data: {'title': 'Hello', 'body': 'World'},
);
// PUT
final response = await api.request<Map<String, dynamic>>(
HttpMethod.put,
'/posts/1',
data: {'title': 'Updated'},
);
// DELETE
await api.request<void>(HttpMethod.delete, '/posts/1');
Per-request options #
final cancelToken = CancelToken();
final response = await api.request<Map<String, dynamic>>(
HttpMethod.get,
'/search',
queryParameters: {'q': 'flutter'},
cancelToken: cancelToken,
options: Options(headers: {'X-Custom': 'value'}),
onReceiveProgress: (received, total) {
if (total != -1) print('${(received / total * 100).toStringAsFixed(0)}%');
},
);
// Cancel any time
cancelToken.cancel();
Override base URL per request #
// Uses https://cdn.example.com/file instead of the global baseUrl
final response = await api.request<dynamic>(
HttpMethod.get,
'/file',
baseUrl: 'https://cdn.example.com',
);
Auth token #
// Set after login
ApiService.instance.setAuthToken(token);
// Remove on logout
ApiService.instance.removeAuthToken();
Locale-aware error messages #
Call setAppLocale() whenever the user changes language. It:
- Sets the
Accept-Languagerequest header - Switches the internal error-message locale
// Switch to Arabic
ApiService.instance.setAppLocale('ar');
// Back to English
ApiService.instance.setAppLocale('en');
From that point on, every ApiException.message and connectivity error is
returned in the selected language automatically.
Call removeAppLocale() to go back to the default locale that was set in
NetworkConfig.defaultHeaders['Accept-Language'] at initialize() time
(falls back to 'en' if no locale was set there):
ApiService.instance.removeAppLocale();
Built-in languages #
| Code | Language |
|---|---|
en |
English (default) |
ar |
Arabic |
Add a custom language #
NetworkLocale.addTranslations('fr', {
'NoInternetConnection': 'Pas de connexion internet.',
'ConnectionTimeout': 'Délai de connexion dépassé.',
'status_404': 'Ressource introuvable.',
// ... add only the keys you need; missing keys fall back to English
});
ApiService.instance.setAppLocale('fr');
Remove custom translations #
// Remove only French overrides
NetworkLocale.clearCustomTranslations('fr');
// Remove all custom translations across every locale
NetworkLocale.clearCustomTranslations();
Error handling #
All errors are thrown as ApiException:
try {
final response = await api.request<Map<String, dynamic>>(
HttpMethod.get,
'/users/me',
);
final user = UserModel.fromJson(response.data!);
} on ApiException catch (e) {
print(e.message); // translated to current locale
print(e.statusCode); // HTTP status (0 = network/offline)
print(e.errorCategory); // 'Network Error', 'Authentication Error', …
print(e.apiErrorCode); // server-side code e.g. 'UserNotFound'
// Specific checks
if (e.isUnauthorized) { /* 401 */ }
if (e.isNotFound) { /* 404 */ }
if (e.isNetworkError) { /* offline / no connection */ }
if (e.isValidationError) { /* 422 */ }
// Check a custom server error code
if (e.hasApiErrorCode('UserNotActive')) { /* … */ }
// Read a field from the raw response body
final errors = e.getResponseField<List>('errors');
}
Domain layer – map to your own Failure type #
ServerFailure / CacheFailure were removed in 1.0.3. Define your own
Failure types and map from ApiException in the repository layer:
// In your repository
Future<Either<Failure, UserModel>> getUser() async {
try {
final response = await api.request<Map<String, dynamic>>(
HttpMethod.get,
'/users/me',
);
return Right(UserModel.fromJson(response.data!));
} on ApiException catch (e) {
return Left(ServerFailure(e.message, statusCode: e.statusCode));
}
}
File upload #
final response = await api.uploadFile<Map<String, dynamic>>(
'/upload/avatar',
File('/path/to/image.jpg'),
fieldName: 'avatar',
data: {'userId': '42'}, // optional extra fields
onSendProgress: (sent, total) {
print('${(sent / total * 100).toStringAsFixed(0)}%');
},
);
File download #
await api.download(
'/files/report.pdf',
'/storage/emulated/0/Download/report.pdf',
onReceiveProgress: (received, total) {
if (total != -1) print('${(received / total * 100).toStringAsFixed(0)}%');
},
);
Dynamic configuration #
Change settings at runtime without reinitialising:
ApiService.instance.configure(
baseUrl: 'https://staging.example.com',
connectTimeoutMs: 15000,
receiveTimeoutMs: 15000,
headers: {'X-App-Version': '2.0.0'},
);
Typical datasource pattern #
class UserRemoteDatasource {
final ApiService _api = ApiService.instance;
Future<UserModel> getProfile() async {
// Set locale from cache before request
final locale = await AppCacheManager().getAppLocale();
if (locale != null) _api.setAppLocale(locale);
final response = await _api.request<Map<String, dynamic>>(
HttpMethod.get,
ApiEndpoints.profile,
);
return UserModel.fromJson(response.data!);
}
Future<UserModel> updateProfile(UpdateProfileParams params) async {
final response = await _api.request<Map<String, dynamic>>(
HttpMethod.put,
ApiEndpoints.profile,
data: params.toMap(),
cancelToken: params.cancelToken,
);
return UserModel.fromJson(response.data!);
}
}
Example app #
A full runnable example using JSONPlaceholder is in the example/ directory.
cd example
flutter run
It demonstrates: initialization, GET / POST requests, error dialogs with locale-aware messages, EN ↔ AR language toggle, and a 404 error demo.
Dependencies #
| Package | Role |
|---|---|
| dio | HTTP client |
| connectivity_plus | Network state |
| dio_smart_retry | Retry interceptor |
| pretty_dio_logger | Debug logging |
| asn1lib | Certificate parsing for pinning |
| crypto | SHA-256 for SPKI pins |