netguard 2.0.0
netguard: ^2.0.0 copied to clipboard
A Dio-based HTTP client for Flutter with caching, connectivity handling, auth token refresh, retries and certificate pinning.
NetGuard ๐ก๏ธ #
A Dio-based HTTP client for Flutter with caching, connectivity handling, authentication token refresh, retries and certificate pinning.
NetGuard wraps Dio rather than replacing it. Every Dio type you already use โ
Response, DioException, Interceptor, CancelToken, FormData โ works
unchanged, and netGuard.dio gives you the underlying instance whenever you
need it.
โจ Features #
- HTTP cache with policies โ
cacheFirst,networkFirst,staleWhileRevalidate,cacheOnly, plusETag/304revalidation - Connectivity handling โ fail fast, wait for the connection, or persist the request and replay it after a restart
- Authentication โ automatic token refresh, both reactive (on 401) and proactive (before the JWT expires), with one refresh shared across concurrent requests
- Retries โ exponential backoff with jitter,
Retry-Aftersupport, idempotent methods only by default - Request deduplication โ identical in-flight GETs collapse into one call
- Certificate pinning โ reject certificates you did not authorise
- Redacted logging โ bearer tokens, JWTs and sensitive headers are masked before anything reaches your log sink
- Observability โ per-request metrics and a rolling cache-hit-rate snapshot
- Cross-platform โ iOS, Android, web (JS and wasm), macOS, Windows, Linux
๐ฆ Installation #
dependencies:
netguard: ^2.0.0
import 'package:netguard/netguard.dart';
Types that require dart:io (such as IOHttpClientAdapter) live in a separate
entry point, so the main library stays usable on the web:
import 'package:netguard/io.dart';
๐ Quick start #
final api = NetGuard.withOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
logLevel: NetGuardLogLevel.info,
);
try {
final response = await api.get<Map<String, dynamic>>('/products');
print(response.data);
} on DioException catch (e) {
print(e.apiMessage);
}
NetGuard also offers a shared instance:
await NetGuard.configure(baseUrl: 'https://api.example.com');
final response = await NetGuard.instance.get<dynamic>('/products');
๐ Requests #
All the usual verbs are available, and each takes the same set of NetGuard options:
await api.get<dynamic>('/users');
await api.post<dynamic>('/users', data: {'name': 'Ada'});
await api.put<dynamic>('/users/1', data: {'name': 'Ada L.'});
await api.patch<dynamic>('/users/1', data: {'active': true});
await api.delete<dynamic>('/users/1');
await api.head<dynamic>('/users');
Typed responses #
class User {
User(this.id, this.name);
final int id;
final String name;
static User fromJson(dynamic json) =>
User(json['id'] as int, json['name'] as String);
}
final user = await api.getAs<User>('/me', decoder: User.fromJson);
A body that does not match raises a DioException naming the endpoint and the
expected type, rather than an opaque cast error.
โ ๏ธ Error handling #
NetGuard follows Dio: a non-2xx response throws a DioException.
try {
await api.get<dynamic>('/protected');
} on DioException catch (e) {
if (e.isNetworkError) {
// No connection.
} else if (e.statusCode == 422) {
print(e.apiMessage); // Pulled from message/error/detail/title.
print(e.apiError); // The decoded JSON body.
}
print(e.userFriendlyMessage);
}
Migrating from 1.x: NetGuard 1.x forced
validateStatus: (s) => s < 600on every request, so 4xx and 5xx responses resolved instead of throwing andtry/catchnever fired. If your code inspectsresponse.statusCoderather than catching, setapi.options.resolveErrorStatuses = trueto keep the old behaviour while you migrate.
๐พ Caching #
Caching is opt-in per request:
// Serve the cached copy instantly, refresh in the background.
await api.get<dynamic>('/feed', cachePolicy: CachePolicy.staleWhileRevalidate);
// Prefer the network, fall back to cache when it fails or you are offline.
await api.get<dynamic>('/feed', cachePolicy: CachePolicy.networkFirst);
// Use the cache when fresh, otherwise fetch.
await api.get<dynamic>(
'/config',
cachePolicy: CachePolicy.cacheFirst,
cacheDuration: const Duration(hours: 1),
);
| Policy | Behaviour |
|---|---|
networkOnly |
Never touches the cache. The default. |
cacheFirst |
Fresh cache wins; otherwise fetch and store. |
networkFirst |
Fetch; fall back to a stale copy on failure. |
staleWhileRevalidate |
Return cache immediately, refresh in the background. |
cacheOnly |
Cache or CacheMissException. Never fetches. |
NetGuard sends If-None-Match / If-Modified-Since when it holds a validator,
and a 304 refreshes the entry without re-downloading the body. A server
Cache-Control: max-age overrides cacheDuration.
Invalidation #
await api.cache.invalidateWhere('/orders'); // Everything under /orders.
await api.cache.clearNamespace(); // This client's entries.
print(api.cache.getStats());
Cache keys include the base URL, method and sorted query parameters, so two
clients pointed at different hosts never collide and ?a=1&b=2 matches
?b=2&a=1.
๐ Connectivity #
api.options.handleNetwork = true;
api.options.offlineBehavior = OfflineBehavior.fail;
| Behaviour | Effect |
|---|---|
fail |
Throw NetworkOfflineException immediately. The default. |
wait |
Block until the connection returns, bounded by offlineTimeout. |
queue |
Persist the request, replay it when back online. |
queue is for fire-and-forget mutations. The caller still receives a
NetworkOfflineException right away; the eventual response arrives through
onQueueReplay, because there is nothing honest to return synchronously:
api.options
..offlineBehavior = OfflineBehavior.queue
..onQueueReplay = (result) {
if (result.isSuccess) print('Replayed ${result.request.path}');
};
Queued requests survive an app restart, carry an Idempotency-Key, and drop
their credentials so a replay picks up a current token rather than a stale one.
Status #
api.statusStream.listen((status) {
if (status == NetworkStatus.offline) showOfflineBanner();
});
print(api.isOnline);
print(api.networkInfo);
By default NetGuard trusts connectivity_plus. To also verify reachability โ
catching captive portals and data-less connections โ point a probe at your
own endpoint:
api.configureConnectivityProbe(
endpoints: ['https://api.example.com/health'],
);
Do not point this at a third party: it leaks usage data, costs battery, and fails CORS on the web.
๐ Authentication #
api.configureAuth(
callbacks: AdvanceAuthCallbacks(
initialToken: storedAccessToken,
initialRefreshToken: storedRefreshToken,
onRefreshToken: () async {
final response = await api.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refresh_token': storedRefreshToken},
isLogin: true, // Never trigger a refresh for the refresh call itself.
);
return response.data?['access_token'] as String?;
},
onTokenRefreshed: (token) => secureStorage.write('access_token', token),
onLogout: () async {
await secureStorage.deleteAll();
navigator.pushReplacementNamed('/login');
},
),
config: const AuthConfig(
proactiveRefresh: true,
refreshLeeway: Duration(seconds: 30),
maxRetryAttempts: 2,
),
);
With proactiveRefresh on, NetGuard reads the JWT exp claim and refreshes
before sending, so you do not spend a request discovering the token is stale.
Opaque tokens fall back to reactive refresh on 401.
Concurrent 401s share a single refresh: the first request performs it, the rest await the same future and then retry. Replays traverse this client's own interceptor chain, so your own interceptors still run.
Mark your sign-in call with isLogin: true so a 401 from bad credentials does
not trigger a refresh loop.
๐ Retries #
api.options.retryPolicy = const RetryPolicy(
maxAttempts: 3,
initialDelay: Duration(milliseconds: 300),
backoffFactor: 2,
jitter: 0.25,
);
// Or per request.
await api.get<dynamic>('/flaky', retryPolicy: const RetryPolicy(maxAttempts: 5));
Retries apply to transport failures and to 408/425/429/500/502/503/504. A
Retry-After header is honoured. Only idempotent methods retry by default; set
retryNonIdempotent: true if your POST endpoints are safe to repeat.
๐ Certificate pinning #
api.pinCertificates(
CertificatePinning(
fingerprints: {
'a1b2c3โฆ', // Current certificate.
'd4e5f6โฆ', // Backup, for rotation.
},
allowedHosts: {'api.example.com'},
),
);
Obtain a fingerprint with:
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null \
| openssl x509 -outform DER | openssl dgst -sha256
Operational warning: a rotated certificate without a matching app release bricks every installed client. Always pin a backup, and ship well before expiry. If you cannot guarantee that, do not pin. Pinning is a no-op on the web, where the browser owns TLS validation.
๐ Logging #
api.enableLogging(
level: NetGuardLogLevel.info,
emitCurl: true,
);
Authorization, Cookie, X-Api-Key and friends are masked, and bearer tokens
or JWTs appearing anywhere in a body are masked too โ so a log you paste into an
issue tracker does not leak a session.
Route logs into your own logging package with a custom sink:
NetGuardLogger.global
..level = NetGuardLogLevel.info
..sink = (record) => myLogger.log(record.level.name, record.message);
๐ Metrics #
api.options.onMetrics = (m) {
analytics.timing(m.path, m.duration, source: m.source.name);
};
print(api.metrics); // requests: 42, ok: 40, cacheHitRate: 31.0%, avg: 128ms
๐งน Cancellation #
await api.get<dynamic>('/widgets', cancelGroup: 'dashboard');
await api.get<dynamic>('/alerts', cancelGroup: 'dashboard');
@override
void dispose() {
api.cancelGroup('dashboard');
super.dispose();
}
โฑ๏ธ Rate limiting #
final api = NetGuard.withOptions(
baseUrl: 'https://api.example.com',
maxConcurrentRequests: 4,
minRequestInterval: const Duration(milliseconds: 50),
);
Identical in-flight GETs are also deduplicated, so two widgets requesting the
same endpoint on the same frame produce one network call. Disable with
deduplicateRequests: false.
๐ค Body encoding #
api.options.bodyEncoder = (body) => myCipher.encrypt(jsonEncode(body));
await api.post<dynamic>('/secure', data: payload, encodeBody: true);
The default encoder is
base64(jsonEncode(body)). That is encoding, not encryption โ trivially reversible, and it provides no confidentiality. It exists for APIs that expect a base64 envelope. TLS already protects the payload in transit; if you need application-layer confidentiality, supply a function performing authenticated encryption with a key the client does not ship.
๐งช Testing #
Swap the adapter to drive requests without a network:
final api = NetGuard.withOptions(baseUrl: 'https://mock.test');
api.httpClientAdapter = myMockAdapter;
See test/support/mock_adapter.dart for a
small scripted adapter used by this package's own suite.
๐ Migrating from 1.x #
Deprecated APIs #
These still work and are removed in 3.0.0. Your IDE names the replacement on each one.
| Deprecated | Use instead |
|---|---|
options.encryptionFunction |
options.bodyEncoder |
encryptBody: true |
encodeBody: true |
useCache: true |
cachePolicy: CachePolicy.staleWhileRevalidate |
options.throwOnOffline = true |
options.offlineBehavior = OfflineBehavior.fail |
options.throwOnOffline = false |
options.offlineBehavior = OfflineBehavior.wait |
options.autoRetryOnNetworkRestore = true |
options.offlineBehavior = OfflineBehavior.queue |
options.maxNetworkRetries = n |
options.retryPolicy = RetryPolicy(maxAttempts: n) |
options.networkRetryDelay = d |
options.retryPolicy = RetryPolicy(initialDelay: d) |
options.showLogs = true |
options.logLevel = NetGuardLogLevel.verbose |
options.showLogs = false |
options.logLevel = NetGuardLogLevel.error |
Renamed, with no alias #
| 1.x | 2.0 |
|---|---|
response.isRedirect |
response.isRedirection |
IOHttpClientAdapter from the main import |
import 'package:netguard/io.dart' |
Behavioural changes #
| Was | Now |
|---|---|
| 4xx/5xx resolved silently | Throws; opt out with options.resolveErrorStatuses = true |
Offline returned a fake 503 body |
Throws NetworkOfflineException |
| Auth replays skipped your interceptors | Replays traverse the client's own chain |
See CHANGELOG.md for the 1.x bugs this release fixes.
๐ Full API reference #
DOCUMENTATION.md documents every public type and member โ
the request pipeline, all 47 public types, RequestOptions.extra keys, and the
concurrency guarantees of the auth refresh.
๐ค Contributing #
See CONTRIBUTING.md. Run flutter analyze and
flutter test before opening a pull request.
๐ License #
MIT โ see LICENSE.