fl_smart_http

pub.dev License: MIT Flutter

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

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-Control headers are honoured by default (respectServerCacheHeaders: true). The server's max-age overrides cacheDuration. no-store / no-cache bypass 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 inside FlResponse. Wrap the call with try/catch or .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_storage or 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 via FlSmartHttp.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:isolate limitations required for encryption.


License

MIT — © 2026 RakeshGowdaR

Libraries

fl_smart_http
fl_smart_http
fl_smart_http_secure
fl_smart_http — Secure entry point.