fl_smart_http 1.0.1
fl_smart_http: ^1.0.1 copied to clipboard
Flutter HTTP client with AES-256-GCM encrypted caching, offline support, priority request queue, interceptors, retry back-off, rate limiting, and DI support via IFlSmartHttp.
fl_smart_http #
A production-ready Flutter HTTP client with encrypted caching, offline support, interceptors, request queue with cancellation, retry with exponential back-off, and full DI support. Works with any state management framework (Provider, Riverpod, GetIt, Bloc, GetX).
Table of Contents #
- Features
- Installation
- Quick Start
- HTTP Methods
- Caching Strategies
- Request Queue & Cancellation
- Interceptors
- Optional Encryption
- Rate Limiting
- Offline Support
- Cache Management
- DI Integration
- Testing with Mocks
- FlResponse Reference
- FlHttpConfig Reference
- Platform Support
Features #
| Feature | Details |
|---|---|
| 🗄️ Smart Caching | 5 strategies: cacheFirst, networkFirst, staleWhileRevalidate, networkOnly, cacheOnly |
| 🔐 AES-256-GCM Encryption | PBKDF2-derived keys; each cache entry uses a fresh random 96-bit IV |
| 📶 Offline Support | Serves stale (expired) cache when the device is offline; configurable maxStaleAge |
| 🚦 Request Queue | Priority ordering (high/normal/low), concurrency limits, pause/resume, queue timeout |
| ❌ Cancellation | Cancel individual, grouped (tag), or all requests at once |
| 🔁 Retry + Backoff | Exponential back-off with optional jitter; configurable per-request via retryPolicy |
| 🔌 Interceptors | onRequest / onResponse / onError chain; built-in LoggingInterceptor & AuthRefreshInterceptor |
| ⏱️ Rate Limiting | maxRequestsPerSecond / maxRequestsPerMinute — auto-delays, never drops requests |
| 💉 DI Ready | IFlSmartHttp abstract interface; drop-in mock for unit tests |
| 🧩 Type-Safe | Generic FlResponse<T> — register fromJson once or pass inline per call |
| 🔑 Key Rotation | rotateEncryptionKey() re-encrypts all cached entries without clearing the cache |
Installation #
# pubspec.yaml
dependencies:
fl_smart_http: ^1.0.1
Encryption is handled via pointycastle (bundled) — no extra packages needed.
Quick Start #
Call FlSmartHttp.init() once in main(), before runApp().
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlSmartHttp.init(
baseUrl: 'https://api.example.com',
config: FlHttpConfig(
defaultCacheDuration: Duration(hours: 1),
defaultRetryAttempts: 3,
defaultStrategy: CacheStrategy.cacheFirst,
serveStaleOnOffline: true,
logLevel: FlLogLevel.verbose, // use FlLogLevel.none in production
interceptors: [
LoggingInterceptor(),
AuthRefreshInterceptor(onRefresh: () async => await getNewToken()),
],
),
);
// Register JSON factories once — never pass fromJson at every call site.
FlSmartHttp.registerModel<User>(User.fromJson);
FlSmartHttp.registerModel<List<User>>(
(json) => (json as List).map((e) => User.fromJson(e)).toList(),
);
runApp(const MyApp());
}
After init(), the singleton is available everywhere via FlSmartHttp.instance.
For additional isolated clients (e.g. multiple base URLs) use FlSmartHttp.create().
HTTP Methods #
All methods return Future<FlResponse<T>> and never throw — errors are captured in FlResponse.errorMessage and FlResponse.exception.
final http = FlSmartHttp.instance;
// ── GET ──────────────────────────────────────────────────────────────────────
// Uses the registered User.fromJson factory automatically.
final res = await http.get<User>(
endpoint: '/users/1',
token: accessToken, // prepends "Bearer " automatically if not present
cacheResponse: true,
cacheDuration: Duration(minutes: 30), // overrides config.defaultCacheDuration
retryPolicy: 3, // overrides config.defaultRetryAttempts for this call
strategy: CacheStrategy.networkFirst, // overrides config.defaultStrategy
);
if (res.isSuccess) {
print(res.data); // User instance
print(res.source); // DataSource.network | .cache | .staleCache
print(res.elapsed); // Duration of the full operation
}
// GET with query parameters — appended as ?userId=1&_limit=5
final posts = await http.get<List<Post>>(
endpoint: '/posts',
queryParams: {'userId': '1', '_limit': '5'},
);
// GET without a type — data is null; use res.rawBody or res.json
final raw = await http.get(endpoint: '/posts/1');
print(raw.rawBody); // raw JSON string
print(raw.json); // decoded Map/List, or null if body is not valid JSON
// GET with an inline factory — overrides the registry for this call only
final post = await http.get<Post>(
endpoint: '/posts/1',
fromJson: Post.fromJson,
);
// ── POST ─────────────────────────────────────────────────────────────────────
// body can be Map, List, or String — Maps and Lists are auto-jsonEncoded.
final created = await http.post<User>(
endpoint: '/users',
token: accessToken,
body: {'name': 'Alice', 'email': 'alice@example.com'},
);
// ── PUT (full update) ────────────────────────────────────────────────────────
final updated = await http.put<User>(
endpoint: '/users/1',
body: {'name': 'Alice', 'email': 'alice@example.com'},
);
// ── PATCH (partial update) ───────────────────────────────────────────────────
final patched = await http.patch<User>(
endpoint: '/users/1',
body: {'name': 'Alice Updated'},
);
// ── DELETE ───────────────────────────────────────────────────────────────────
final deleted = await http.delete(endpoint: '/users/1');
// Clear the stale cache entry afterwards:
await http.invalidate('/users/1');
// ── Error handling ────────────────────────────────────────────────────────────
final res = await http.get<User>(endpoint: '/users/99');
if (res.isFailure) {
print(res.statusCode); // e.g. 404, or -1 for network/local errors
print(res.errorMessage); // human-readable description
print(res.exception); // original exception, if any
}
Caching Strategies #
Set globally via FlHttpConfig.defaultStrategy, or override per-request via the strategy parameter.
| Strategy | Behaviour |
|---|---|
cacheFirst (default) |
Return cached data if not expired. Fetch from network if expired or missing. |
networkFirst |
Always try the network first. Fall back to cache if offline or on any network error. |
staleWhileRevalidate |
Return cached data immediately (even if expired), then refresh the cache silently in the background. The next call will get fresh data. |
networkOnly |
Never read from or write to the cache. |
cacheOnly |
Never hit the network — return cached data or a failure response if no entry exists. |
final res = await http.get<List<Post>>(
endpoint: '/posts',
strategy: CacheStrategy.staleWhileRevalidate,
);
print(res.isStale); // true if the entry was expired when served
print(res.cacheHitCount); // how many times this exact entry has been served from cache
Server
Cache-Controlheaders are honoured by default (respectServerCacheHeaders: true). The server'smax-ageoverridescacheDuration.no-store/no-cachebypass the cache for that response.
Request Queue & Cancellation #
Every request passes through an internal priority queue before execution.
Cancellation tokens #
final token = CancellationToken();
// Pass the token to any HTTP method:
final res = await http.get<User>(
endpoint: '/users/1',
cancelToken: token,
tag: 'profile', // group tag — used with cancelGroup()
priority: RequestPriority.high, // high | normal (default) | low
);
// Cancel this specific token:
token.cancel('User navigated away');
// token.isCancelled is now true
// token.reason == 'User navigated away'
// Cancel all requests sharing a tag:
http.cancelGroup('profile');
// Cancel every pending and in-flight request:
http.cancelAll();
Important: A cancelled request throws
RequestCancelledException, which is not caught insideFlResponse. Wrap the call withtry/catchor.catchError()if needed.
Queue control #
http.pauseQueue(); // in-flight requests continue; new requests are queued
http.resumeQueue(); // releases all queued requests
// Real-time queue stats stream (use in a StreamBuilder for a loading badge):
http.queueStatus.listen((s) {
print('pending: ${s.pending} active: ${s.active} '
'done: ${s.completed} cancelled: ${s.cancelled}');
});
Queue configuration #
FlHttpConfig(
maxConcurrentRequests: 4, // max simultaneous in-flight requests
maxQueueSize: 100, // throws QueueFullException beyond this
queueTimeout: Duration(seconds: 30), // auto-cancel requests waiting too long
deduplicateRequests: true, // coalesce identical pending requests into one call
)
Interceptors #
Interceptors are called in the order they appear in FlHttpConfig.interceptors for every request and response.
// ── Built-in: LoggingInterceptor ─────────────────────────────────────────────
// Logs every outgoing request and incoming response to the console.
LoggingInterceptor()
// ── Built-in: AuthRefreshInterceptor ─────────────────────────────────────────
// Intercepts 401 responses, calls onRefresh to get a new token, updates the
// Authorization header, and the caller transparently receives the retry result.
AuthRefreshInterceptor(
onRefresh: () async {
final newToken = await authService.refreshToken();
return newToken; // return null to give up and let the 401 propagate
},
maxRefreshAttempts: 1, // prevent infinite refresh loops
)
// ── Custom interceptor ────────────────────────────────────────────────────────
class AppVersionInterceptor extends HttpInterceptor {
@override
Future<RequestContext> onRequest(RequestContext ctx) async {
ctx.headers['X-App-Version'] = '2.0.0';
return ctx; // always return ctx — even if you didn't change anything
}
@override
Future<ResponseContext> onResponse(ResponseContext ctx) async {
// Inspect or transform the response before the caller sees it.
return ctx;
}
@override
Future<FlResponse<T>?> onError<T>(
RequestContext request,
Object error,
StackTrace stackTrace,
) async {
// Return a FlResponse to recover from the error, or null to propagate it.
return null;
}
}
FlHttpConfig(
interceptors: [
LoggingInterceptor(),
AuthRefreshInterceptor(onRefresh: () async => await getToken()),
AppVersionInterceptor(),
],
)
Optional Encryption #
All cached response bodies are encrypted at rest using AES-256-GCM with a PBKDF2-HMAC-SHA256 derived key. Each entry uses a fresh random 96-bit IV — identical responses produce different ciphertexts.
FlHttpConfig(
enableEncryption: true,
encryptionKey: 'your-32-character-secret-key!!!!', // MUST be exactly 32 chars
)
⚠️ Never hard-code the encryption key. Read it at runtime from
flutter_secure_storageor your secrets management service.
Key rotation (without clearing the cache) #
// 1. Re-initialise with the new key:
await FlSmartHttp.init(
baseUrl: 'https://api.example.com',
config: FlHttpConfig(
enableEncryption: true,
encryptionKey: 'new-32-character-secret-key!!!!',
),
reinitialise: true,
);
// 2. Re-encrypt all existing cache entries from the old key to the new one:
final count = await FlSmartHttp.instance.rotateEncryptionKey(
oldKey: 'old-32-character-secret-key!!!!!',
);
// Entries that cannot be decrypted with oldKey are automatically evicted.
print('Re-encrypted $count entries');
Secure entry point (optional, signals intent) #
// Identical API — the _secure variant just documents that encryption is in use.
import 'package:fl_smart_http/fl_smart_http_secure.dart';
Rate Limiting #
Requests that exceed the limit are delayed, not dropped.
FlHttpConfig(
maxRequestsPerSecond: 10, // max 10 requests in any rolling 1-second window
maxRequestsPerMinute: 100, // max 100 requests in any rolling 1-minute window
)
Both limits can be active simultaneously; the stricter window governs.
Offline Support #
When the device is offline, fl_smart_http falls back to cached data automatically.
FlHttpConfig(
serveStaleOnOffline: true, // serve expired entries when offline (default: true)
maxStaleAge: Duration(days: 7), // reject entries older than 7 days even when offline
// null (default) = serve any age
)
Check connectivity at any time:
print(FlSmartHttp.instance.isOnline); // current state
// Fires every time the device transitions between online and offline:
FlSmartHttp.instance.connectivityStream.listen((isOnline) {
if (isOnline) refetchData();
});
A stale response has res.isStale == true and res.source == DataSource.staleCache.
If the device was offline and no cache exists, res.statusCode == -1 and res.isFailure == true.
Cache Management #
final http = FlSmartHttp.instance;
// Invalidate a single endpoint (GET by default):
await http.invalidate('/users/1');
await http.invalidate('/users/1', method: 'POST'); // specify method if not GET
// Invalidate all entries whose cache key begins with a prefix:
final removed = await http.invalidateByPrefix('/users'); // returns count removed
// Wipe the entire cache:
await http.clearCache();
// Pre-seed the cache before the first network call:
await http.prewarm(
'/config',
'{"theme":"dark","maintenance":false}',
statusCode: 200,
duration: Duration(days: 7),
);
// The next get('/config') will be served from cache instantly.
// Migrate entries from a previous Hive box (e.g. after renaming hiveBoxName):
final migrated = await FlSmartHttp.migrateCache('old_box_name', config);
// Diagnostics — useful for a debug screen:
final stats = http.diagnostics();
// Keys: totalEntries, expiredEntries, validEntries, approximateSizeBytes,
// maxEntries, baseUrl, strategy, encryptionEnabled, isOnline, queue, ...
DI Integration #
FlSmartHttp implements IFlSmartHttp. Depend on the interface in your business logic so unit tests can inject a mock.
Provider #
// main.dart
final http = await FlSmartHttp.init(baseUrl: 'https://api.example.com');
runApp(Provider<IFlSmartHttp>.value(value: http, child: const MyApp()));
// In a widget:
final http = context.read<IFlSmartHttp>();
final res = await http.get<User>(endpoint: '/me');
Riverpod #
final flHttpProvider = FutureProvider<IFlSmartHttp>((ref) async {
final http = await FlSmartHttp.init(baseUrl: 'https://api.example.com');
ref.onDispose(() => http.dispose());
return http;
});
// Usage:
final res = await ref.read(flHttpProvider.future)
.then((http) => http.get<User>(endpoint: '/me'));
GetIt #
await GetIt.I.registerSingletonAsync<IFlSmartHttp>(
() => FlSmartHttp.init(baseUrl: 'https://api.example.com'),
);
await GetIt.I.allReady();
final http = GetIt.I<IFlSmartHttp>();
// Named instance for a second base URL:
await GetIt.I.registerSingletonAsync<IFlSmartHttp>(
() => FlSmartHttp.create(
baseUrl: 'https://auth.example.com',
config: FlHttpConfig(hiveBoxName: 'auth_cache'),
),
instanceName: 'auth',
);
final authHttp = GetIt.I<IFlSmartHttp>(instanceName: 'auth');
Bloc #
class UserBloc extends Bloc<UserEvent, UserState> {
final IFlSmartHttp _http;
UserBloc(this._http) : super(UserInitial()) {
on<LoadUser>(_onLoadUser);
}
Future<void> _onLoadUser(LoadUser event, Emitter<UserState> emit) async {
emit(UserLoading());
final res = await _http.get<User>(endpoint: '/users/${event.id}');
if (res.isSuccess) {
emit(UserLoaded(res.data!));
} else {
emit(UserError(res.errorMessage ?? 'Unknown error'));
}
}
}
BlocProvider(create: (ctx) => UserBloc(ctx.read<IFlSmartHttp>()))
GetX #
// AppBindings:
Get.putAsync<IFlSmartHttp>(
() => FlSmartHttp.init(baseUrl: 'https://api.example.com'),
permanent: true,
);
// Controller:
class UserController extends GetxController {
final IFlSmartHttp _http = Get.find();
final user = Rxn<User>();
Future<void> loadUser(int id) async {
final res = await _http.get<User>(endpoint: '/users/$id');
if (res.isSuccess) user.value = res.data;
}
}
Multiple isolated clients #
// Each client has its own Hive box and independent configuration.
final mainHttp = await FlSmartHttp.init(
baseUrl: 'https://api.example.com',
config: FlHttpConfig(hiveBoxName: 'main_cache'),
);
final authHttp = await FlSmartHttp.create(
baseUrl: 'https://auth.example.com',
config: FlHttpConfig(hiveBoxName: 'auth_cache', defaultCacheDuration: Duration(minutes: 5)),
);
// Dispose when done (e.g. on logout):
await mainHttp.dispose();
await authHttp.dispose();
FlSmartHttp.init()sets the global singleton accessed viaFlSmartHttp.instance.
FlSmartHttp.create()creates an isolated instance — it does not affect the singleton.
Testing with Mocks #
// test/mocks.dart
import 'package:mocktail/mocktail.dart';
import 'package:fl_smart_http/fl_smart_http.dart';
class MockFlSmartHttp extends Mock implements IFlSmartHttp {}
// test/user_bloc_test.dart
void main() {
late MockFlSmartHttp mockHttp;
late UserBloc bloc;
setUpAll(() {
// mocktail requires fallback values for custom types used as named params
registerFallbackValue(CancellationToken());
registerFallbackValue(RequestPriority.normal);
});
setUp(() {
mockHttp = MockFlSmartHttp();
bloc = UserBloc(mockHttp);
});
test('emits UserLoaded when get succeeds', () async {
when(() => mockHttp.get<User>(endpoint: '/users/1'))
.thenAnswer((_) async => FlResponse.success(
statusCode: 200,
rawBody: '{"id":1,"name":"Alice","email":"a@b.com"}',
headers: const {},
source: DataSource.network,
cacheKey: 'k',
elapsed: Duration.zero,
data: const User(id: 1, name: 'Alice', email: 'a@b.com'),
));
bloc.add(LoadUser(1));
await expectLater(
bloc.stream,
emitsInOrder([isA<UserLoading>(), isA<UserLoaded>()]),
);
});
test('emits UserError when get fails', () async {
when(() => mockHttp.get<User>(endpoint: '/users/99'))
.thenAnswer((_) async => FlResponse.failure(
cacheKey: 'k',
elapsed: Duration.zero,
errorMessage: 'Not found',
statusCode: 404,
));
bloc.add(LoadUser(99));
await expectLater(
bloc.stream,
emitsInOrder([isA<UserLoading>(), isA<UserError>()]),
);
});
}
FlResponse Reference #
FlResponse<T> is the unified return type for every HTTP call. It never throws.
| Property | Type | Description |
|---|---|---|
isSuccess |
bool |
true when statusCode is 200–299 |
isFailure |
bool |
Opposite of isSuccess |
statusCode |
int |
HTTP status code; -1 for local errors (offline, timeout, parse failure) |
data |
T? |
Parsed model instance; null if no factory registered or parsing failed |
rawBody |
String |
Raw response body — always available regardless of T |
json |
dynamic |
jsonDecode(rawBody), or null if the body is not valid JSON |
headers |
Map<String, String> |
HTTP response headers |
source |
DataSource |
network, cache, or staleCache |
isFromCache |
bool |
true for both cache and staleCache |
isStale |
bool |
true only for staleCache (expired entry served while offline) |
isOfflineResponse |
bool |
true when isStale && statusCode == -1 |
elapsed |
Duration |
Total time from enqueue to completion |
expiresAt |
DateTime? |
When the cache entry expires; null for non-cached responses |
cacheHitCount |
int |
Times this entry has been served from cache; 0 = first time |
cacheKey |
String |
SHA-256 cache key used (pass to http.invalidate() for manual removal) |
errorMessage |
String? |
Human-readable error; non-null only when isFailure |
exception |
Object? |
Original exception, if any |
contentType |
String? |
Shorthand for headers['content-type'] |
mapData<R>(factory) |
FlResponse<R> |
Transform data to another type without a new network call |
FlHttpConfig Reference #
All fields are optional and have production-safe defaults — only set what you need.
| Parameter | Type | Default | Description |
|---|---|---|---|
defaultCacheDuration |
Duration |
1h |
TTL for cached responses (override per-call with cacheDuration) |
defaultRetryAttempts |
int |
3 |
Retry count (override per-call with retryPolicy) |
retryBaseDelay |
Duration |
500ms |
Base retry delay; doubles each attempt |
retryMaxDelay |
Duration |
30s |
Cap on retry delay |
retryJitter |
bool |
true |
±20% random jitter prevents thundering-herd |
retryOnStatusCodes |
Set<int> |
{408,429,500,502,503,504} |
HTTP codes that trigger a retry |
retryOnException |
bool |
true |
Retry on socket / timeout exceptions |
defaultStrategy |
CacheStrategy |
cacheFirst |
Default caching strategy (override per-call with strategy) |
varyHeaders |
List<String> |
[] |
Headers factored into the cache key — use ['Authorization'] for per-user caches |
cacheableMethods |
Set<String> |
{'GET'} |
Methods whose responses are cached |
cacheableStatusCodes |
Set<int> |
{200,203,204,206,300,301} |
Status codes whose responses are cached |
respectServerCacheHeaders |
bool |
true |
Honour Cache-Control: max-age, no-store, no-cache |
serveStaleOnOffline |
bool |
true |
Serve expired cache when offline |
maxStaleAge |
Duration? |
null |
Max age of a stale entry to serve offline; null = no limit |
enableEncryption |
bool |
false |
Encrypt cached payloads with AES-256-GCM |
encryptionKey |
String? |
null |
Exactly 32 characters. Required when enableEncryption: true |
pbkdf2Salt |
String |
'_fl_http_salt_v1_' |
PBKDF2 salt — override per app flavour or environment |
pbkdf2Iterations |
int |
10000 |
PBKDF2 iterations — higher = more secure but slower key derivation |
useIsolates |
bool |
true |
Parse large responses in a Dart isolate (prevents UI jank) |
isolateSizeThreshold |
int |
51200 |
Minimum body size in bytes to trigger isolate parsing |
maxCacheEntries |
int |
500 |
LRU eviction threshold — oldest-accessed entries are removed first |
maxResponseSizeBytes |
int |
5242880 |
Responses larger than this are not cached |
connectionTimeout |
Duration |
15s |
TCP connection timeout |
receiveTimeout |
Duration |
30s |
Response read timeout |
maxConcurrentRequests |
int |
4 |
Max simultaneous in-flight requests |
maxQueueSize |
int |
100 |
Max pending requests; throws QueueFullException beyond this |
queueTimeout |
Duration? |
null |
Auto-cancel requests waiting longer than this |
deduplicateRequests |
bool |
false |
Coalesce identical pending requests into one network call |
maxRequestsPerSecond |
int? |
null |
Rate limit per second; null = unlimited |
maxRequestsPerMinute |
int? |
null |
Rate limit per minute; null = unlimited |
interceptors |
List<HttpInterceptor> |
[] |
Executed in order for every request/response |
onAllRetriesFailed |
void Function(String, int, Object)? |
null |
Called when all retries are exhausted; args: endpoint, attempts, error |
onCacheLimitWarning |
void Function(int, int)? |
null |
Called when entries exceed 90% of maxCacheEntries; args: current, max |
logLevel |
FlLogLevel |
error |
none / error / info / verbose |
onLog |
void Function(FlLogLevel, String)? |
null |
Custom log handler; falls back to debugPrint |
defaultHeaders |
Map<String, String> |
{'Content-Type':'application/json','Accept':'application/json'} |
Merged into every request |
hiveBoxName |
String |
'fl_smart_http_v1' |
Hive box name — use a unique name per isolated client |
FlHttpConfig is const-constructable and fully supports copyWith().
Platform Support #
| Platform | Caching | Encryption | Isolates |
|---|---|---|---|
| Android | ✅ | ✅ | ✅ |
| iOS | ✅ | ✅ | ✅ |
| macOS | ✅ | ✅ | ✅ |
| Linux | ✅ | ✅ | ✅ |
| Windows | ✅ | ✅ | ✅ |
Note: Web platform is not supported due to
dart:isolatelimitations required for encryption.
License #
MIT — © 2026 RakeshGowdaR
