Réseau et API REST
Dio, intercepteurs, refresh token concurrent, retry, erreurs typées, pagination, OpenAPI. C'est le chapitre où l'on juge un senior mobile face à des microservices derrière une API gateway : l'intercepteur de refresh est une question d'entretien phare, et elle se code au tableau.
Objectifs
- Expliquer idempotence, timeouts et codes HTTP du point de vue d'un client mobile.
- Configurer Dio 5 :
BaseOptions, intercepteurs,CancelToken, transformer, adapter. - Écrire de mémoire, en 45 minutes, un intercepteur de refresh token qui gère N requêtes concurrentes.
- Mettre en place un retry sûr (backoff, jitter,
Idempotency-Key) et un mapping d'erreurs typé. - Défendre le choix d'un client généré depuis OpenAPI face à un client Retrofit écrit à la main.
HTTP vu du mobile
Un client mobile vit sur un réseau hostile : latence variable, passage 4G / Wi-Fi, ascenseur, mode avion, proxy d'entreprise. Toutes les décisions de ce chapitre découlent d'une question : que se passe-t-il si la requête est envoyée deux fois, ou si la réponse n'arrive jamais ?
Verbes et idempotence
Une méthode est idempotente si l'exécuter une ou N fois laisse le serveur dans le même état. GET, HEAD, OPTIONS sont en plus sûres (sans effet de bord). PUT et DELETE sont idempotentes par contrat : remplacer une ressource deux fois par la même valeur, ou la supprimer deux fois, donne le même résultat. POST et PATCH ne le sont pas : deux POST /transfers font deux virements.
Le point clé pour le mobile : un timeout ne dit pas si le serveur a traité la requête. La réponse a pu se perdre au retour. On ne peut donc rejouer automatiquement que ce qui est idempotent, ou ce que l'on a rendu idempotent avec une clé (voir plus bas).
| Code | Sens pour le client | Réaction attendue |
|---|---|---|
| 200 / 201 / 204 | Succès (201 : créé, 204 : sans corps) | Mettre à jour l'état ; 204 : ne pas parser de corps |
| 304 | Pas modifié (cache ETag) | Servir la copie locale |
| 400 / 422 | Requête invalide / règle métier | Afficher le message, ne jamais rejouer |
| 401 | Non authentifié (jeton absent, expiré) | Refresh puis rejeu, ou déconnexion |
| 403 | Authentifié mais non autorisé | Pas de refresh : le jeton est valide, le droit manque |
| 404 / 409 | Introuvable / conflit de version | Message ; 409 : recharger et fusionner |
| 429 | Trop de requêtes (souvent l'API gateway) | Attendre Retry-After, puis rejouer |
| 500 | Erreur serveur | Message avec référence de support ; rejeu prudent |
| 502 / 503 / 504 | Passerelle, indisponibilité, timeout amont | Erreurs transitoires typiques : retry avec backoff |
En-têtes utiles
Authorization: Bearer <jeton>: le jeton d'accès, jamais dans l'URL (les URL finissent dans les logs de la gateway).Accept,Content-Type,Accept-Language(messages d'erreur localisés).X-Request-Id: identifiant de corrélation, voir la section API gateway.Idempotency-Key: rend unPOSTrejouable sans double effet.ETag/If-None-Match,Cache-Control: cache HTTP.Retry-After: délai imposé par le serveur sur 429 / 503.
Les trois timeouts
Dio distingue connectTimeout (établir la connexion TCP + TLS), sendTimeout (envoyer le corps, utile pour les uploads) et receiveTimeout (délai maximal entre deux paquets reçus, pas la durée totale). Un téléchargement lent mais régulier ne déclenche donc pas receiveTimeout. Le package http n'a qu'un timeout global, posé avec Future.timeout, qui n'annule pas la requête sous-jacente.
« Votre paiement a fait timeout, vous le relancez automatiquement ? » Réponse attendue : non, pas sans clé d'idempotence. Le timeout ne prouve pas l'échec côté serveur. Avec une Idempotency-Key stable, le rejeu est sûr : le serveur renvoie la transaction déjà créée.
Package http ou Dio 5
Le package http est minimal, maintenu par l'équipe Dart, et suffit pour quelques appels. Il expose une interface Client que l'on peut brancher sur des implémentations natives (cupertino_http, cronet_http). Dès que l'on a de l'authentification, des retries, des logs et des dizaines d'endpoints, on réécrit à la main ce que Dio fournit.
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> fetchWallet(http.Client client, String token) async {
final res = await client
.get(
Uri.parse('http://10.0.2.2:8080/api/v1/wallet'),
headers: {'Authorization': 'Bearer $token', 'Accept': 'application/json'},
)
.timeout(const Duration(seconds: 15)); // timeout global, pas par phase
if (res.statusCode != 200) throw http.ClientException('HTTP ${res.statusCode}', res.request?.url);
return jsonDecode(res.body) as Map<String, dynamic>;
}
| Besoin | http | Dio 5 |
|---|---|---|
| Intercepteurs (auth, logs, retry) | À écrire (wrapper de Client) | Natif, chaîne ordonnée |
| Timeouts par phase | Non | connect / send / receive |
| Annulation | Fermer le Client | CancelToken par requête |
| Progression upload / download | Manuel | onSendProgress, onReceiveProgress |
| Décodage JSON hors thread UI | Manuel | Transformer configurable |
| Pinning, proxy, HTTP/2 | Via IOClient | Via l'adapter |
| Génération de client | Rare | Retrofit, openapi-generator dart-dio, swagger_parser |
Les briques de Dio
- BaseOptions :
baseUrl, timeouts, en-têtes communs,responseType,validateStatus(quels codes sont des succès). - Interceptors :
onRequest,onResponse,onError. Chaque callback appellehandler.next(continuer),handler.resolve(répondre à la place du réseau) ouhandler.reject(échouer). Un callback qui n'appelle aucun des trois bloque la requête. - QueuedInterceptor : même interface, mais Dio sérialise les appels : la requête suivante n'entre dans le callback que lorsque la précédente en est sortie.
- CancelToken : annule une requête (écran quitté, recherche remplacée). L'erreur produite est de type
DioExceptionType.cancel, à ne jamais afficher. - Transformer : sérialise la requête et décode la réponse. Le défaut actuel est
FusedTransformer, qui fusionne décodage UTF-8 et JSON ; son paramètrecontentLengthIsolateThresholdbascule le décodage dans un isolate au-delà d'une taille.BackgroundTransformerdécode systématiquement hors du thread principal. - HttpClientAdapter : la couche transport.
IOHttpClientAdapter(dart:io) sur mobile, aveccreateHttpClientetvalidateCertificatepour le pinning ; des adapters natifs existent (native_dio_adapter) pour utiliser la pile HTTP de la plateforme.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import 'auth_interceptor.dart';
import 'retry_interceptor.dart';
import 'token_store.dart';
const _uuid = Uuid();
BaseOptions _baseOptions(String baseUrl) => BaseOptions(
baseUrl: baseUrl, // http://10.0.2.2:8080/api/v1 sur émulateur Android
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
contentType: Headers.jsonContentType,
responseType: ResponseType.json,
headers: {'Accept': 'application/json'},
);
/// Ajoute un identifiant de corrélation par requête, repris dans les logs
/// de l'API gateway et des microservices.
class CorrelationIdInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers.putIfAbsent('X-Request-Id', _uuid.v4);
handler.next(options);
}
}
Dio createDio({
required String baseUrl,
required TokenStore store,
required void Function() onSessionExpired,
}) {
final dio = Dio(_baseOptions(baseUrl));
// Client dédié au refresh : pas d'AuthInterceptor, donc pas de boucle.
final refreshDio = Dio(_baseOptions(baseUrl))
..interceptors.add(CorrelationIdInterceptor());
dio.interceptors.addAll([
CorrelationIdInterceptor(),
AuthInterceptor(
dio: dio,
refreshDio: refreshDio,
store: store,
onSessionExpired: onSessionExpired,
),
RetryInterceptor(dio),
if (kDebugMode)
LogInterceptor(
requestHeader: false, // jamais l'en-tête Authorization dans les logs
requestBody: false, // mots de passe, montants, PII
responseBody: false,
logPrint: (o) => debugPrint(o.toString()),
),
]);
return dio;
}
L'ordre des intercepteurs compte. Corrélation d'abord (pour que l'id soit présent partout), auth ensuite, retry après, logs en dernier pour voir la requête finale. Dio 5.10 a ajouté DioExceptionType.transformTimeout : un switch exhaustif écrit pour une version antérieure ne compile plus, ce qui est voulu.
Sérialisation : json_serializable et freezed
Écrire fromJson à la main est acceptable pour trois modèles, pas pour cent endpoints. json_serializable génère le mapping, freezed ajoute immutabilité, copyWith, égalité et unions. Les deux tournent avec build_runner. Avec freezed 3 et 4, la classe doit être déclarée abstract (ou sealed pour une union) ; freezed 4 suit Dart 3.13 et refuse final dans les paramètres de constructeur.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'transaction.freezed.dart';
part 'transaction.g.dart';
@JsonEnum(fieldRename: FieldRename.snake) // transferIn <-> "transfer_in"
enum TransactionType { transferIn, transferOut, payment, cashIn, cashOut, bill, unknown }
enum Direction { credit, debit }
enum TransactionStatus { completed, pending, failed, unknown }
@freezed
abstract class Transaction with _$Transaction {
const factory Transaction({
required String id,
// Valeur inconnue (nouveau type côté serveur) : pas de crash.
@JsonKey(unknownEnumValue: TransactionType.unknown)
required TransactionType type,
required int amount, // centimes (minor units), jamais de double
required Direction direction,
required String counterparty,
String? note, // champ optionnel : absent ou null
@JsonKey(unknownEnumValue: TransactionStatus.unknown)
@Default(TransactionStatus.pending)
TransactionStatus status,
required DateTime createdAt, // ISO 8601 UTC, à convertir avec toLocal()
}) = _Transaction;
factory Transaction.fromJson(Map<String, dynamic> json) =>
_$TransactionFromJson(json);
}
@freezed
abstract class TransactionPage with _$TransactionPage {
const factory TransactionPage({
required List<Transaction> items,
String? nextCursor, // null : dernière page
}) = _TransactionPage;
factory TransactionPage.fromJson(Map<String, dynamic> json) =>
_$TransactionPageFromJson(json);
}
Les cas qui cassent en production
- Champs optionnels : type nullable (
String? note). Absent etnulldonnent tous deuxnull. Si le serveur doit distinguer « absent » de « remis à null » (PATCH), il faut un type dédié. - Champs inconnus : ignorés par défaut, c'est ce que l'on veut. Un nouveau champ côté serveur ne doit jamais casser une version de l'app déjà publiée. Ne pas activer
disallowUnrecognizedKeyssur des modèles de réponse. - Enum avec valeur inconnue : sans
unknownEnumValue, un nouveau type de transaction ajouté par le back-end fait planter le parsing de toute la page sur les versions installées. Prévoir une valeurunknownet un affichage générique. - Dates : l'API envoie de l'ISO 8601 UTC.
DateTime.parseconserve l'indication UTC ; convertir avectoLocal()au moment de l'affichage seulement, et renvoyer de l'UTC (toUtc().toIso8601String()). - Argent : entier en unités mineures (centimes).
0.1 + 0.2 != 0.3en double, et une erreur d'arrondi sur un solde est un incident, pas un bug cosmétique. Le formatage (1 250,00 MAD) se fait à l'affichage avecintl.
final double amount = json['amount'] / 100; // arrondis, comparaisons fausses
final int amount = json['amount'] as int; // 15000 = 150,00 MAD
final label = NumberFormat.currency(locale: 'fr', symbol: 'MAD').format(amount / 100);
Parser hors du thread UI
Une page de 20 transactions se décode en quelques millisecondes. Un export de 5 000 lignes, non : décodage et mapping peuvent dépasser les 16 ms d'une frame et provoquer du jank (saccades). Deux options : configurer le transformer, ou demander la réponse brute et faire jsonDecode + fromJson dans Isolate.run. La seconde déplace aussi le mapping, souvent plus coûteux que le décodage. Le coût de création d'un isolate et de copie du résultat n'est rentable qu'au-delà d'une certaine taille : mesurez avec DevTools plutôt que de tout envoyer en isolate.
import 'dart:convert';
import 'dart:isolate';
import 'package:dio/dio.dart';
import 'transaction.dart';
TransactionPage _parsePage(String body) =>
TransactionPage.fromJson(jsonDecode(body) as Map<String, dynamic>);
class TransactionsApi {
TransactionsApi(this._dio);
final Dio _dio;
Future<TransactionPage> fetchPage({
String? cursor,
int limit = 20,
String? type,
CancelToken? cancelToken,
}) async {
final res = await _dio.get<String>(
'/transactions',
queryParameters: {
'cursor': ?cursor, // null-aware element : clé omise si null
'limit': limit,
'type': ?type,
},
options: Options(responseType: ResponseType.plain),
cancelToken: cancelToken,
);
// Décodage ET mapping hors du thread UI pour les grosses pages.
final body = res.data!;
return body.length < 50 * 1024 ? _parsePage(body) : Isolate.run(() => _parsePage(body));
}
Future<Transaction> transfer({
required String beneficiaryId,
required int amount,
String? note,
required String idempotencyKey, // générée UNE fois par intention de paiement
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/transfers',
data: {'beneficiaryId': beneficiaryId, 'amount': amount, 'note': ?note},
options: Options(headers: {'Idempotency-Key': idempotencyKey}),
);
return Transaction.fromJson(res.data!);
}
}
Remarquez 'cursor': ?cursor : les null-aware elements de Dart 3.8 omettent la clé quand la valeur est nulle, au lieu d'envoyer cursor=null.
Intercepteur d'authentification et refresh concurrent
C'est la question que l'on vous posera. Le contrat du projet : un access token de 300 secondes, un refresh token à rotation (chaque /auth/refresh invalide l'ancien), des 401 avec un code TOKEN_EXPIRED (rafraîchir) ou REFRESH_INVALID (déconnecter).
Le problème
L'écran d'accueil lance en parallèle /me, /wallet et /transactions. Le jeton a expiré : les trois reçoivent 401. Un intercepteur naïf déclenche trois refresh. Le premier réussit et fait tourner le refresh token ; les deux autres présentent un refresh token déjà invalidé, reçoivent REFRESH_INVALID, et l'utilisateur est déconnecté alors que tout allait bien. Sur certains serveurs, la réutilisation d'un refresh token révoqué est même traitée comme un vol et révoque toute la famille de jetons.
La solution
- Un seul refresh en vol : un
Completer<String>partagé. Le premier 401 le crée et lance le refresh ; les suivants attendent sonfuture. Dart étant mono-thread par isolate, il suffit d'affecter le Completer avant toutawaitpour qu'il n'y ait pas de course. - Rejouer chaque requête en échec avec le nouveau jeton via
dio.fetch(requestOptions), puishandler.resolve: l'appelant reçoit la réponse comme si rien ne s'était passé. - Déconnecter une seule fois si le refresh est refusé (401 sur
/auth/refresh) : effacer les jetons, notifier l'app, faire échouer toutes les requêtes en attente. - Ne pas déconnecter sur une panne : une erreur réseau ou un 503 pendant le refresh doit remonter comme erreur réseau. Déconnecter quelqu'un dans un tunnel est un bug.
- Pas de boucle infinie : le refresh passe par un Dio séparé qui n'a pas l'intercepteur d'auth ; une requête rejouée est marquée et ne déclenche jamais un second refresh.
- Jeton déjà changé : une requête partie avec l'ancien jeton peut revenir en 401 après la fin du refresh. On compare l'en-tête envoyé au jeton courant et on rejoue directement.
/// Paire de jetons renvoyée par /auth/login et /auth/refresh.
typedef TokenPair = ({String accessToken, String refreshToken, int expiresIn});
/// Abstraction du stockage des jetons (implémentée au chapitre 10
/// avec flutter_secure_storage). L'access token est aussi gardé en mémoire.
abstract interface class TokenStore {
String? get accessToken;
Future<String?> readRefreshToken();
Future<void> save(TokenPair pair);
Future<void> clear();
}
/// Levée quand le refresh token est refusé : la session est terminée.
final class SessionExpiredException implements Exception {
const SessionExpiredException();
}
import 'dart:async';
import 'package:dio/dio.dart';
import 'token_store.dart';
/// Ajoute le jeton d'accès et gère le refresh concurrent.
///
/// Garanties :
/// 1. un seul appel à /auth/refresh, même si N requêtes reçoivent 401 ensemble ;
/// 2. les requêtes en échec attendent ce refresh puis sont rejouées une fois ;
/// 3. REFRESH_INVALID déclenche une seule déconnexion ;
/// 4. aucune boucle : /auth/refresh passe par un Dio séparé, et une requête
/// déjà rejouée n'est jamais rejouée une deuxième fois.
class AuthInterceptor extends Interceptor {
// Paramètres nommés privés (Dart 3.12) : l'appelant écrit `dio:`, `store:`...
AuthInterceptor({
required this._dio,
required this._refreshDio,
required this._store,
required this._onSessionExpired,
});
final Dio _dio; // le client principal, pour rejouer
final Dio _refreshDio; // client nu, SANS cet intercepteur
final TokenStore _store;
final void Function() _onSessionExpired;
/// Refresh en cours, partagé par toutes les requêtes qui attendent.
Completer<String>? _refreshing;
static const _retriedKey = 'auth_retried';
static const skipAuthKey = 'skip_auth';
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (options.extra[skipAuthKey] == true) return handler.next(options);
// Un refresh est déjà en vol : inutile d'envoyer une requête vouée au 401.
final inFlight = _refreshing;
if (inFlight != null) {
try {
await inFlight.future;
} catch (_) {
// L'échec du refresh sera traité par la réponse 401 éventuelle.
}
}
final token = _store.accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final options = err.requestOptions;
if (!_shouldRefresh(err)) return handler.next(err);
try {
// Si le jeton a déjà changé depuis l'envoi (un autre appel a rafraîchi),
// on rejoue directement avec le jeton courant.
final sentWith = options.headers['Authorization'];
final current = _store.accessToken;
final token = (current != null && sentWith != 'Bearer $current')
? current
: await _refreshOnce();
options
..headers['Authorization'] = 'Bearer $token'
..extra[_retriedKey] = true; // garde-fou anti-boucle
final response = await _dio.fetch<dynamic>(options);
handler.resolve(response);
} on SessionExpiredException {
handler.next(err); // la couche data mappera vers SessionExpired
} on DioException catch (e) {
handler.next(e); // échec du rejeu ou erreur réseau pendant le refresh
}
}
bool _shouldRefresh(DioException err) {
final options = err.requestOptions;
if (err.response?.statusCode != 401) return false;
if (options.extra[skipAuthKey] == true) return false; // login, refresh
if (options.extra[_retriedKey] == true) return false; // déjà rejouée
return _errorCode(err.response) == 'TOKEN_EXPIRED';
}
/// Un seul refresh à la fois : les appelants suivants reçoivent le même Future.
Future<String> _refreshOnce() {
final inFlight = _refreshing;
if (inFlight != null) return inFlight.future;
final completer = Completer<String>();
_refreshing = completer; // affecté AVANT tout await : pas de course
_doRefresh().then(completer.complete, onError: completer.completeError)
.whenComplete(() => _refreshing = null);
return completer.future;
}
Future<String> _doRefresh() async {
final refreshToken = await _store.readRefreshToken();
if (refreshToken == null) throw _expire();
try {
final res = await _refreshDio.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refreshToken': refreshToken},
options: Options(extra: {skipAuthKey: true}),
);
final body = res.data!;
await _store.save((
accessToken: body['accessToken'] as String,
refreshToken: body['refreshToken'] as String, // rotation
expiresIn: body['expiresIn'] as int,
));
return body['accessToken'] as String;
} on DioException catch (e) {
if (e.response?.statusCode == 401) throw _expire(); // REFRESH_INVALID
rethrow; // réseau, 5xx : on ne déconnecte pas pour une panne
}
}
SessionExpiredException _expire() {
unawaited(_store.clear());
_onSessionExpired(); // une seule fois : un seul refresh est en vol
return const SessionExpiredException();
}
static String? _errorCode(Response<dynamic>? response) {
final data = response?.data;
if (data case {'error': {'code': final String code}}) return code;
return null;
}
}
Points de détail qui font la différence au tableau :
onRequestattend un refresh en cours : les requêtes lancées pendant le refresh partent directement avec le bon jeton au lieu de collectionner des 401.- L'access token est lu en mémoire, pas dans le stockage sécurisé à chaque requête (lecture Keystore coûteuse). Seul le refresh token est persisté (chapitre 10).
- Le corps d'une requête rejouée doit être rejouable : un
MapJSON l'est, unStreamnon, unFormDatadoit être cloné (FormData.clone()). - On peut ajouter un refresh proactif : lire
expiresInet rafraîchir 30 secondes avant l'échéance. Cela réduit les 401 mais ne dispense pas du réactif (horloge de l'appareil fausse, jeton révoqué côté serveur).
Variante avec QueuedInterceptor
QueuedInterceptor fait la mise en file pour vous : les onError sont traités un par un. Le premier rafraîchit ; quand le suivant entre, le jeton a changé et il rejoue directement. C'est plus court, mais toutes les erreurs (y compris les 500 sans rapport) passent par la file, et le débit des erreurs est sérialisé. Le Completer explicite rend le mécanisme visible, ce qui est préférable en entretien.
import 'package:dio/dio.dart';
import 'token_store.dart';
/// Variante QueuedInterceptor : Dio sérialise les appels à onError,
/// le premier 401 rafraîchit, les suivants constatent que le jeton a changé.
class QueuedAuthInterceptor extends QueuedInterceptor {
QueuedAuthInterceptor(this._dio, this._store, this._refresh);
final Dio _dio;
final TokenStore _store;
final Future<String> Function() _refresh; // appelle /auth/refresh
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final options = err.requestOptions;
if (err.response?.statusCode != 401 || options.extra['auth_retried'] == true) {
return handler.next(err);
}
try {
final current = _store.accessToken;
final token = options.headers['Authorization'] != 'Bearer $current' && current != null
? current // déjà rafraîchi par l'erreur précédente de la file
: await _refresh();
options
..headers['Authorization'] = 'Bearer $token'
..extra['auth_retried'] = true;
handler.resolve(await _dio.fetch<dynamic>(options));
} on DioException catch (e) {
handler.next(e);
}
}
}
« Quand plusieurs requêtes reçoivent 401 en même temps, je garantis un seul appel de refresh avec un Completer partagé : la première crée le Completer et lance le refresh, les autres attendent son future. Ensuite je rejoue chaque requête une seule fois avec le nouveau jeton, marquée pour éviter toute boucle, et le refresh passe par un client Dio séparé sans cet intercepteur. Si le refresh est refusé, je vide les jetons et je déconnecte une seule fois ; si c'est une panne réseau, je ne déconnecte pas. Je l'ai couvert par un test qui lance cinq requêtes concurrentes et vérifie qu'il n'y a qu'un appel à /auth/refresh. »
« Pourquoi pas un simple booléen isRefreshing ? » Un booléen dit qu'un refresh est en cours, mais pas comment attendre son résultat ni le récupérer. On finit par du polling ou par rejeter les requêtes. Le Completer porte à la fois l'état et le résultat (jeton ou erreur). Autre piège : refaire un refresh sur un 403. Le 403 signifie « droits insuffisants », pas « jeton expiré ».
Tester l'intercepteur
On remplace l'adapter par un faux qui renvoie 401 tant que l'en-tête ne contient pas le nouveau jeton, et compte les appels à /auth/refresh. Ce test vaut plus qu'un long discours en entretien.
test('un seul refresh pour 5 requêtes concurrentes en 401', () async {
final adapter = FakeAdapter(); // 401 TOKEN_EXPIRED sauf si 'Bearer new'
final store = MemoryTokenStore(access: 'old', refresh: 'r1');
var expired = 0;
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
final refreshDio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
dio.interceptors.add(AuthInterceptor(
dio: dio,
refreshDio: refreshDio,
store: store,
onSessionExpired: () => expired++,
));
final responses = await Future.wait(List.generate(5, (i) => dio.get('/r$i')));
expect(responses.map((r) => r.statusCode), everyElement(200));
expect(adapter.refreshCalls, 1);
expect(expired, 0);
});
Retry, backoff et idempotence
Rejouer une requête n'est utile que pour les erreurs transitoires : timeout, coupure, 502 / 503 / 504, 429. Jamais pour un 4xx métier ni une annulation. Trois règles :
- Backoff exponentiel : 300 ms, 600 ms, 1,2 s... plafonné. On laisse le serveur respirer.
- Jitter (aléa) : sans lui, des milliers d'apps qui ont perdu le réseau au même moment reviennent toutes à la même milliseconde et écroulent la gateway (thundering herd). Le full jitter tire un délai uniforme entre 0 et le plafond courant.
- Idempotence : seulement GET, HEAD, OPTIONS, PUT, DELETE, ou un POST porteur d'une
Idempotency-Key. La clé est générée une fois par intention utilisateur (l'appui sur « Confirmer »), conservée pendant tous les rejeux, et persistée si l'opération peut survivre à un redémarrage (outbox, chapitre 11).
import 'dart:math';
import 'package:dio/dio.dart';
/// Rejoue les erreurs transitoires avec backoff exponentiel et full jitter.
/// Ne rejoue que les requêtes idempotentes ou portant une Idempotency-Key.
class RetryInterceptor extends Interceptor {
RetryInterceptor(
this._dio, {
this.maxAttempts = 3,
this.baseDelay = const Duration(milliseconds: 300),
this.maxDelay = const Duration(seconds: 5),
Random? random,
}) : _random = random ?? Random();
final Dio _dio;
final int maxAttempts;
final Duration baseDelay;
final Duration maxDelay;
final Random _random;
static const _attemptKey = 'retry_attempt';
static const _idempotentMethods = {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'};
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final options = err.requestOptions;
final attempt = (options.extra[_attemptKey] as int?) ?? 0;
if (attempt + 1 >= maxAttempts || !_isRetryable(err)) {
return handler.next(err);
}
await Future<void>.delayed(_delayFor(attempt, err.response));
options.extra[_attemptKey] = attempt + 1;
try {
handler.resolve(await _dio.fetch<dynamic>(options));
} on DioException catch (e) {
handler.next(e);
}
}
bool _isRetryable(DioException err) {
final options = err.requestOptions;
final safe = _idempotentMethods.contains(options.method.toUpperCase()) ||
options.headers.containsKey('Idempotency-Key');
if (!safe) return false;
return switch (err.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError => true,
DioExceptionType.badResponse =>
const {429, 502, 503, 504}.contains(err.response?.statusCode),
_ => false, // annulation, certificat, 4xx métier : jamais
};
}
Duration _delayFor(int attempt, Response<dynamic>? response) {
// Le serveur indique quand revenir : on le respecte (429, 503).
final retryAfter = int.tryParse(response?.headers.value('retry-after') ?? '');
if (retryAfter != null) return Duration(seconds: retryAfter);
// Full jitter : délai aléatoire entre 0 et min(max, base * 2^n).
final cap = min(maxDelay.inMilliseconds, baseDelay.inMilliseconds * (1 << attempt));
return Duration(milliseconds: _random.nextInt(cap + 1));
}
}
Riverpod 3 rejoue automatiquement un provider en erreur, avec backoff exponentiel (jusqu'à 10 tentatives par défaut pour une Exception). Combiné au retry de Dio, un seul appel utilisateur peut produire des dizaines de requêtes. Choisissez un seul niveau de retry : par exemple ProviderScope(retry: (count, error) => null, child: ...) pour désactiver celui de Riverpod globalement, ou un retry par provider.
Gestion d'erreurs : DioException vers Failure
La couche présentation ne doit jamais voir une DioException. Le repository attrape, mappe vers une Failure scellée (chapitre 08), et l'UI fait un switch exhaustif. La hiérarchie ci-dessous affine celle du chapitre 08 : UnauthorizedFailure y est scindée en InvalidCredentialsFailure et SessionExpiredFailure, parce que l'UI ne réagit pas de la même façon (message sur l'écran de connexion d'un côté, redirection de l'autre). Le corps d'erreur du contrat { "error": { "code", "message" } } se lit avec un pattern.
import 'package:dio/dio.dart';
/// Erreurs typées exposées au domaine (cf. chapitre 08).
sealed class Failure {
const Failure();
}
final class NetworkFailure extends Failure {
const NetworkFailure();
}
final class TimeoutFailure extends Failure {
const TimeoutFailure();
}
final class SessionExpiredFailure extends Failure {
const SessionExpiredFailure();
}
final class InvalidCredentialsFailure extends Failure {
const InvalidCredentialsFailure();
}
final class ValidationFailure extends Failure {
const ValidationFailure(this.message);
final String message;
}
final class InsufficientFundsFailure extends Failure {
const InsufficientFundsFailure();
}
final class NotFoundFailure extends Failure {
const NotFoundFailure();
}
final class RateLimitedFailure extends Failure {
const RateLimitedFailure(this.retryAfter);
final Duration? retryAfter;
}
final class ServerFailure extends Failure {
const ServerFailure(this.requestId);
final String? requestId; // à afficher pour le support
}
final class CancelledFailure extends Failure {
const CancelledFailure();
}
final class UnexpectedFailure extends Failure {
const UnexpectedFailure(this.error);
final Object error;
}
Failure mapDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout: // Dio 5.10+
return const TimeoutFailure();
case DioExceptionType.connectionError:
return const NetworkFailure();
case DioExceptionType.cancel:
return const CancelledFailure();
case DioExceptionType.badCertificate:
return UnexpectedFailure(e); // pinning en échec : ne jamais contourner
case DioExceptionType.badResponse:
return _fromResponse(e.response!);
case DioExceptionType.unknown:
return UnexpectedFailure(e.error ?? e);
}
}
Failure _fromResponse(Response<dynamic> response) {
final (code, message) = switch (response.data) {
{'error': {'code': final String c, 'message': final String m}} => (c, m),
_ => (null, null),
};
return switch ((response.statusCode, code)) {
(401, 'INVALID_CREDENTIALS') => const InvalidCredentialsFailure(),
(401, _) => const SessionExpiredFailure(),
(422, 'INSUFFICIENT_FUNDS') => const InsufficientFundsFailure(),
(422, _) => ValidationFailure(message ?? 'Données invalides'),
(404, _) => const NotFoundFailure(),
(429, _) => RateLimitedFailure(_retryAfter(response)),
(final int s, _) when s >= 500 =>
ServerFailure(response.requestOptions.headers['X-Request-Id'] as String?),
_ => UnexpectedFailure('HTTP ${response.statusCode}'),
};
}
Duration? _retryAfter(Response<dynamic> r) {
final s = int.tryParse(r.headers.value('retry-after') ?? '');
return s == null ? null : Duration(seconds: s);
}
/// Message utilisateur : court, actionnable, sans détail technique.
String userMessage(Failure f) => switch (f) {
NetworkFailure() => 'Pas de connexion. Vérifiez votre réseau.',
TimeoutFailure() => 'Le service met trop de temps à répondre. Réessayez.',
SessionExpiredFailure() => 'Votre session a expiré. Reconnectez-vous.',
InvalidCredentialsFailure() => 'Téléphone ou mot de passe incorrect.',
ValidationFailure(:final message) => message,
InsufficientFundsFailure() => 'Solde insuffisant.',
NotFoundFailure() => 'Élément introuvable.',
RateLimitedFailure() => 'Trop de tentatives. Patientez un instant.',
ServerFailure(:final requestId) =>
'Service indisponible.${requestId == null ? '' : ' Référence : $requestId'}',
CancelledFailure() => '',
UnexpectedFailure() => 'Une erreur inattendue est survenue.',
};
Règles pour les messages utilisateur : courts, actionnables, sans jargon (« Pas de connexion », pas « SocketException »), jamais de trace technique. Pour les 5xx, afficher la référence X-Request-Id : le support la colle dans l'outil de logs de la gateway et retrouve la trace complète en quelques secondes. Le message du serveur peut être affiché pour les erreurs de validation si le back-end s'engage à le localiser ; sinon, mappez le code vers vos propres textes.
Pagination : offset ou cursor
L'offset (?page=3&size=20 ou ?offset=40) est simple et permet de sauter à une page. Mais sur une liste qui bouge, il produit des doublons et des trous : si une transaction arrive pendant que l'utilisateur scrolle, tout se décale d'un rang. Et OFFSET 100000 force la base à parcourir 100 000 lignes.
Le cursor (?cursor=eyJpZCI6...&limit=20) est un marqueur opaque fourni par le serveur, souvent l'encodage de la clé de tri de la dernière ligne (createdAt + id). Stable sur une liste qui change, performant (requête indexée WHERE (created_at, id) < (...)), et le client n'a rien à calculer : il renvoie nextCursor tel quel, et null signifie fin de liste. Limite : pas de saut direct à la page 50, ce dont un fil de transactions n'a pas besoin.
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../model/transaction.dart';
import '../model/transactions_api.dart';
import '../sec/secure_token_store.dart';
import 'dio_client.dart';
import 'token_store.dart';
final tokenStoreProvider = Provider<TokenStore>((ref) => SecureTokenStore());
/// Incrémenté quand la session expire ; le routeur (chapitre 05) redirige.
class SessionExpiredSignal extends Notifier<int> {
@override
int build() => 0;
void fire() => state++;
}
final sessionExpiredProvider = NotifierProvider<SessionExpiredSignal, int>(
SessionExpiredSignal.new,
);
final dioProvider = Provider<Dio>((ref) {
final dio = createDio(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:8080/api/v1',
),
store: ref.watch(tokenStoreProvider),
onSessionExpired: () => ref.read(sessionExpiredProvider.notifier).fire(),
);
ref.onDispose(dio.close);
return dio;
});
final transactionsApiProvider = Provider<TransactionsApi>(
(ref) => TransactionsApi(ref.watch(dioProvider)),
);
/// Pagination par curseur : on accumule les pages, nextCursor null = fin.
class TransactionHistory extends AsyncNotifier<TransactionPage> {
bool _loadingMore = false;
@override
Future<TransactionPage> build() async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel); // écran quitté : requête annulée
return ref.watch(transactionsApiProvider).fetchPage(cancelToken: cancelToken);
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.nextCursor == null || _loadingMore) return;
_loadingMore = true;
try {
final next = await ref
.read(transactionsApiProvider)
.fetchPage(cursor: current.nextCursor);
if (!ref.mounted) return; // Riverpod 3 : provider détruit entre-temps
state = AsyncData(
TransactionPage(
items: [...current.items, ...next.items],
nextCursor: next.nextCursor,
),
);
} finally {
_loadingMore = false;
}
}
}
final transactionHistoryProvider =
AsyncNotifierProvider<TransactionHistory, TransactionPage>(
TransactionHistory.new,
);
Le CancelToken branché sur ref.onDispose annule la requête quand l'écran est quitté. ref.mounted (Riverpod 3) évite d'écrire un état sur un provider détruit pendant l'await. Le drapeau _loadingMore empêche le scroll de lancer dix fois la même page.
Cache HTTP et ETag
Un ETag est une empreinte de la ressource. Le client la renvoie dans If-None-Match ; si rien n'a changé, le serveur répond 304 sans corps. On économise de la bande passante, pas un aller-retour. C'est utile pour des référentiels (liste de banques, frais, configuration) ; pour un solde, on préfère un cache applicatif explicite (chapitre 11). Le package dio_cache_interceptor gère cela de façon complète ; la version minimale ci-dessous montre le principe.
import 'package:dio/dio.dart';
/// Cache de validation : on renvoie l'ETag, le serveur répond 304 si rien n'a changé.
class ETagInterceptor extends Interceptor {
final _cache = <String, ({String etag, Object? data})>{};
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final entry = _cache[options.uri.toString()];
if (options.method == 'GET' && entry != null) {
options.headers['If-None-Match'] = entry.etag;
}
// 304 ne doit pas être traité comme une erreur.
options.validateStatus = (s) => s != null && (s < 300 || s == 304);
handler.next(options);
}
@override
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
final key = response.requestOptions.uri.toString();
if (response.statusCode == 304 && _cache[key] != null) {
// Pas de corps sur le réseau : on sert la copie locale.
return handler.resolve(
Response<dynamic>(
requestOptions: response.requestOptions,
data: _cache[key]!.data,
statusCode: 200,
headers: response.headers,
),
);
}
final etag = response.headers.value('etag');
if (etag != null) _cache[key] = (etag: etag, data: response.data);
handler.next(response);
}
}
OpenAPI et génération de client
Avec des microservices Spring Boot, le contrat existe déjà : springdoc-openapi publie un document OpenAPI par service, souvent agrégé au niveau de la gateway. Générer le client Dart à partir de ce document supprime une classe entière de bugs (faute de frappe dans un champ, type int devenu string, paramètre oublié) et rend les changements de contrat visibles à la compilation.
# openapi-generator : client Dio complet (API + modèles)
openapi-generator-cli generate \
-i https://api-dev.exemple.ma/v3/api-docs/wallet \
-g dart-dio \
-o packages/wallet_api \
--additional-properties=pubName=wallet_api,serializationLibrary=json_serializable
# swagger_parser : génère des interfaces Retrofit + modèles freezed/json_serializable
dart run swagger_parser
| Approche | Forces | Limites |
|---|---|---|
| Retrofit écrit à la main | Contrôle total, lisible, annotations simples | Dérive silencieuse du contrat, travail répétitif sur 100 endpoints |
| openapi-generator dart-dio | Officiel côté OpenAPI, couvre tout le spec, package séparé | Code verbeux, built_value par défaut (json_serializable et freezed en beta) |
| swagger_parser | Produit du Retrofit + freezed, idiomatique Flutter | Dépend de la qualité du spec, communauté plus petite |
Bonnes pratiques : générer dans un package dédié du monorepo (Melos ou pub workspaces), versionner le spec utilisé, régénérer en CI et faire échouer le build si le diff n'est pas commité. Ne jamais modifier le code généré : on l'enveloppe dans le repository, qui mappe les DTO générés vers les entités du domaine. Le domaine ne dépend ainsi pas du générateur, et l'on peut en changer.
@RestApi()
abstract class WalletApi {
factory WalletApi(Dio dio) = _WalletApi;
@GET('/transactions')
Future<TransactionPage> transactions(
@Query('cursor') String? cursor,
@Query('limit') int limit,
);
@POST('/transfers')
Future<Transaction> transfer(
@Header('Idempotency-Key') String key,
@Body() Map<String, dynamic> body,
);
}
Retrofit et le client généré partagent le même Dio : les intercepteurs d'auth, de retry et de logs s'appliquent à l'identique. Le passage de l'un à l'autre est donc progressif, service par service.
Versioning d'API
Une app mobile publiée vit des mois sur les téléphones : le back-end doit servir plusieurs versions de client simultanément. Le versioning par chemin (/api/v1) est le plus courant et le plus simple à router dans une gateway. Côté client : tolérer les champs ajoutés (compatibilité ascendante), gérer les valeurs d'enum inconnues, et prévoir un mécanisme de mise à jour forcée (un endpoint de configuration qui renvoie la version minimale supportée, MASVS-CODE-2).
Travailler avec une API gateway
Dans une banque ou un opérateur, l'app ne parle jamais directement aux microservices : une gateway (Spring Cloud Gateway, Kong, Apigee, un WAF devant) assure le routage, la validation des jetons, le rate limiting et la journalisation.
- Corrélation : envoyez un
X-Request-Id(UUID) par requête. La gateway le propage aux services (ou le relie à sa propre trace, par exempletraceparenten W3C Trace Context). Un utilisateur qui appelle le support avec une référence affichée à l'écran, c'est un incident résolu en minutes. - Rate limiting : 429 est une réponse normale de la gateway. Respectez
Retry-After, ne rejouez pas en boucle. - Erreurs hétérogènes : une erreur de la gateway (HTML d'un WAF, JSON d'un autre format) n'a pas la forme du contrat métier. Le mapping doit tolérer un corps inattendu : d'où le cas par défaut
(null, null)dans_fromResponse. - Environnements : dev, recette, préproduction, production, chacun avec sa
baseUrlet parfois ses pins de certificat. Passez-les par flavors et--dart-define(chapitre 08), jamais en dur ; ce ne sont pas des secrets.
Mock d'API quand le back-end n'est pas prêt
En régie, le front mobile démarre souvent avant le back-end. Trois niveaux :
- Adapter Dio factice (comme dans le test ci-dessus) : réponses en mémoire, idéal pour les tests et le mode démo.
- Serveur de mock depuis le spec OpenAPI (Prism, WireMock côté équipe Java) : réponses conformes au contrat, partagé avec l'équipe QA.
- Repository factice injecté par override Riverpod : l'UI avance sans aucun réseau.
L'important est de figer le contrat tôt (un spec OpenAPI validé par les deux équipes) et de générer mocks et client depuis la même source.
Logging réseau sans fuite
Les logs sont la première source de fuite de données en audit (MASVS-STORAGE-2). Un LogInterceptor laissé avec requestBody: true écrit dans logcat le mot de passe du login, et logcat est lisible par un attaquant avec un accès ADB ou un appareil rooté.
- Logs réseau uniquement en debug (
kDebugMode), jamais en release. - Jamais l'en-tête
Authorization, jamais de corps de login, refresh ou paiement. Masquer téléphone et numéros de compte s'il faut logguer. - Côté crash reporting (Crashlytics, Sentry), filtrer les breadcrumbs HTTP : URL sans query string sensible, aucun corps.
- Logguer le
X-Request-Idplutôt que le contenu : il suffit pour retrouver la requête côté serveur.
WebSocket, SSE et GraphQL
WebSocket (web_socket_channel) : canal bidirectionnel persistant, pour du chat ou des cotations. Sur mobile, il faut gérer reconnexion, backoff, heartbeat et passage en arrière-plan (le système coupe la socket). SSE (Server-Sent Events) : flux serveur vers client sur HTTP classique, plus simple à faire passer dans une gateway ; Dio peut le lire avec ResponseType.stream. Pour une notification de paiement reçu, le push (FCM / APNs) reste la solution standard, la socket ne survivant pas en arrière-plan.
GraphQL (graphql_flutter, ferry) : le client choisit les champs, utile pour des écrans qui agrègent beaucoup de sources. En contexte bancaire avec des microservices REST déjà en place, on le rencontre surtout derrière une couche BFF (Backend For Frontend). Savoir le situer suffit généralement en entretien.
Questions d'entretien
Comment gérez-vous l'expiration du jeton quand plusieurs requêtes échouent en même temps ?
Un intercepteur Dio qui, sur 401 TOKEN_EXPIRED, partage un unique refresh via un Completer : la première requête le lance, les autres attendent le même future. Chaque requête est ensuite rejouée une fois avec le nouveau jeton. Le refresh passe par un Dio séparé, une requête rejouée est marquée pour éviter les boucles, un refresh refusé déconnecte une seule fois, une panne réseau ne déconnecte pas.
Quelle différence entre Interceptor et QueuedInterceptor dans Dio ?
Un Interceptor traite les callbacks en parallèle. Un QueuedInterceptor les sérialise : une requête n'entre dans onRequest ou onError que lorsque la précédente en est sortie. Pratique pour le refresh (le premier rafraîchit, les suivants voient le nouveau jeton), mais toutes les erreurs passent par la file.
Quelles requêtes peut-on rejouer automatiquement ?
Les idempotentes (GET, HEAD, OPTIONS, PUT, DELETE) sur erreurs transitoires (timeout, coupure, 502 / 503 / 504, 429 en respectant Retry-After). Un POST seulement s'il porte une Idempotency-Key stable que le serveur honore. Jamais un 4xx métier, jamais une annulation.
Pourquoi ajouter du jitter au backoff exponentiel ?
Pour désynchroniser les clients. Après une coupure générale, sans aléa, tous les clients rejouent aux mêmes instants (300 ms, 600 ms...) et créent des pics qui font retomber le service. Le full jitter répartit les rejeux uniformément.
Comment représentez-vous un montant ?
Un entier en unités mineures (centimes) plus une devise, encapsulé dans un type dédié (extension type Money). Jamais de double : erreurs d'arrondi binaire. Le formatage se fait à l'affichage avec intl, en tenant compte de la locale.
Le back-end ajoute un nouveau type de transaction. Que se passe-t-il dans votre app déjà publiée ?
Rien de grave : l'enum a une valeur unknown déclarée via @JsonKey(unknownEnumValue: ...), l'UI affiche un libellé générique. Sans cela, le parsing de la page entière lève une exception et l'historique devient vide pour tous les utilisateurs de l'ancienne version.
Offset ou cursor pour paginer un historique de transactions ?
Cursor : stable quand des éléments sont ajoutés pendant le scroll (pas de doublon ni de trou), performant côté base car indexé, opaque pour le client. L'offset ne se justifie que si l'on doit sauter à une page arbitraire.
Quand déplacer le parsing JSON dans un isolate ?
Quand la mesure montre des frames au-delà du budget, typiquement pour des réponses de plusieurs centaines de Ko. On demande la réponse brute et on fait jsonDecode + fromJson dans Isolate.run, ou on configure le seuil du FusedTransformer. Pour de petites réponses, le coût de l'isolate dépasse le gain.
Pourquoi générer le client depuis OpenAPI plutôt que l'écrire ?
Le contrat devient la source de vérité : un champ renommé ou un type changé casse la compilation au lieu de casser la production. Sur des dizaines de microservices, c'est aussi beaucoup moins de code répétitif. On isole le code généré dans un package et on le mappe vers le domaine dans le repository.
Comment aidez-vous le support à diagnostiquer une erreur serveur signalée par un client ?
Chaque requête porte un X-Request-Id unique, propagé par la gateway aux microservices. En cas de 5xx, l'app affiche cette référence. Le support la recherche dans l'outil de logs centralisé et retrouve toute la chaîne d'appels, sans que l'app ait loggué de donnée sensible.
Que ne doit-on jamais voir dans les logs réseau ?
Jetons (en-tête Authorization), mots de passe, OTP, corps de login, de refresh et de paiement, numéros de compte complets. Logs réseau en debug uniquement, filtrage des breadcrumbs du crash reporting, et le X-Request-Id comme seule clé de recherche.
Exercices
Écrivez de mémoire, sans documentation, l'intercepteur d'auth avec refresh concurrent, puis le test à cinq requêtes concurrentes avec un adapter factice.
Critères de réussite : un seul appel à /auth/refresh ; les cinq requêtes réussissent ; avec REFRESH_INVALID, une seule notification de déconnexion et cinq erreurs ; aucun appel récursif possible sur /auth/refresh ; une coupure réseau pendant le refresh ne déconnecte pas.
Écrivez le RetryInterceptor avec full jitter et respect de Retry-After. Ajoutez un test qui injecte un Random à graine fixe pour vérifier les délais, et un test qui prouve qu'un POST sans Idempotency-Key n'est jamais rejoué.
Sur papier : écrivez mapDioException et userMessage pour tous les codes du contrat. Expliquez à voix haute, en deux minutes, pourquoi un 403 ne déclenche pas de refresh et pourquoi un 429 n'est pas une erreur définitive.
Dans l'app Wallet (voir le projet) :
core/network/:createDio,CorrelationIdInterceptor,AuthInterceptor(refresh concurrent, file via Completer),RetryInterceptor, logs en debug seulement.core/errors/: mapping versFailurepourINVALID_CREDENTIALS,TOKEN_EXPIRED,REFRESH_INVALID,VALIDATION_ERROR,INSUFFICIENT_FUNDS,NOT_FOUND,RATE_LIMITED,SERVER_ERROR.- Modèles freezed
TransactionetTransactionPage, montants enint, enums avec valeur inconnue. - Historique paginé par cursor avec
AsyncNotifier,loadMoreet pull-to-refresh. - Transfert avec
Idempotency-Keygénérée à la confirmation et conservée pendant les rejeux. - Validation : lancez le backend avec
X-Chaos-Fail-Rate: 0.3etX-Chaos-Latency: 2000; l'historique doit finir par se charger, et un transfert rejoué ne doit débiter qu'une fois. Attendez l'expiration du jeton (300 s) écran d'accueil ouvert, puis tirez pour rafraîchir : un seul/auth/refreshdans les logs du backend.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.
Pour aller plus loin
- Dio sur pub.dev (README : intercepteurs, QueuedInterceptor, transformers, vérification de certificat)
- Package http
- Sérialisation JSON (docs Flutter)
- json_serializable et freezed
- Parser du JSON en arrière-plan (cookbook Flutter)
- Isolates (dart.dev)
- retrofit et swagger_parser
- Générateur dart-dio d'openapi-generator
- Riverpod (retry automatique,
ref.mounted)