Irbisa · Шпаргалка

Сеть и REST

http, Dio, REST API, сериализация JSON, интерцепторы

Middle-разработчик · 25 вопросов

  1. 01

    Какие HTTP-методы и коды ответа нужно знать и как приложение должно реагировать на 401, 403, 404 и 5xx?

    GET читает, POST создаёт, PUT заменяет, PATCH меняет часть, DELETE удаляет; 2xx означает успех, 3xx редирект, 4xx ошибку клиента, 5xx ошибку сервера.

  2. 02

    Как загрузить данные из API при открытии экрана и показать состояния загрузки, ошибки и данных?

    Запустите запрос один раз (в initState или в слое управления состоянием), храните Future в поле и отдайте FutureBuilder отрисовку трёх состояний по его снапшоту.

    class _UserScreenState extends State<UserScreen> {  late Future<User> _user;   @override  void initState() {    super.initState();…
  3. 03

    Этот fetchUser работает в демо и падает в продакшене, как только пользователя удаляют: что в нём не так?

    Future<User> fetchUser(int id) async {  final res = await http.get(Uri.parse('$baseUrl/users/$id'));  final json = jsonDecode(res.body) as Map<String, dynamic>;  return User.fromJson(json);}

    package:http не бросает исключение на 4xx и 5xx, поэтому код декодирует любое пришедшее тело и отдаёт его в User.fromJson.

  4. 04

    Поиск начинает возвращать чушь, как только пользователь вводит «C# & Java»: что не так со способом сборки URL этого запроса?

    Значение подставляется в строку URL интерполяцией, поэтому # начинает фрагмент, и всё после него, включая &, до сервера не доходит: он видит только q=C.

    // wrong: '#' starts a fragment, so the server sees only q=Cfinal bad = Uri.parse('https://api.example.com/search?q=$term'); // right: every value is percent-encodedfinal good = Uri.https('api.example.com', '/search', {'q': term, 'page': '1'});…
  5. 05

    Какой HTTP-клиент и способ сериализации JSON вы выберете для Flutter-приложения и почему?

    Когда эндпоинтов больше нескольких, берите dio: интерцепторы, отмена, multipart и base URL у него из коробки.

    part 'user.g.dart'; @JsonSerializable()class User {  const User({required this.id, required this.name, required this.email});…
  6. 06

    Найдите баг: fetchOrders падает на каждом вызове, хотя сервер возвращает валидный JSON с нужными полями.

    class Order {  Order({required this.id, required this.total});  final int id;  final double total;   factory Order.fromJson(Map<String, dynamic> json) =>      Order(id: json['id'] as int, total: (json['total'] as num).toDouble());} Future<List<Order>> fetchOrders(Dio dio) async {  final res = await dio.get<Map<String, dynamic>>('/orders');  final items = res.data!['items'] as List<Map<String, dynamic>>;  return items.map(Order.fromJson).toList();}

    jsonDecode создаёт каждый JSON-массив как List<dynamic>, поэтому as List<Map<String, dynamic>> в рантайме бросает type 'List<dynamic>' is not a subtype of ... на любом ответе, каким бы валидным ни был JSON.

  7. 07

    На плохой сети приложение две минуты крутит спиннер, прежде чем хоть что-то упадёт: какие таймауты Dio это лечат и что покрывает каждый?

    Задайте все три: connectTimeout на установку соединения, sendTimeout на отправку тела, receiveTimeout на ответ.

    final dio = Dio(BaseOptions(  baseUrl: 'https://api.example.com',  connectTimeout: const Duration(seconds: 8), // TCP + TLS handshake  sendTimeout: const Duration(seconds: 15), // writing the request body  receiveTimeout: const Duration(seconds: 20), // gap between response bytes));…
  8. 08

    Как реализовать интерцепторы запросов/ответов во Flutter?

    В Dio наследуйтесь от Interceptor и переопределите onRequest, onResponse, onError; каждый вызывает handler.next, resolve или reject.

    class AuthInterceptor extends Interceptor {  AuthInterceptor(this._tokens);  final TokenStore _tokens;   @override  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {…
  9. 09

    Разлогиненные пользователи жалуются, что публичный каталог крутится бесконечно, а у залогиненных всё работает: что не так с этим интерцептором?

    dio.interceptors.add(InterceptorsWrapper(  onRequest: (options, handler) async {    final token = await storage.read(key: 'access_token');    if (token != null) {      options.headers['Authorization'] = 'Bearer $token';      handler.next(options);    }  },));

    Когда токена нет, колбэк не вызывает handler.next, поэтому запрос застревает в цепочке интерцепторов, и его future никогда не завершается.

  10. 10

    Как маппить типы DioException в типизированные доменные ошибки?

    Маппинг делайте один раз и в одном месте, чтобы HTTP не протекал в бизнес-логику: DioException превращается в типизированный сбой, с которым репозитории и вью-модели работают, не зная про статус-коды.

    sealed class AppException implements Exception {  const AppException();} class NetworkException extends AppException { const NetworkException(); }class UnauthorizedException extends AppException { const UnauthorizedException(); }…
  11. 11

    Пользователь уходит с экрана, пока его запрос ещё выполняется. Как отменить запрос?

    Дайте экрану собственный CancelToken, передавайте его в каждый запрос этого экрана и вызывайте token.cancel() в dispose.

    class OrdersController {  OrdersController(this._dio);  final Dio _dio;  final _cancel = CancelToken(); // one per screen   Future<List<Order>?> load() async {…
  12. 12

    Найдите баг: после быстрого ввода список поиска иногда показывает результаты, которые не совпадают с текстом в поле.

    class _SearchState extends State<SearchScreen> {  List<Product> _results = [];   Future<void> _onQueryChanged(String query) async {    final results = await widget.api.search(query);    if (!mounted) return;    setState(() => _results = results);  }   @override  Widget build(BuildContext context) => Column(children: [        TextField(onChanged: _onQueryChanged),        Expanded(child: ProductList(_results)),      ]);}

    Каждое нажатие клавиши запускает запрос, и побеждает ответ, пришедший последним: если ответ на «p» медленнее ответа на «phone», он затирает более новые результаты, и список перестаёт совпадать с полем.

  13. 13

    Коллега создаёт новый Dio внутри каждого метода репозитория, и задержка выросла на пару сотен миллисекунд на вызов: за что платит приложение?

    Новый клиент означает новый пул соединений, поэтому каждый вызов заново делает DNS-резолв и рукопожатия TCP и TLS вместо переиспользования уже открытого сокета.

  14. 14

    На нестабильной мобильной сети падает примерно один запрос из двадцати: как добавить ретраи, не перегрузив сервер и не списав с пользователя деньги дважды?

    Повторяйте только то, что безопасно повторить, отступайте экспоненциально с джиттером и давайте серверному Retry-After перебивать ваше расписание.

    class RetryInterceptor extends Interceptor {  RetryInterceptor(this._dio, {this.maxAttempts = 3});  final Dio _dio;  final int maxAttempts;  @override  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {…
  15. 15

    Пять экранов одновременно отправляют запросы с протухшим access-токеном, и пользователя выкидывает на экран логина: что не так сделал интерцептор обновления токена?

    Все пять ответов 401 запустили собственное обновление: первый выполнил ротацию refresh-токена, а остальные четыре предъявили токен, который сервер только что отозвал.

    class RefreshInterceptor extends Interceptor {  RefreshInterceptor(this._dio, this._authDio, this._tokens);  final Dio _dio, _authDio; // _authDio has no auth interceptor: no re-entry  final TokenStore _tokens;  Future<String>? _refreshing;  @override…
  16. 16

    Как загружать файлы (multipart/form-data) во Flutter?

    Оберните каждый файл в MultipartFile, сложите поля и файлы в FormData и отправьте это.

    Future<void> uploadAvatar(  Dio dio,  String path,  CancelToken cancel,  ValueNotifier<double> progress,) async {…
  17. 17

    Как скачать большой файл с прогресс-баром и возобновить загрузку после обрыва связи?

    Пишите ответ потоком во временный файл, показывайте прогресс из колбэка полученных байт и возобновляйте загрузку, запрашивая у сервера оставшийся диапазон.

    Future<void> downloadResumable(    Dio dio, String url, String path, void Function(double) onProgress) async {  final part = File('$path.part');  final have = await part.exists() ? await part.length() : 0;   final res = await dio.get<ResponseBody>(…
  18. 18

    Offset или cursor-пагинация: что просить у бэкенда и когда клиенту запрашивать следующую страницу?

    Просите cursor-пагинацию: сервер возвращает непрозрачный nextCursor, клиент отправляет его обратно, и строки, добавленные или удалённые между страницами, не повторяются и не пропадают.

  19. 19

    Как кэшировать HTTP-ответы во Flutter-приложении?

    Три слоя, каждый под свой случай: HTTP-заголовки кэша, кэш разобранных моделей на уровне приложения и кэш картинок.

  20. 20

    Как понять, что устройство ушло в офлайн, и почему одного connectivity_plus для этого мало?

    connectivity_plus сообщает, какой сетевой интерфейс поднят, а не доступен ли интернет: Wi-Fi за captive portal отдаёт wifi.

    class Reachability {  Reachability(this._dio);  final Dio _dio;   // connectivity_plus 6+ emits a list: Wi-Fi and VPN can be up together.  Stream<bool> get hasInterface => Connectivity()…
  21. 21

    Когда GraphQL стоит добавлять во Flutter-приложение?

    GraphQL работает через один эндпоинт, где клиент описывает ровно те данные, которые ему нужны, и получает не больше и не меньше.

  22. 22

    Как подключиться к WebSocket и обрабатывать переподключение?

    Откройте канал через web_socket_channel, исходите из того, что он оборвётся, и переподключайтесь с экспоненциальным backoff и джиттером; добавьте ping, чтобы заметить мёртвый сокет, и переоткрывайте соединение при возврате приложения.

    class LiveFeed {  LiveFeed(this.uri);  final Uri uri;  final events = StreamController<String>.broadcast();  WebSocketChannel? _channel;  var _attempt = 0, _closed = false;…
  23. 23

    Эндпоинт чата стримит токены через server-sent events, вы читаете его через Dio, и поток умирает через десять секунд: что происходит и как его правильно разбирать?

    receiveTimeout измеряет паузу между порциями данных, а не длину ответа, поэтому поток, молчащий дольше таймаута, например пока модель думает, в него упирается.

    Stream<String> chatEvents(Dio dio, Object body, CancelToken cancel) async* {  final res = await dio.post<ResponseBody>(    '/chat/stream',    data: body,    cancelToken: cancel,    options: Options(…
  24. 24

    Экрану нужны детали по 200 элементам: Future.wait на 200 запросов упирается в рейт-лимит или теряет 199 удачных ответов из-за одной ошибки. Что делать вместо этого?

    Ограничьте число запросов в полёте и собирайте исход по каждому элементу отдельно, чтобы пул не захлёбывался, а одна ошибка не выбрасывала 199 удачных ответов.

    Future<List<Item?>> loadDetails(    ItemApi api, List<int> ids, CancelToken cancel) async {  final pool = Pool(6); // at most six requests in flight  final futures = [    for (final id in ids)      pool.withResource(() {…
  25. 25

    Бэкенд добавил поле, переименовал значение enum и сделал строку nullable. Как ваше приложение это переживёт?

    Считайте payload недоверенным вводом и сделайте слой данных единственным местом, где изменение контракта имеет право упасть.

    enum OrderStatus { pending, shipped, delivered, unknown } @JsonSerializable()class OrderDto {  OrderDto({required this.id, required this.status, this.note});…