simple_network_handler 2.0.1
simple_network_handler: ^2.0.1 copied to clipboard
A package for handling network errors with error registry and interceptors. Supports Dio and Supabase.
simple_network_handler #
Network error handling for Flutter, done once, in one place.
Declare what every status code on every endpoint means, and every call in your
app returns a typed Result — no try/catch in repositories, no stringly-typed
errors in cubits, no "you're offline" shown as a server error.
final result = await handler.safeCall(() => api.getUserById(5));
switch (result) {
case Ok(:final value): emit(Loaded(value));
case Err(:final failure): emit(Failed(failure));
}
The family:
| Package | Use it for |
|---|---|
simple_network_handler |
Dio / REST — registries, interceptors, retries, refresh, cancellation |
simple_network_handler_supabase |
Supabase — same patterns for Auth/Postgrest/Storage/Functions errors |
simple_network_handler_bloc |
flutter_bloc — request cancellation tied to cubit lifecycle |
simple_network_handler_core |
Shared types (Failure, Result, …) — re-exported by the packages above; you don't depend on it directly |
Dio-only apps never pull the Supabase SDK, and vice versa.
Installation #
dependencies:
simple_network_handler: ^2.0.0 # Dio/REST
simple_network_handler_supabase: ^2.0.0 # if you use Supabase
simple_network_handler_bloc: ^2.0.0 # if you use flutter_bloc
Setup in 4 steps #
1. Define failures #
A Failure is plain data. Mix in LocalizedFailure to give it user-facing
copy, colocated in the class:
class UserNotFoundFailure extends Failure with LocalizedFailure {
const UserNotFoundFailure();
@override
String getTitle(BuildContext context) => context.l10n.userNotFound;
@override
String getSubtitle(BuildContext context) => context.l10n.userNotFoundHint;
}
Generic error UI branches once:
if (failure is LocalizedFailure) {
showError(failure.getTitle(context));
} else {
showError(context.l10n.genericError);
}
Tip: declare
abstract class AppFailure extends Failure with LocalizedFailure {}and extend it everywhere for a compile-time guarantee that all your failures carry copy.
2. Declare the registry #
One place where "status X on endpoint Y" gains meaning:
class MyRegistry extends ErrorRegistry {
@override
ErrorModelRegistry get endpointRegistry => {
'*': { 500: (json) => ServerFailure.fromJson(json) },
'/api/users/{id}': { 404: (json) => const UserNotFoundFailure() },
};
@override
Failure get genericError => const GenericFailure();
@override
Failure get transportError => const OfflineFailure(); // see Offline below
}
Only genericError is required — endpointRegistry, dioRegistry,
generalRegistry and the rest all have defaults, so a registry can start at
three lines and grow.
In a hurry? DefaultErrorRegistry() ships mappings for 401/403/404/5xx,
timeouts and connection errors, with English copy and correct transport
typing. Use it as-is, then extend it when you need your own copy:
final handler = dio.installNetworkHandling(errorRegistry: DefaultErrorRegistry());
// later:
class MyRegistry extends DefaultErrorRegistry {
@override
ErrorModelRegistry get endpointRegistry => {
...super.endpointRegistry,
'/api/users/{id}': { 404: (json) => const UserNotFoundFailure() },
};
}
Registry getters are read once per handler/interceptor and cached (the endpoint keys are compiled into a lookup index), so declare them as literals — don't return something that changes over time.
Endpoint keys support {param} templates, matching your Retrofit paths 1:1.
Resolution order: exact path → template → * (query strings stripped,
leading slashes normalized). Non-JSON error bodies (empty 404s, HTML 502s)
still map — status-keyed factories receive an empty map.
3. Wire Dio — one call #
installNetworkHandling owns the interceptor ordering so you can't get it
wrong, and returns the handler wired to the same registry instance — the
two can never disagree. Everything except the registry is optional:
final sessionScope = CancellationScope();
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final handler = dio.installNetworkHandling(
errorRegistry: MyRegistry(),
refresh: RefreshTokenInterceptor(tokenStore: store, refreshToken: refresh),
retry: RetryInterceptor(),
logging: NetworkLogInterceptor(),
cancellation: sessionScope,
onFailure: (failure, error, stack) => Sentry.captureException(error),
);
Register dio and handler in your DI container together. (Constructing
NetworkHandler(registry) yourself still works — just pass it the same
registry instance the interceptors got.)
4. Inject the handler #
class UserRepository {
UserRepository(this._handler, this._api);
final NetworkHandler _handler;
final UserApi _api;
Future<Result<User>> getUser(int id) =>
_handler.safeCall(() => _api.getUserById(id));
}
That's the whole core loop. Everything below is optional capability.
Result #
Sealed type with two variants — exhaustive switch, or fold if you prefer:
result.fold(
(failure) => emit(Failed(failure)),
(user) => emit(Loaded(user)),
);
Helpers: isOk / isErr, valueOrNull / failureOrNull,
getOrDefault / getOrElse, map / flatMap / mapErr (and the async
mapAsync / flatMapAsync), the side-effect pair onOk / onErr, and
mapBusiness (below).
final name = (await repo.getUser(id)).getOrElse((f) => 'anonymous');
(await repo.getUser(id))
.onErr(logger.warn)
.onOk((user) => emit(Loaded(user)));
Offline & transport failures #
Connectivity problems are not business errors. Mark your offline failure with
TransportFailure, point transportError at it (both registries support it),
and use mapBusiness in repositories — it substitutes a feature-specific
failure for real server errors while letting transport (and cancellation)
failures pass through unchanged:
class OfflineFailure extends Failure with TransportFailure, LocalizedFailure { ... }
// Repository:
return result.mapBusiness(
const ProfileFailure(), // server said no → feature copy
(dto) => Ok(dto.toDomain()), // OfflineFailure survives untouched
);
// One global branch in the UI:
if (failure is TransportFailure) showOfflineBanner();
Defaults: the Dio side classifies connectionError and all timeouts as
transport; the Supabase side covers http.ClientException, TimeoutException
and SocketException. An explicit dioRegistry mapping always wins.
Token refresh #
Give it storage and a way to get a new token — queuing, single-flight, and replay are automatic:
RefreshTokenInterceptor(
tokenStore: MyTokenStore(), // 2-method interface
refreshRequest: RefreshRequest( // or: refreshToken: () async => ...
path: 'https://api.example.com/auth/refresh',
buildData: () async => {'refresh_token': await readRefreshToken()},
extractAccessToken: (r) => r.data['access_token'] as String?,
),
isTokenExpired: JwtExpiry.isExpired, // optional: refresh BEFORE the 401
excludedPaths: ['/auth/*', '/public/*'], // exact, {param}, or * patterns
onRefreshFailed: (e) => auth.forceLogout(),
)
On a 401 the interceptor refreshes once (concurrent 401s queue and reuse
it), persists the token, and silently re-sends the original request — your
code receives the success as if nothing happened. isTokenExpired (use the
bundled JwtExpiry.isExpired for JWTs) refreshes proactively and skips the
401 round-trip entirely. FormData uploads are replay-safe (cloned).
| Option | Default | |
|---|---|---|
triggerStatusCodes |
[401] |
statuses that trigger a refresh |
applyHeader |
Authorization: Bearer <t> |
how the token is attached |
httpClient |
internal (mirrors your Dio via the installer) | client used for refresh + replays |
Retries #
RetryInterceptor(
// all optional — defaults shown:
maxRetries: 3,
baseDelay: Duration(milliseconds: 300), // exponential + full jitter
maxDelay: Duration(seconds: 10), // cap for every delay
retryableStatusCodes: {429, 502, 503, 504},
retryableMethods: {'GET', 'HEAD', 'OPTIONS'}, // idempotent-only by default
respectRetryAfter: true, // server's Retry-After wins (capped)
waitForConnectivity: null, // see below
onRetry: (error, attempt, delay) {}, // observability
)
Cancelled requests are never retried; consumed Stream bodies are never
retried; FormData is cloned. Opt POST in only if you have idempotency keys.
Offline-aware: pass waitForConnectivity and connectivity-shaped failures
wait for the network to return (capped at maxDelay) instead of burning
retries while the device is offline:
waitForConnectivity: () => Connectivity()
.onConnectivityChanged
.firstWhere((r) => !r.contains(ConnectivityResult.none)),
Cancellation #
Three levels, in precedence order — per-call token, page scope, app scope:
// App-wide (sign-out): one call aborts everything in flight.
sessionScope.cancelAll('signed out');
// Per-page: bind the work; requests started inside — however deep in
// repositories — die with the page. No token plumbing.
final pageScope = CancellationScope();
Future<void> load() => pageScope.bind(() async { ...4 parallel requests... });
// in close()/dispose(): pageScope.cancelAll();
// Per-call: pass a CancelToken to the API method as usual — it always wins.
With flutter_bloc, the mixin makes the page case two words:
class ProfileCubit extends Cubit<ProfileState> with CancellableRequests {
Future<void> load() => cancellable(() async { ... });
// no field, no close() override — pop the page, requests abort
}
Cancelled requests surface as Err(RequestCancelledFailure()) — silent by
design: skipped by the onFailure observer, passed through mapBusiness,
never rendered. Scopes are reusable immediately after cancelAll.
Boundary: a raw
dio.getcancelled by a Dio-wide scope in the same synchronous block may slip through (Dio defers enrollment);bind-scoped work has no such window. Keep sign-outcancelAllin its own user action.
Observability & logging #
Every handler takes an onFailure observer — one line to wire your tracker.
Throwing observers never break error propagation; cancellations are skipped:
NetworkHandler(MyRegistry(),
onFailure: (f, e, s) => Sentry.captureException(e, stackTrace: s));
NetworkLogInterceptor logs one sanitized line per request and outcome
(durations included, Authorization/Cookie redacted), with optional
headers/bodies and a copy-pasteable redacted curl export:
--> GET https://api.example.com/users/5
<-- 200 GET https://api.example.com/users/5 (184ms)
It is debug-only by default (enabled: kDebugMode) so a release build
never prints URLs, timings or bodies — pass enabled: true for a triage
build. Large bodies are described rather than dumped: binary uploads log as
<5242880 bytes>, strings truncate at maxBodyLength.
NetworkHandler.enableDebugLogging is a separate, minimal safeCall-level
trace; use one or the other, not both.
Broken registry factories (a throwing fromJson) are never swallowed:
ErrorRegistry.onMappingError fires — loud in debug, reportable in release.
Per-call overrides & exception matching #
// One-off handling before the registry (result bypasses onFailure):
handler.safeCall(() => api.call(), onEndpointError: (dioErr) {
if (dioErr.response?.statusCode == 403) return const Err(NoSeatsFailure());
return null; // fall through
});
// Non-HTTP exceptions: exact type via generalRegistry, subclasses/predicates
// via matchers:
@override
List<ExceptionMatcher> get generalMatchers => [
ExceptionMatcher.whenType<MyBaseException>((e) => MyFailure(e.reason)),
];
Supabase #
Same shape, Supabase exceptions. SupabaseHandler.safeCall maps
AuthException (by semantic code, then statusCode, then message),
PostgrestException (by PGRST/Postgres code, then numeric status),
StorageException (by message), FunctionException (by status):
class MySupabaseRegistry extends SupabaseErrorRegistry {
@override
SupabaseAuthErrorRegistry get authErrorRegistry => {
'invalid_credentials': (e) => const InvalidCredentialsFailure(),
};
@override
SupabaseErrorCodeRegistry get postgrestErrorCodeRegistry => {
'PGRST116': (e, msg, code) => const RecordNotFoundFailure(),
};
@override
Failure get transportError => const OfflineFailure();
@override
Failure get genericError => const GenericSupabaseFailure();
}
final supabaseHandler = SupabaseHandler(MySupabaseRegistry());
Future<Result<Profile>> profile() => supabaseHandler.safeCall(
() => supabase.from('profiles').select().single(),
);
Ready-made failures ship in the package (InvalidCredentialsFailure,
RecordNotFoundFailure, SupabaseNetworkFailure, …). Note: the Supabase SDK
has no request cancellation — CancellableRequests is a no-op for Supabase
calls; guard late emits with isClosed.
Testing your app #
FakeNetworkHandler / FakeSupabaseHandler (from
package:simple_network_handler/testing.dart and the Supabase equivalent):
queue results, no Dio, no adapters, no registries:
final fake = FakeNetworkHandler();
final repo = UserRepository(fake, FakeApi());
fake.enqueueOk(UserDto(id: 1));
fake.enqueueErr(const UserNotFoundFailure());
expect((await repo.getUser(1)).isOk, isTrue);
expect((await repo.getUser(2)).failureOrNull, isA<UserNotFoundFailure>());
An empty queue throws a descriptive error; set defaultResult for blanket
scenarios ("everything is offline"). Queueing a value of the wrong type fails
with a message naming both types instead of a raw cast error.
Also available: enqueueOkAll([...]), remaining, reset() for shared
fixtures, and verifyDrained() to assert the code under test consumed
everything you queued.
Registry keys are strings, so a typo silently maps nothing. Catch it in a test by feeding the registry your API's path constants:
test('every registry key matches a real endpoint', () {
expect(MyRegistry().debugUnmatchedKeys(ApiPaths.all), isEmpty);
});
Migrating from 1.x #
Either<Failure, T>→Result<T>;Left/Right→Err/Ok.foldkeeps its argument order — fold-based call sites just rename types.- Registry factories return the
Failuredirectly:(json) => MyFailure().Right-recovery via the registry is gone — useonEndpointError. getTitle/getSubtitlemoved to theLocalizedFailuremixin: addwith LocalizedFailureto each failure class (bodies unchanged).- Static facades removed: construct
NetworkHandler(registry)/SupabaseHandler(registry)and inject them. - Supabase moved to
package:simple_network_handler_supabase. parsedEitherKey→parsedFailureKey;mapBusinessnow lives onResult.
Example #
The example/ app wires everything — installer, retry, logging,
session scope, the bloc mixin — with Retrofit, injectable, and flutter_bloc.