api_result_kit
A typed Result and error-handling kit for Flutter apps built on Dio.
It turns this:
try {
final response = await dio.get('/profile');
final user = UserModel.fromJson(response.data);
setState(() => this.user = user);
} on DioException catch (e) {
// now go figure out by hand whether this was a timeout, a 401,
// a FastAPI validation error, or something else entirely
}
into this:
final result = await dio.safeRequest(
() => dio.get('/profile'),
(data) => UserModel.fromJson(data),
);
or, if you want it fully automatic with zero code at the call site at all, into this:
final response = await dio.get('/profile'); // plain Dio, nothing special
with retry, error normalization, and a themed error snackbar all happening behind the scenes, because you attached SafeDioInterceptor once when you built your Dio instance.
No freezed, no build_runner, no code generation. Just plain Dart classes and Dart 3 pattern matching.
Table of contents
- Why this exists
- Installation
- Quick start
- The data layer
- Calling your API
- Showing errors automatically
- Rendering results in your widgets
- RichSnackbar
- Supporting your own backend shape
- What this package does not do
- Full status code reference
- Testing
- License
Why this exists
Every Flutter app that talks to a REST API ends up rewriting the same three things, over and over, screen after screen:
- A
try/catcharound every Dio call. - Code that looks at the failure and decides: was that a timeout, a 401, a validation error, a 500? Each backend framework answers this differently. FastAPI sends
{"detail": ...}. Express apps often send{"message": ...}or{"errors": {...}}. Plain network failures do not even have a response body to inspect. - Code that decides what the user should see: a snackbar, an inline form error, a redirect to the login screen, or nothing at all.
This package does all three, once, so your screens do not have to.
It is intentionally not a state-management library. It does not replace Bloc, Riverpod, Provider, or setState. ApiResult<T> is just an immutable value, the same way a Bloc state or a Riverpod AsyncValue is a value. You hold it however you already hold state in your app; this package only worries about producing that value correctly and consistently from a network call.
Installation
Add the package and its peer dependencies to your pubspec.yaml. The version numbers below are illustrative; use whatever versions your own pubspec.yaml actually resolves to, since this README was written without sight of the package's real pubspec.yaml:
dependencies:
api_result_kit: ^0.1.0
dio: ^5.0.0
flutter_animate: ^4.0.0
flutter_svg: ^2.0.0
dio is required because this package builds directly on top of it. flutter_animate powers the snackbar entrance and exit animations, and flutter_svg is only needed if you use SnackbarImage.svgAsset or SnackbarImage.svgNetwork for a snackbar's leading visual; both are pulled in automatically as soon as you import RichSnackbar.
Then:
import 'package:api_result_kit/api_result_kit.dart';
That single import gives you everything described in this document.
Quick start
The fastest path to a fully working setup, the kind described as "one tap" in this package's own design goals, is three steps.
Step one, in your root widget, register the navigator key once. This lets background code, like the interceptor below, find a BuildContext to show UI on, without you having to thread context through every API call:
MaterialApp(
navigatorKey: ApiResultKitNavigator.key,
home: const HomeScreen(),
);
Step two, wherever you build your Dio instance, attach the interceptor once:
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.interceptors.add(
SafeDioInterceptor(
dio: dio,
onApiError: showDefaultErrorSnackbar,
),
);
Step three, call Dio exactly the way you always have, anywhere in your app:
final response = await dio.get('/profile');
From here on, every request made through that Dio instance automatically retries transient failures, normalizes whatever error shape the backend sent, and shows a themed snackbar on final failure, with nothing extra written at the call site.
If you would rather get back a typed value instead of a thrown exception, for example because you want to render a success or failure state in your widget tree, use the extension methods described in Calling your API instead of, or alongside, the interceptor.
The data layer
ApiResult
ApiResult<T> is a sealed class with exactly two variants: ApiSuccess<T>, holding a value of type T, and ApiFailure<T>, holding an ApiException. Because it is a sealed class, Dart's switch can check at compile time that you have handled both cases.
final ApiResult<UserModel> result = await dio.safeRequest(
() => dio.get('/profile'),
(data) => UserModel.fromJson(data),
);
Construct one directly when you need to, for example in a test or when wrapping a non-Dio source of truth:
ApiResult<int>.success(42);
ApiResult<int>.failure(
const ApiException(type: ApiErrorType.network, message: 'No connection'),
);
Read the outcome with when, which forces you to handle both branches:
final message = result.when(
success: (user) => 'Welcome, ${user.name}',
failure: (error) => 'Could not load profile: ${error.message}',
);
Or with maybeWhen, when you only care about one branch and want a shared fallback for the other:
final greeting = result.maybeWhen(
success: (user) => 'Welcome, ${user.name}',
orElse: () => 'Welcome back',
);
Transform a successful value without unwrapping it manually, using map. A failure passes straight through untouched:
final ApiResult<String> nameResult = result.map((user) => user.name);
Chain another asynchronous, result-returning operation onto a success using flatMap. If the original result was already a failure, the chained operation is never called, and the original failure is returned unchanged:
final ApiResult<List<Post>> postsResult = await result.flatMap(
(user) => dio.safeRequest(
() => dio.get('/users/${user.id}/posts'),
(data) => (data as List).map(Post.fromJson).toList(),
),
);
Run a side effect, like logging or analytics, without altering the result and without breaking a method chain, using tap. It returns the same instance it was called on:
final logged = result.tap(
onSuccess: (user) => analytics.log('profile_loaded', {'id': user.id}),
onFailure: (error) => analytics.log('profile_load_failed', {'type': error.type.name}),
);
Reach for the raw value or error directly with dataOrNull and errorOrNull when a full pattern match would be overkill, for example inside a simple if check:
if (result.dataOrNull case final user?) {
print('Cached user: ${user.name}');
}
isSuccess and isFailure are also available as plain booleans for the same kind of quick check.
Both variants implement value-based equality and hashCode, comparing by their wrapped data or error. This matters if you put ApiResult<T> directly into a Bloc state or a Riverpod provider, since equality is how those frameworks decide whether to skip a rebuild.
ApiException
ApiException is the single, normalized shape every error takes by the time it reaches your widgets, no matter what the backend originally sent or what kind of network failure occurred.
class ApiException implements Exception {
final ApiErrorType type;
final String message;
final int? statusCode;
final Map<String, List<String>>? fieldErrors;
final Object? raw;
final bool retryable;
}
message is always a short, human-readable string that is safe to show directly in your UI. You never need to write your own copy for "no internet connection" or "session expired"; this package already has it, and you can override it per error type if you want different wording.
fieldErrors is populated only for validation errors, and maps a field name to the list of problems with it, for example {"email": ["Enter a valid email address"]}. Two convenience getters make this easier to use in a form:
if (error.hasFieldErrors) {
final summary = error.flattenedFieldErrors;
// "email: Enter a valid email address\npassword: Too short"
}
raw keeps the original, un-normalized error or response body around, intended for logging and crash reporting, never for showing to the user.
retryable tells you whether trying the same request again is a reasonable thing to suggest. It is true for network failures and server errors (5xx, 408, 429), and false for validation, auth, and not-found errors, since retrying those without changing anything will just fail again.
suggestedDisplay maps the error's type to a recommended ErrorDisplayMode, covered next. It is a recommendation your widgets are free to ignore.
copyWith lets you adjust one or two fields while keeping the rest, which is mostly useful in tests:
final retried = error.copyWith(retryable: false);
A subtlety worth knowing if you ever compare two ApiException instances directly: equality and hashCode are based only on type, message, and statusCode. Two exceptions that differ only in fieldErrors, raw, or retryable are still considered equal. This is deliberate, since those three fields are metadata about how the error happened rather than what the error fundamentally is, but it is worth knowing if a test or a state-management equality check ever surprises you.
ApiErrorType
Every error is bucketed into exactly one of seven categories, regardless of which backend produced it.
| Type | Meaning |
|---|---|
network |
No connectivity, a DNS failure, a connect or receive timeout, or an untrusted certificate. |
auth |
HTTP 401 or 403. The session is invalid, or the user lacks permission. |
validation |
HTTP 400 or 422 with field-level problems attached, for example a bad email address. |
notFound |
HTTP 404. The requested resource does not exist. |
server |
HTTP 5xx, or 429 (rate limited). The backend itself failed or asked the client to slow down. |
cancelled |
The client cancelled the request itself, for example a search query that was superseded by a newer one. Almost never worth showing to the user. |
unknown |
Anything that does not cleanly map to the above. |
Each type has a recommended ErrorDisplayMode:
| ApiErrorType | suggestedDisplay |
|---|---|
network |
retrySnackbar |
server |
retrySnackbar |
auth |
fullScreenRedirect |
validation |
inline |
notFound |
inline |
cancelled |
silent |
unknown |
snackbar |
ApiErrorParser
ApiErrorParser.parse is the function that turns anything caught in a catch block into an ApiException. It is safe to call with literally anything; an unrecognized object falls back to ApiErrorType.unknown rather than throwing.
try {
await dio.get('/profile');
} catch (error) {
final apiError = ApiErrorParser.parse(error);
}
If you pass it an ApiException it already produced, it returns that same instance unchanged, so it is safe to call more than once on the same error as it passes through different layers of your app.
Out of the box it understands two backend conventions.
FastAPI:
{ "detail": "Invalid credentials" }
{ "detail": [ { "loc": ["body", "email"], "msg": "invalid email" } ] }
Express, and the same convention used by many other Node backends:
{ "message": "Invalid credentials" }
{ "errors": { "email": "must be a valid email" } }
{ "errors": { "email": ["must be a valid email", "already taken"] } }
If your backend uses something else entirely, see Supporting your own backend shape below.
Calling your API
There are three ways to call your API through this package, each suited to a different style of error handling. You can mix all three in the same app, even on the same Dio instance, since they all funnel through the same ApiErrorParser underneath.
safeRequest
The simplest option. Wraps a single call, returns an ApiResult<T> instead of throwing.
final result = await dio.safeRequest(
() => dio.post('/login', data: {'email': email, 'password': password}),
(data) => UserModel.fromJson(data as Map<String, dynamic>),
);
Use this when you want full control over what happens next, typically because you are storing the result in your own state container and rendering it with ApiResultBuilder or your own widget logic.
safeRequestWithRetry and RetryPolicy
The same idea, but it automatically retries a failed call according to a RetryPolicy, before finally giving up and returning an ApiResult<T>.failure.
final result = await dio.safeRequestWithRetry(
() => dio.get('/profile'),
(data) => UserModel.fromJson(data),
policy: const RetryPolicy(maxAttempts: 4, initialDelay: Duration(milliseconds: 300)),
);
RetryPolicy has four settings.
maxAttempts is the total number of attempts including the first one. The default, 3, means: try once, then retry up to two more times if the failure is retryable.
initialDelay is how long to wait before the first retry. The default is 500 milliseconds.
backoffFactor is the multiplier applied to the delay after every failed attempt, so the wait grows between retries instead of hammering a struggling server at a fixed interval. The default is 2.0, meaning the delay doubles each time: 500ms, then 1000ms, then 2000ms, and so on.
retryIf is an optional predicate that lets you decide for yourself whether a given error should be retried, completely overriding the error's own retryable flag rather than combining with it. Useful if you want to retry on a status code this package does not normally consider transient, or to refuse to retry something it normally would.
const RetryPolicy(
retryIf: (error) => error.type == ApiErrorType.server,
);
A genuine quirk worth knowing: setting backoffFactor to exactly 0 does not produce a zero delay. It is special-cased to behave as a constant delay equal to initialDelay, repeated for every attempt, rather than retrying instantly. If you want truly instant retries, set initialDelay to Duration.zero instead.
RetryPolicy.none is a ready-made constant for "exactly one attempt, no retries at all," equivalent to RetryPolicy(maxAttempts: 1). It is a convenient, explicit way to opt a specific call out of retrying.
SafeDioInterceptor: the one-tap setup
safeRequest and safeRequestWithRetry both require you to call them, by name, at every call site. SafeDioInterceptor removes that requirement entirely. Attach it once to a Dio instance, and every call made through that instance, written as completely ordinary Dio, gets the same retry and normalization behavior automatically.
final dio = Dio();
dio.interceptors.add(
SafeDioInterceptor(
dio: dio,
retryPolicy: const RetryPolicy(),
onApiError: (error) {
print('Request failed: ${error.message}');
},
onRetry: (options, attempt, maxAttempts) {
print('Retrying ${options.path}, attempt $attempt of $maxAttempts');
},
),
);
final response = await dio.get('/profile'); // ordinary Dio call, nothing extra
dio must be the exact same Dio instance the interceptor is attached to. This is required so that retries are re-issued through that instance directly, preserving its other interceptors, like auth headers or logging, and its base options, rather than going out through a separate, bare client.
retryPolicy controls retry behavior exactly the same way it does for safeRequestWithRetry, and defaults to RetryPolicy() if you do not provide one.
onApiError is called exactly once per request, with the final, normalized ApiException, but only after every retry attempt has been exhausted, or immediately if the error was never retryable to begin with. This is the place to centralize a global reaction to failures: a snackbar, a forced logout, a log line. If your callback itself throws, that exception is caught and discarded internally; it can never break the request pipeline or leak past the interceptor.
onRetry is called once before each retry attempt, with the request being retried and an (attempt, maxAttempts) pair, for example (2, 3) to mean "this is attempt 2 of 3 total." It is never called for the first attempt, only for retries, and is purely for observability, logging, or telemetry; it has no effect on whether the retry actually happens.
When every retry is exhausted, the DioException that ultimately reaches your catch block, if you have one, carries the normalized ApiException in its error field instead of Dio's own error details:
try {
await dio.get('/profile');
} on DioException catch (e) {
final apiError = e.error as ApiException;
print(apiError.type); // ApiErrorType.server, ApiErrorType.network, etc
}
If you do not want a try/catch at all, the next section covers showing errors automatically without writing one.
Showing errors automatically
This package offers two independent ways to automatically react to an error with UI: one keyed off a global navigator, the other keyed off a BuildContext you already have in hand. They solve the same problem from two different starting points, and the right one depends on whether you are using the interceptor or the extension methods.
ApiResultKitNavigator
A small, app-wide holder for a single GlobalKey<NavigatorState>. Code with no access to the widget tree, like a Dio interceptor running deep in your networking layer, cannot normally obtain a BuildContext to show a snackbar or trigger navigation. This key solves exactly that, and nothing else; every screen in your app keeps using its own local context completely as normal.
Set it up once, in your root widget:
MaterialApp(
navigatorKey: ApiResultKitNavigator.key,
home: const HomeScreen(),
);
After that, ApiResultKitNavigator.context returns the current top-level BuildContext, or null if the app has not finished mounting yet, or if you forgot to wire up the key.
showDefaultErrorSnackbar
A ready-made void Function(ApiException) you can hand straight to SafeDioInterceptor.onApiError, requiring ApiResultKitNavigator to already be wired up.
SafeDioInterceptor(
dio: dio,
onApiError: showDefaultErrorSnackbar,
);
It looks at the error's suggestedDisplay and shows the matching RichSnackbar variant: a "retry" style snackbar for network and server errors, a plain warning snackbar for unknown errors, and a longer-lived error snackbar for auth failures, since those tend to need the user's attention for a moment longer.
Two error types are deliberately skipped, on purpose, not by oversight: cancelled requests, since those are intentional and never worth interrupting the user about, and validation errors, since a global toast cannot show which form field actually has the problem. Those are expected to be rendered inline by whichever screen made the call, typically by inspecting result.errorOrNull?.fieldErrors directly or by using ApiResultBuilder.
If ApiResultKitNavigator.context is null, for example because the error happened before the app finished its first frame, this function does nothing rather than throwing.
Redirecting to login on auth errors
showDefaultErrorSnackbar shows a snackbar for auth errors, but it has no way to know your app's login route, so it cannot navigate there for you. withDefaultErrorSnackbar lets you keep the automatic snackbar while adding your own logic on top, in one line:
SafeDioInterceptor(
dio: dio,
onApiError: withDefaultErrorSnackbar((error) {
if (error.type == ApiErrorType.auth) {
ApiResultKitNavigator.key.currentState
?.pushNamedAndRemoveUntil('/login', (route) => false);
}
}),
);
ApiErrorHandler and safeRequestHandled
The second approach to automatic error UI, this one built around a BuildContext you already have, typically because you are calling it from inside a widget's event handler.
final result = await dio.safeRequestHandled(
context,
() => dio.get('/profile'),
(data) => UserModel.fromJson(data),
onRetry: _loadProfile,
);
result.maybeWhen(
success: (user) => setState(() => _user = user),
orElse: () {}, // failure UI was already shown for you
);
On failure, safeRequestHandled immediately calls ApiErrorHandler.handle(context, error, onRetry: onRetry), which shows the right UI based on the error's suggestedDisplay: a snackbar, a snackbar with a retry button, or a redirect, exactly like showDefaultErrorSnackbar does, except this version uses the context you passed in directly, rather than reading from the global navigator key, and it does know how to redirect, because you can configure exactly where.
Configure the auth redirect once, typically in main.dart, before your app's first frame:
ApiErrorHandler.onAuthFailure = (context) {
authStore.clearToken();
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
};
By default, with no configuration at all, onAuthFailure simply navigates to a named route called /login.
Two more static settings on ApiErrorHandler let you adjust the look app-wide: useRichSnackbar, true by default, which you can set to false to fall back to a plain Material SnackBar shown through ScaffoldMessenger instead, for the rare screen with no Overlay of its own; and snackbarPosition and snackbarLayout, which control where and how the rich snackbar appears by default.
Pass showErrorUi: false to opt a single call out of the automatic UI entirely, while still getting a normalized ApiResult<T> back, useful for a silent background prefetch where a failure should not interrupt anyone:
final result = await dio.safeRequestHandled(
context,
() => dio.get('/prefetch'),
(data) => CacheModel.fromJson(data),
showErrorUi: false,
);
Which approach should I use
If you are using SafeDioInterceptor and want errors to surface without writing anything at the call site at all, use ApiResultKitNavigator plus showDefaultErrorSnackbar, optionally wrapped in withDefaultErrorSnackbar for an auth redirect.
If you are calling safeRequest or safeRequestWithRetry directly inside a widget and you already have a context on hand, use safeRequestHandled instead, which reads more naturally at the call site and lets ApiErrorHandler redirect to your actual login route out of the box.
Both ultimately render the same kind of UI, choose the same way, based on the same ApiException.suggestedDisplay. Pick whichever one fits the shape of the code you are writing; there is no wrong choice, and nothing stops you from using the interceptor for most of the app and safeRequestHandled for a handful of screens that need finer control.
Rendering results in your widgets
ApiResultBuilder
Renders an ApiResult<T> declaratively, without writing if/else or a switch by hand in your build method.
ApiResultBuilder<UserModel>(
result: state.userResult,
onSuccess: (context, user) => UserProfileView(user: user),
onRetry: () => viewModel.reload(),
)
result is required, and is exactly what safeRequest or safeRequestWithRetry gave you. onSuccess is required, and receives the unwrapped data. onError is optional, and lets you fully replace the default error view; when you provide it, onRetry is ignored, since you are expected to wire up your own retry button inside your own error widget. onRetry, when onError is not provided, is passed straight through to DefaultErrorWidget.
This widget deliberately does not know anything about a loading state. It only ever renders the success or failure outcome already present in result. Pair it with whatever loading flag or enum your state-management approach already gives you, the same way you would pair a Bloc's loading state with its data state, rather than expecting this widget to grow a loading: parameter of its own.
DefaultErrorWidget
The fallback view ApiResultBuilder uses when you do not supply your own onError. It picks an icon based on the error's type, shows the error's message, shows flattenedFieldErrors underneath when present, and shows a retry button only when both onRetry is provided and the error's retryable flag is true.
You can also use it directly, completely independent of ApiResultBuilder, anywhere you already have an ApiException on hand:
DefaultErrorWidget(error: someError, onRetry: _reload)
RichSnackbar
A themeable, animated snackbar system built on an Overlay entry rather than ScaffoldMessenger, which means two things the stock Material SnackBar cannot do: it works above a Navigator with no Scaffold at all, and it can genuinely appear at the top of the screen, not only the bottom.
Basic usage
Five static constructors cover the common cases, all forwarding to the same underlying RichSnackbar.show.
RichSnackbar.success(context, title: 'Saved!');
RichSnackbar.error(
context,
title: 'Upload failed',
message: 'Check your connection and try again.',
);
RichSnackbar.warning(context, title: 'Low storage');
RichSnackbar.info(context, title: 'New version available');
RichSnackbar.show(
context,
title: 'Custom',
variant: SnackbarVariant.neutral,
);
Each variant comes with a default color, icon, and foreground color tuned for that intent, all theme-aware through the current ColorScheme, so they adapt correctly between light and dark mode without any extra work.
An action button is available on every constructor:
RichSnackbar.error(
context,
title: 'Upload failed',
actionLabel: 'Retry',
onAction: _retryUpload,
);
Position, layout, and animation
position is either SnackbarPosition.top or SnackbarPosition.bottom, defaulting to bottom.
layout controls the overall shape and size, with four options: floatingSmall, a compact pill with margin on all sides and a single line of text, and the default; floatingLarge, a bigger floating card with room for both a title and a subtitle message; bannerSmall, an edge-to-edge strip with no horizontal margin, single line; and bannerLarge, edge-to-edge with room for a title and subtitle.
animation controls the entrance and exit motion, with four lightweight options built on flutter_animate: slide, fade, scaleFade, and the default, slideFade. All of them are simple translate, fade, or scale effects; none use a physics simulation, so they stay cheap regardless of how often a snackbar appears.
duration controls how long the snackbar stays visible before it dismisses itself, defaulting to four seconds. Pass Duration.zero to require the user to dismiss it manually or by swiping, rather than ever auto-dismissing.
showProgress, true by default, draws a thin countdown bar along the snackbar's bottom edge so the user can see how much time is left before it disappears.
Images, assets, and SVGs
By default, the leading visual is a plain Material icon matched to the variant. Override it with any SnackbarImage:
RichSnackbar.show(
context,
title: 'Friend request',
message: 'Aanya wants to connect.',
image: SnackbarImage.network('https://example.com/avatar.png'),
layout: SnackbarLayout.floatingLarge,
);
Five constructors are available: SnackbarImage.icon(icon, color: ...) for a plain Material icon with an explicit tint; SnackbarImage.asset(path) for a raster image bundled with your app; SnackbarImage.network(url) for a raster image fetched over the network; SnackbarImage.svgAsset(path, color: ...) for a bundled SVG, optionally tinted; and SnackbarImage.svgNetwork(url, color: ...) for a network SVG, also optionally tinted. The two SVG variants require the flutter_svg dependency mentioned in Installation.
Dismissing and queuing
Every call to RichSnackbar.show, or any of the named variant constructors, returns a RichSnackbarHandle. Call dismiss() on it to close that specific snackbar early, for example once the asynchronous task it was reporting on has finished:
final handle = RichSnackbar.info(context, title: 'Uploading...', duration: Duration.zero);
await uploadFile();
handle.dismiss();
Snackbars are queued independently per position, so a top snackbar and a bottom snackbar can be visible at the same time without interfering with each other, while two requests for the same position queue politely, one after another, in the order they were requested.
RichSnackbar.clear() immediately clears every pending and visible snackbar, everywhere. Pass position: to clear only one edge of the screen instead:
RichSnackbar.clear(position: SnackbarPosition.top);
Supporting your own backend shape
The built-in FastAPI and Express parsing covers two conventions, not every possible API. If your backend sends errors in a different shape, register your own parser, which is consulted only when none of the built-in shapes match, so it never overrides the conventions this package already understands.
For a flat error message:
ApiErrorParser.addMessageParser((body) {
if (body is Map && body['custom_msg'] is String) {
return body['custom_msg'] as String;
}
return null; // not our shape, let the chain continue
});
For field-level validation errors:
ApiErrorParser.addFieldErrorParser((body) {
if (body is Map && body['violations'] is Map) {
final violations = body['violations'] as Map;
return violations.map(
(key, value) => MapEntry(key.toString(), [value.toString()]),
);
}
return null;
});
Register these once, early in your app's startup, typically in main.dart before runApp. They apply globally to every call made through ApiErrorParser.parse, which both safeRequest and SafeDioInterceptor use internally.
What this package does not do
It does not manage state. ApiResult<T> is a value you store, the same way you would store any other value, in a Bloc state, a Riverpod provider, a ValueNotifier, or a plain setState field. Nothing in this package cares which one you pick.
It does not model a loading state. Loading is something that happens before a result exists at all, so it is naturally outside what ApiResult<T> represents. Track it the way you already track any other boolean or enum in your chosen state-management approach.
It does not understand GraphQL, gRPC, or any transport other than Dio's HTTP calls. ApiErrorParser.parse works against DioException specifically, falling back to a generic unknown result for anything else.
It does not guess your backend's error shape beyond FastAPI and Express conventions. Anything else needs a custom parser, as shown above.
Full status code reference
| HTTP status | ApiErrorType | retryable |
|---|---|---|
| none (DNS failure, no connectivity, connect or receive timeout) | network |
yes |
| none (untrusted certificate) | network |
no |
| 400 | validation |
no |
| 401 | auth |
no |
| 403 | auth |
no |
| 404 | notFound |
no |
| 408 | network |
yes |
| 422 | validation |
no |
| 429 | server |
yes |
| 500 and above | server |
yes |
| anything else | unknown |
yes |
| request cancelled by the client | cancelled |
no |
Testing
The package ships with its own test suite covering ApiResult, ApiException, ApiErrorParser, RetryPolicy, and SafeDioInterceptor, including retry, backoff timing, and every supported error shape. Run it the usual way:
flutter test
If you are testing code that uses SafeDioInterceptor in your own app, swap in a scripted HttpClientAdapter rather than hitting a real network, the same approach the package's own tests use, so retries and failures are deterministic.
License
MIT, pending an actual LICENSE file in the repository. MIT is the conventional choice for a utility package like this one and is assumed here only as a placeholder; pick whichever license you actually intend to publish under and add the corresponding LICENSE file before running dart pub publish.
Libraries
- api_result_kit
- A typed Result/Error handling kit for Flutter apps.