simple_network_handler 2.0.1 copy "simple_network_handler: ^2.0.1" to clipboard
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.get cancelled 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-out cancelAll in 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/RightErr/Ok. fold keeps its argument order — fold-based call sites just rename types.
  • Registry factories return the Failure directly: (json) => MyFailure(). Right-recovery via the registry is gone — use onEndpointError.
  • getTitle/getSubtitle moved to the LocalizedFailure mixin: add with LocalizedFailure to each failure class (bodies unchanged).
  • Static facades removed: construct NetworkHandler(registry) / SupabaseHandler(registry) and inject them.
  • Supabase moved to package:simple_network_handler_supabase.
  • parsedEitherKeyparsedFailureKey; mapBusiness now lives on Result.

Example #

The example/ app wires everything — installer, retry, logging, session scope, the bloc mixin — with Retrofit, injectable, and flutter_bloc.

2
likes
160
points
156
downloads

Documentation

API reference

Publisher

verified publisherkeep-it-simple.dev

Weekly Downloads

A package for handling network errors with error registry and interceptors. Supports Dio and Supabase.

Repository (GitHub)
View/report issues

Topics

#dio #network #error-handling #supabase

License

MIT (license)

Dependencies

dio, flutter, simple_network_handler_core

More

Packages that depend on simple_network_handler