Wiring dio or package:http
A query function is any function that returns a Future, so the library does
not care how you talk to your server. The app does: a request the library has
cancelled should stop on the wire, a server that hangs should fail rather than
spin for ever, a 404 has to become an error (package:http does not throw for
one), and whatever reaches a screen should be a sentence a user can read, not a
DioException with a stack of HTTP detail. This recipe puts all of that in one
API client and keeps it out of the queries.
Every recipe in the cookbook's first half builds on the same small app: a product catalogue with a list, a search, a detail screen, an edit form and an endless feed. This page lays its foundation.
The finished code
The error type every layer above the transport sees:
/// Every failure the API layer throws: a sentence a user can read, and the
/// HTTP status when a response arrived.
class ApiException implements Exception {
const ApiException(this.message, {this.status});
final String message;
/// `null` when no response arrived at all — a timeout, no network.
final int? status;
/// The server knows who we are and said no, or does not know who we are.
bool get isAuth => status == 401 || status == 403;
/// The request itself was wrong; asking again will not help.
bool get isClientError => status != null && status! >= 400 && status! < 500;
String toString() => message;
}
/// A 422: the server refused the input, field by field.
class ValidationException extends ApiException {
const ValidationException(this.fieldErrors)
: super('Please correct the highlighted fields', status: 422);
/// Field name → what is wrong with it, as the server said it.
final Map<String, String> fieldErrors;
}
The client, on dio 5. It is the only file that imports dio:
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';
import 'api_exception.dart';
class ApiClient {
ApiClient({required String baseUrl, Dio? dio})
: dio = dio ??
Dio(BaseOptions(
baseUrl: baseUrl,
// A server that hangs must fail the query, not leave it
// fetching for ever.
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
final Dio dio;
Future<T> get<T>(
String path,
T Function(Object? json) parse, {
Map<String, Object?>? query,
QueryCancelToken? signal,
}) =>
_run(
() => dio.get<Object?>(
path,
queryParameters: query,
cancelToken: _bridge(signal),
),
parse,
);
Future<T> send<T>(
String method,
String path,
T Function(Object? json) parse, {
Object? body,
}) =>
_run(
() => dio.request<Object?>(
path,
data: body,
options: Options(method: method),
),
parse,
);
/// A query's cancellation, handed on to dio: when the library cancels the
/// fetch, dio aborts the request.
static CancelToken? _bridge(QueryCancelToken? signal) {
if (signal == null) return null;
final token = CancelToken();
signal.onCancel(token.cancel);
return token;
}
Future<T> _run<T>(
Future<Response<Object?>> Function() request,
T Function(Object? json) parse,
) async {
final Response<Object?> response;
try {
response = await request();
} on DioException catch (error) {
// Cancelled by the library: it already knows, and a CancelledError of
// its own wins. Nothing to translate.
if (CancelToken.isCancel(error)) rethrow;
throw _translate(error);
}
try {
return parse(response.data);
} on Object {
// A missing field or a wrong type is a server failure too, and a
// TypeError is no message for a user.
throw const ApiException('The server sent something unexpected');
}
}
static ApiException _translate(DioException error) {
final status = error.response?.statusCode;
final body = _decode(error.response?.data);
if (status == 422 && body is Map && body['errors'] is Map) {
return ValidationException(<String, String>{
for (final MapEntry(:key, :value) in (body['errors'] as Map).entries)
'$key': '$value',
});
}
if (body is Map && body['message'] is String) {
return ApiException(body['message'] as String, status: status);
}
return ApiException(
switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'The server is not responding',
DioExceptionType.badResponse => 'The server said no ($status)',
_ => 'The server is unreachable',
},
status: status,
);
}
/// dio hands an error body back parsed or as text, depending on its
/// content type; read both.
static Object? _decode(Object? data) {
if (data is! String) return data;
try {
return jsonDecode(data);
} on FormatException {
return null;
}
}
}
The calls the screens make, as an interface, and its dio implementation:
/// The calls the screens make. The query functions depend on this, not on
/// dio — so a test hands them a fake, and the transport can change.
abstract interface class ProductApi {
Future<List<Product>> list({String search = '', QueryCancelToken? signal});
Future<Product> get(String id, {QueryCancelToken? signal});
Future<ProductPage> page(int offset, {QueryCancelToken? signal});
/// Creates the product when [ProductDraft.id] is null, else updates it.
Future<Product> save(ProductDraft draft);
}
import 'package:query_kit_flutter/query_kit_flutter.dart';
import 'api_client.dart';
import 'models.dart';
import 'product_api.dart';
class DioProductApi implements ProductApi {
DioProductApi(this._client);
final ApiClient _client;
Future<List<Product>> list({String search = '', QueryCancelToken? signal}) =>
_client.get(
'/products',
(json) => <Product>[
for (final item in json! as List<Object?>)
Product.fromJson(item! as Map<String, Object?>),
],
query: <String, Object?>{if (search.isNotEmpty) 'q': search},
signal: signal,
);
Future<Product> get(String id, {QueryCancelToken? signal}) => _client.get(
'/products/$id',
(json) => Product.fromJson(json! as Map<String, Object?>),
signal: signal,
);
Future<ProductPage> page(int offset, {QueryCancelToken? signal}) =>
_client.get(
'/products',
(json) {
final body = json! as Map<String, Object?>;
return ProductPage(
items: <Product>[
for (final item in body['items']! as List<Object?>)
Product.fromJson(item! as Map<String, Object?>),
],
nextOffset: body['nextOffset'] as int?,
);
},
query: <String, Object?>{'offset': offset, 'limit': 20},
signal: signal,
);
Future<Product> save(ProductDraft draft) => _client.send(
draft.id == null ? 'POST' : 'PUT',
draft.id == null ? '/products' : '/products/${draft.id}',
(json) => Product.fromJson(json! as Map<String, Object?>),
body: <String, Object?>{'name': draft.name, 'price': draft.price},
);
}
And the keys the queries use, in one place:
/// Every key of the products feature, in one place.
abstract final class ProductKeys {
static final QueryKey all = QueryKey(const <Object?>['products']);
/// Every list, whatever it was searched for — the prefix a write
/// invalidates.
static final QueryKey lists = all.append(const <Object?>['list']);
static QueryKey list({String search = ''}) => lists.append(<Object?>[search]);
static QueryKey detail(String id) => all.append(<Object?>['detail', id]);
static final QueryKey feed = all.append(const <Object?>['feed']);
}
The app hands the implementation to its widgets once, in main:
import 'package:flutter/material.dart';
import 'app/app.dart';
import 'data/api_client.dart';
import 'data/dio_product_api.dart';
void main() {
final api = DioProductApi(ApiClient(baseUrl: 'https://api.example.com/v1'));
runApp(CatalogueApp(api: api));
}
CatalogueApp is the root widget from
A global error snackbar: a
ProductApiScope (a plain InheritedWidget holding the ProductApi), the
QueryClientProvider and the MaterialApp. Any dependency-injection tool does
the same job as ProductApiScope.
How it works
- The query functions depend on
ProductApi, not on dio. A query'squeryFncallsapi.list(signal: context.signal)and never sees an HTTP type. That is what lets Testing a screen hand the same screens a fake that answers from memory, and what would let you swap dio forpackage:httpwithout touching a query. - Cancellation crosses the bridge in
_bridge. Every query function is handed aQueryCancelTokenascontext.signal.signal.onCancel(token.cancel)hands the library's cancel on to a dioCancelToken, so when the library cancels a fetch —cancelQuerieswas called, or the last reader left while it ran, as when a newer search replaces an older one — dio aborts the request. (Readingcontext.signalis what allows the second case: a query function that never reads it is left to finish when its readers go.)onCancelruns the callback at once if the signal is already cancelled. - A cancelled request is rethrown untouched. When dio reports
CancelToken.isCancel(error), the library cancelled the fetch itself and already knows. Translating that into anApiExceptionwould turn a quiet cancel into a failure a toast could report. - Everything else becomes an
ApiException. A 422 with field errors becomes aValidationException(the form in Forms and server validation reads it), a body with amessagekeeps the server's wording, and the rest is a sentence per failure kind. Thestatussurvives, so the retry policy in Auth and token refresh can refuse to repeat a 4xx. - A parse failure is a server failure.
parseruns inside the client, so a missing field or a wrong type fails the query with a readable message instead of aTypeError. - Timeouts are set on
BaseOptions. Without areceiveTimeout, a server that accepts the connection and never answers leaves the query fetching — the library has no timeout of its own, as TanStack Query has none.
The same client on package:http
package:http 1.5 and later can abort a request: send an AbortableRequest
and complete its abortTrigger. A QueryCancelToken has a future that
completes on cancel, whenCancelled, which is exactly that trigger:
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:query_kit_flutter/query_kit_flutter.dart';
import 'api_exception.dart';
class HttpApiClient {
HttpApiClient({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
/// No trailing slash, as for dio: `https://api.example.com/v1`, and paths
/// start with one, `/products`. (`Uri.resolve` would drop the `/v1`.)
final String baseUrl;
final http.Client _client;
Future<T> get<T>(
String path,
T Function(Object? json) parse, {
Map<String, String>? query,
QueryCancelToken? signal,
}) async {
final url = Uri.parse('$baseUrl$path');
final request = http.AbortableRequest(
'GET',
query == null ? url : url.replace(queryParameters: query),
// http 1.5 and later: the request is aborted when this completes.
abortTrigger: signal?.whenCancelled,
);
final http.Response response;
try {
// The timeout covers waiting for the headers and reading the body.
response = await _client
.send(request)
.then(http.Response.fromStream)
.timeout(const Duration(seconds: 30));
} on http.RequestAbortedException {
rethrow; // cancelled by the library, which already knows
} on http.ClientException {
throw const ApiException('The server is unreachable');
} on TimeoutException {
throw const ApiException('The server is not responding');
}
// package:http does not throw for a 404 or a 500 — a status is just a
// number on a response. A query only fails if its function throws.
if (response.statusCode >= 400) {
throw ApiException(
'The server said no (${response.statusCode})',
status: response.statusCode,
);
}
try {
return parse(jsonDecode(response.body));
} on Object {
throw const ApiException('The server sent something unexpected');
}
}
}
Traps
package:httpdoes not throw for an error status. A 404 or a 500 is a response like any other, and a query only fails when its function throws. A client that returnsjsonDecode(response.body)without checking the status hands an error page tofromJson, or, worse, succeeds with it..timeoutstops waiting; it does not stop the request. Onpackage:httpthe request goes on until the server answers or the client is closed. For a real deadline on dio, use its timeouts, which do abort.- Before
package:http1.5 there is no abort. On an older version the request runs to the end on the wire whatever the library does. An explicit cancel (cancelQueries) still puts the entry back and drops the late answer; a query function that never readscontext.signalis not even cancelled when its last reader leaves — the request finishes and its answer is cached. Upgrade rather than work around it. - Do not create a
CancelTokenper client. One shared token cancels every request the client ever makes. Make one per call, from that call's signal, as_bridgedoes. - A mutation's signal is opt-in.
mutationFnreceives only the variables, which is whysendtakes nosignalhere. A write that can be cancelled usesmutationFnWithContext, whose context carries a signal to bridge the same way — see Cancelling mutations.
Variations
- Several backends. One
ApiClientper base URL, and oneProductApi-style interface per feature. The queries still only see interfaces. - Auth headers and token refresh go into a dio interceptor on the same
Dio— see Auth and token refresh. - Logging. dio's
LogInterceptoron the client, not aprintin each query function.
The same split: a query function receives an AbortSignal and passes it to
fetch or axios. Here the signal is a QueryCancelToken, and the one-line
bridge in _bridge plays the part that signal plays for axios. The
showcase
and the task manager
both wire dio this way.
See also
- Query cancellation — what the library does when it cancels, and what the signal adds.
- Query functions — what a query function is handed and what it may throw.
- Query keys — why the keys are a hierarchy.