GET читает, POST создаёт, PUT заменяет, PATCH меняет часть, DELETE удаляет; 2xx означает успех, 3xx редирект, 4xx ошибку клиента, 5xx ошибку сервера. Реакция по коду: на 401 обновить токен один раз, на 403 и 404 не повторять, на 429 и 5xx повторять с backoff.
Идемпотентные методы: GET, PUT и DELETE можно отправить дважды с тем же результатом; POST так отправлять нельзя
401 Unauthorized: токена нет или он истёк: обновите его один раз, а если не вышло, отправьте пользователя на логин
403 Forbidden: сервер знает, кто вы, и всё равно отказывает; обновление токена не поможет, покажите «нет доступа»
404 Not Found: покажите пустое состояние или «не найдено» и не повторяйте запрос
400 / 422: не прошла валидация: покажите ошибки сервера рядом с соответствующими полями
429, 5xx и таймауты: временные: повторяйте с backoff, учитывайте Retry-After и повторяйте только запросы, которые безопасно отправить дважды
FlutterMiddle
Сеть и REST
http, Dio, REST API, сериализация JSON, интерцепторы
Закрыто вопросов
0 / 25
- 0Повторить
- 4Часто спрашивают
Вопросы
Запустите запрос один раз (в initState или в слое управления состоянием), храните Future в поле и отдайте FutureBuilder отрисовку трёх состояний по его снапшоту.
Создавайте future один раз: не в build, который выполняется при каждой перестройке и слал бы запрос заново
Читайте снапшот: ожидание → спиннер, error → экран ошибки с кнопкой повтора, data → контент
**Сначала проверяйте waiting**: при повторе снапшот хранит прошлую ошибку и данные, пока выполняется новый future, поэтому если первым проверить hasError, вместо спиннера останется старая ошибка
Повтор: присвойте новый future внутри setState; FutureBuilder увидит другой future и подпишется заново
За пределами демо: вынесите вызов в репозиторий за Bloc, Riverpod FutureProvider или AsyncNotifier, чтобы кэширование, обновление и отмена не были заботой виджета
package:http не бросает исключение на 4xx и 5xx, поэтому код декодирует любое пришедшее тело и отдаёт его в User.fromJson. Разбор тела 404 вроде {"error":"not found"} падает глубоко внутри модели с TypeError, далеко от настоящей причины.
**Проверяйте statusCode до декодирования**: и превращайте не-2xx в типизированные ошибки вроде NotFoundException и ServerException, которые UI умеет нормально показать
Тело не всегда JSON: прокси или балансировщик отвечает на 502 HTML-страницей, и jsonDecode бросает FormatException
Нет таймаута: на мёртвом соединении вызов ждёт столько, сколько позволит сокет ОС; добавьте .timeout(...)
Dio ведёт себя иначе: по умолчанию он бросает DioException с типом badResponse на любой статус вне 2xx, поэтому 404 до декодера не доходит, но его всё равно нужно смапить
Значение подставляется в строку URL интерполяцией, поэтому # начинает фрагмент, и всё после него, включая &, до сервера не доходит: он видит только q=C.
Собирайте Uri из частей: Uri.https('api.example.com', '/search', {'q': term}) кодирует каждое значение: пробел станет +, а # станет %23; голый & («Tom & Jerry») начал бы новый параметр
**В Dio передавайте queryParameters:: не пишите строку запроса в пути, он закодирует значения так же
Не кодируйте заранее**: ваш Uri.encodeComponent плюс их собственное экранирование дадут %2520; двойное кодирование это вторая половина той же ошибки
Сегменты пути тоже: id со слешем незаметно меняет маршрут, поэтому экранируйте его или стройте Uri через pathSegments
Когда эндпоинтов больше нескольких, берите dio: интерцепторы, отмена, multipart и base URL у него из коробки. Для JSON берите кодогенерацию: json_serializable, а если нужны ещё иммутабельность, copyWith и ==, то freezed.
**http против dio**: http официальный и крошечный, но без интерцепторов; годится, пока не нужны обновление токена, ретраи и отмена
**Рукописный fromJson: без кодогенерации, нормально для пары моделей, легко ошибиться незаметно
Сгенерированный: переименованное поле падает на сборке, а не в рантайме
Путь запроса: собрать запрос, проверить статус, разобрать тело в модель или смапить сбой в типизированную ошибку, а потом по одному результату отрисовать loading, data или error
Не пускайте Map<String, dynamic> в UI**: разбирайте JSON на границе репозитория, чтобы виджеты видели только модели
jsonDecode создаёт каждый JSON-массив как List<dynamic>, поэтому as List<Map<String, dynamic>> в рантайме бросает type 'List<dynamic>' is not a subtype of ... на любом ответе, каким бы валидным ни был JSON.
Почему каст падает: дженерики в Dart реифицированы: as проверяет рантайм-тип самого списка, а не типы его элементов, а декодированный массив создаётся как List<dynamic>, даже если все элементы являются мапами
Почему верхний уровень работает: декодированный объект создаётся как Map<String, dynamic>, поэтому dio.get<Map<String, dynamic>> проходит, а падает только вложенный список
Исправление: приведите список к List<dynamic>, а каждый элемент к Map<String, dynamic>; .cast<Map<String, dynamic>>() тоже подходит, но оборачивает список лениво и проверяет элемент только при чтении
**Заметка про total**: fromJson уже читает total как num; as double упало бы на 10, потому что JSON-число без дробной части декодируется как int
Как поймать в следующий раз: анализатор молчит, потому что res.data!['items'] имеет тип dynamic; юнит-тест, который прогоняет fromJson по записанному ответу через jsonDecode, падает сразу, а json_serializable сам генерирует приведение каждого элемента
Задайте все три: connectTimeout на установку соединения, sendTimeout на отправку тела, receiveTimeout на ответ. По умолчанию в Dio их нет, поэтому зависший запрос ждёт столько, сколько позволяет сокет ОС.
**connectTimeout: установка TCP- и TLS-соединения; обычное значение 5-10 с
sendTimeout и receiveTimeout**: отправка тела и ожидание первого байта ответа, а дальше пауза между порциями данных. Receive не ограничивает всю загрузку: сервер, отдающий по байту в секунду, в него не упрётся
Ни один из них не бюджет на весь вызов: для жёсткого дедлайна оберните вызов в .timeout() и отмените CancelToken в onTimeout: .timeout лишь перестаёт ждать Future, а сокет остаётся открытым
Receive для обычного JSON: 15-30 с, а для загрузок и стримов ослабляйте через Options запроса
В Dio наследуйтесь от Interceptor и переопределите onRequest, onResponse, onError; каждый вызывает handler.next, resolve или reject. Он срабатывает на каждом запросе, ответе и ошибке, поэтому заголовки авторизации, логи, ретраи, маппинг ошибок и обновление токена живут в одном месте.
Подключение: добавьте его в dio.interceptors; для небольших случаев InterceptorsWrapper принимает обычные колбэки
Решает handler: handler.next(...) продолжает, handler.resolve(response) завершает успехом, а handler.reject(err) завершает ошибкой
Порядок важен: они выполняются в порядке добавления, поэтому логирующий ставьте последним, если хотите видеть заголовок авторизации
**У http такого понятия нет**: наследуйтесь от http.BaseClient (или оберните внутренний http.Client) и переопределите send
Ловушка обновления токена: повтор через тот же Dio снова заходит в интерцептор, и с окончательно битым токеном получается бесконечный цикл: ставьте флаг в options.extra и сводите параллельные 401 к одному обновлению
Когда токена нет, колбэк не вызывает handler.next, поэтому запрос застревает в цепочке интерцепторов, и его future никогда не завершается. Каждая ветка интерцептора должна вызвать next, resolve или reject ровно один раз.
Почему крутится вечно: таймауты применяет адаптер, который отправляет запрос, а этот запрос до него не доходит, так что и таймаут не срабатывает
Исправление: вынесите handler.next(options) из if, чтобы запрос без токена всё равно ушёл, а решение принял сервер
Брошенное исключение относится к другому случаю: начиная с dio 5.10 исключение внутри async-onRequest превращается в упавший запрос; зависает только ветка, которая вышла, не вызвав handler. На более старом dio зависает и оно, поэтому оберните тело в try и вызовите handler.reject(DioException(requestOptions: options, error: e))
**Async-работа в onRequest**: обычный Interceptor пропускает запросы параллельно; используйте QueuedInterceptor, когда каждый запрос должен дождаться предыдущего, например во время обновления токена
Маппинг делайте один раз и в одном месте, чтобы HTTP не протекал в бизнес-логику: DioException превращается в типизированный сбой, с которым репозитории и вью-модели работают, не зная про статус-коды.
Типы, по которым ветвитесь: connectionTimeout, sendTimeout/receiveTimeout, badResponse (дальше смотрите response?.statusCode), cancel, connectionError, badCertificate, transformTimeout (разбор тела занял слишком долго, dio 5.10+), unknown
Типичная иерархия: NetworkException для связи и таймаутов, ServerException(statusCode) для 5xx, UnauthorizedException для 401, ForbiddenException для 403, NotFoundException, BadRequestException(message, fields) для 4xx со структурированным телом и CancelledException
Где маппить: в onError интерцептора либо в одном хелпере, через который идут все методы репозитория
Зачем: UI разбирает закрытый набор сбоев через switch, а правила вроде «403 не ретраим» пишутся один раз
Дайте экрану собственный CancelToken, передавайте его в каждый запрос этого экрана и вызывайте token.cancel() в dispose.
Отменённый вызов: завершается DioException с типом cancel; считайте это нормальным исходом и не показывайте пользователю как ошибку
Токен одноразовый: отменённый остаётся отменённым, для следующего запроса создавайте новый
**С package:http**: начиная с 1.5 AbortableRequest принимает future abortTrigger, который прерывает запрос; раньше оставался только close() у Client, который обрывает всё, что тот выполняет
Отмена происходит на стороне клиента: запрос мог уже дойти до сервера, поэтому для записей всё равно нужны ключи идемпотентности
Каждое нажатие клавиши запускает запрос, и побеждает ответ, пришедший последним: если ответ на «p» медленнее ответа на «phone», он затирает более новые результаты, и список перестаёт совпадать с полем.
Почему так происходит: ответы приходят не в том порядке, в каком ушли запросы: короткий запрос находит больше строк и выполняется дольше, один вызов отдаёт кэш, а другой нет, мобильная сеть добавляет разброс задержек. Проверка mounted защищает только от уничтоженного виджета, но не от устаревшего ответа
Исправление: нумеруйте каждый запрос и позволяйте менять состояние только самому новому; более старый ответ, пришедший позже, отбрасывается
Debounce и отмена сверху: ждите 300 мс после последнего нажатия и отменяйте предыдущий вызов через CancelToken, чтобы сэкономить трафик и нагрузку на сервер; отмена работает по мере возможности (ответ может уже прийти, или внутренний слой не передаёт токен), поэтому проверку оставьте
В стейт-менеджменте: Bloc с трансформером restartable() из bloc_concurrency или switchMap в RxDart сами оставляют только последний запрос
Как поймать в тесте: фейковый API на Completer: завершите сначала второй вызов, потом первый, и проверьте, что список показывает результаты второго запроса
Новый клиент означает новый пул соединений, поэтому каждый вызов заново делает DNS-резолв и рукопожатия TCP и TLS вместо переиспользования уже открытого сокета.
Что такое пул: адаптер Dio кеширует один HttpClient из dart:io, и именно он владеет keep-alive соединениями; один долгоживущий Dio это один пул
Почему это 100-300 мс: адаптер Dio ставит клиенту idleTimeout в 3 секунды, и внутри этого окна второй запрос к тому же хосту переиспользует соединение и пропускает оба рукопожатия
**Создавайте один Dio на API при старте: базовый URL плюс его интерцепторы, и прокидывайте его дальше
Никогда не закрывайте общий клиент из виджета**: dio.close(force: true) обрывает запросы в полёте; отменяйте через CancelToken. То же и с Client из package:http
Когда пула мало: dart:io умеет только HTTP/1.1, поэтому ради HTTP/2, системных прокси и платформенных сертификатов подставьте native_dio_adapter
Повторяйте только то, что безопасно повторить, отступайте экспоненциально с джиттером и давайте серверному Retry-After перебивать ваше расписание.
Что можно повторять: GET, HEAD, PUT и DELETE идемпотентны по контракту; POST повторяйте только с ключом идемпотентности, по которому бэкенд дедуплицирует, иначе упавший по таймауту платёж станет двумя
Какие сбои: connectionError, таймауты, 408, 429, 502, 503, 504. Никогда 400, 401, 403, 404 и 422: тот же запрос упадёт точно так же
Задержка: 500 мс, затем 1 с, максимум три попытки всего, плюс потолок по общему времени. Добавляйте джиттер, иначе все телефоны, потерявшие одну вышку, пойдут на ретрай синхронно
Где это живёт: один интерцептор onError в Dio, который переотправляет через dio.fetch(err.requestOptions) и держит счётчик попыток в options.extra; для package:http есть RetryClient
Ровно в одном слое: интерцептор с ретраями под репозиторием с ретраями под кнопкой «Повторить» это девять запросов на одно нажатие
Все пять ответов 401 запустили собственное обновление: первый выполнил ротацию refresh-токена, а остальные четыре предъявили токен, который сервер только что отозвал.
Обновление в один поток: держите в интерцепторе одно поле Future<String>?: первый 401 его заполняет, остальные ждут тот же future, а не дёргают эндпоинт снова
Сначала проверьте: если токен уже сменился с тех пор, как упавший запрос отправил заголовок, просто повторите запрос с новым
Помечайте повтор: options.extra['retried'] = true, чтобы второй 401 завершился ошибкой, а не бесконечным циклом
**Обновляйте через отдельный Dio** без auth-интерцептора, иначе сам запрос обновления на первом же 401 зайдёт в ваш интерцептор; QueuedInterceptorsWrapper представляет собой встроенный в dio вариант той же идеи
Логаут делают только по 4xx от эндпоинта обновления: connectionError таковым не является, иначе поездка в тоннеле выкидывает из аккаунта. Обновляйте заранее по exp из JWT, и большинства этих 401 просто не будет
Оберните каждый файл в MultipartFile, сложите поля и файлы в FormData и отправьте это. В dio это FormData, в http это MultipartRequest; API у Dio удобнее.
**Никогда не задавайте Content-Type сами**: клиент сам пишет multipart/form-data; boundary=..., а ваша перезапись ломает boundary
Откуда берётся файл: image_picker для камеры и галереи, file_picker для всего остального; на web вы получаете байты, а не путь, поэтому нужен MultipartFile.fromBytes
Большие файлы: MultipartFile.fromFile уже читает файл потоком с диска, поэтому он целиком не лежит в памяти; всё в память кладёт fromBytes. MultipartFile.fromStream(() => stream, length, filename: ...) нужен, только если данные не лежат файлом на диске
Прогресс и отмена: onSendProgress: (sent, total) кормит индикатор, а CancelToken даёт пользователю остановить загрузку
Имена полей должны совпадать с серверными: и учтите, что presigned PUT URL у S3 это вообще не multipart: туда вы PUT-ите сырые байты с тем Content-Type, который был подписан
Пишите ответ потоком во временный файл, показывайте прогресс из колбэка полученных байт и возобновляйте загрузку, запрашивая у сервера оставшийся диапазон.
Прогресс: dio.download(url, path, onReceiveProgress: (received, total) {...}); total равен -1, если сервер не прислал Content-Length, тогда показывайте неопределённый индикатор. Обновляйте UI несколько раз в секунду, не чаще
Возобновление: шлите Range: bytes=<downloaded>- и дописывайте в частичный файл; поддерживающий сервер ответит 206. Ответ 200 значит, что диапазон проигнорирован: обрезайте файл и качайте заново
Храните ETag рядом с частичным файлом: изменился ETag, значит на сервере уже другой файл: выбрасывайте и начинайте сначала
**Качайте в <name>.part и переименовывайте только после последнего байта: тогда падение не оставит половину файла, выглядящую целой
Когда приложение уходит в фон, загрузка средствами Dart останавливается**: если она обязана это пережить, отдайте её background_downloader
Просите cursor-пагинацию: сервер возвращает непрозрачный nextCursor, клиент отправляет его обратно, и строки, добавленные или удалённые между страницами, не повторяются и не пропадают. Запрашивайте следующую страницу за несколько экранов до конца и никогда не больше одной за раз.
Offset (?page=3&limit=20) устроен просто и позволяет прыгнуть на страницу N, но новая строка сверху сдвигает все страницы: пользователь видит дубль, а удаление молча пропускает элемент
Cursor (?after=abc&limit=20) устойчив к вставкам, а nextCursor: null обозначает конец, так что hasMore берётся из API, а не угадывается по размеру страницы
Когда грузить: за несколько элементов до конца, по одному запросу за раз под защитой флага isLoading
Аккуратно склеивайте: убирайте дубли по id и показывайте упавшую страницу строкой «Повторить» в конце списка, а не полноэкранной ошибкой, которая прячет уже загруженное
**infinite_scroll_pagination**: если не хочется самим владеть автоматом пагинации
Три слоя, каждый под свой случай: HTTP-заголовки кэша, кэш разобранных моделей на уровне приложения и кэш картинок.
HTTP-заголовки: сервер отдаёт Cache-Control и ETag, клиент в следующий раз шлёт If-None-Match и получает 304, если ничего не изменилось. Браузерного кэша во Flutter нет, эту работу делает dio_cache_interceptor
На уровне приложения: держите разобранную модель в локальной базе и работайте по stale-while-revalidate: сразу показать кэш, обновить в фоне, подменить свежим
Картинки: cached_network_image добавляет дисковый кэш, и они переживают перезапуск
Не кэшируйте: данные реального времени; на них подписываются стримом
Две ошибки, которые стоит назвать: кэширование ответов с ошибкой и выдача кэшированного ответа одного пользователя следующему: никогда не кэшируйте 4xx/5xx, ключуйте персональные ответы по URL плюс текущий пользователь и очищайте кэш при выходе
connectivity_plus сообщает, какой сетевой интерфейс поднят, а не доступен ли интернет: Wi-Fi за captive portal отдаёт wifi. Используйте его как подсказку, чтобы показать баннер или разбудить синхронизацию, а настоящим сигналом считайте упавший запрос.
Стрим: начиная с версии 6 onConnectivityChanged отдаёт List<ConnectivityResult>, потому что Wi-Fi и VPN могут быть активны одновременно; офлайн выглядит как список с none
Проверяйте доступность: дешёвым HEAD к своему API или через internet_connection_checker_plus, прежде чем говорить пользователю, что он онлайн
Не блокируйте запросы по флагу: он запаздывает и врёт; отправляйте запрос и превращайте connectionError и таймауты в офлайн-состояние
В UI: продолжайте показывать кэшированные данные с баннером «Нет сети» вместо экрана ошибки и повторяйте отложенную работу, когда связь вернётся
GraphQL работает через один эндпоинт, где клиент описывает ровно те данные, которые ему нужны, и получает не больше и не меньше. Он окупается, когда бэкенд уже на нём говорит, а экранам нужны разные вложенные формы данных.
Библиотека: graphql_flutter: линки для транспорта и авторизации, политики cache-first / network-first и graphql_codegen для типобезопасных операций вместо рукописного fromJson
Не берите его: для обычного REST CRUD-бэкенда и для приложения с двумя эндпоинтами; файлы тоже проще грузить через REST
Нормализация кэша: ключ это __typename плюс id, поэтому запрос без id запутывает обновления кэша
Пагинация: общепринятая форма это Relay (edges, node, pageInfo); проектируйте запросы вокруг неё с первого дня
Подписки идут по WebSocket: со всеми теми же оговорками про переподключение и авторизацию
Откройте канал через web_socket_channel, исходите из того, что он оборвётся, и переподключайтесь с экспоненциальным backoff и джиттером; добавьте ping, чтобы заметить мёртвый сокет, и переоткрывайте соединение при возврате приложения.
API: WebSocketChannel.connect(uri), channel.stream для входящих событий, channel.sink.add(...) для отправки, sink.close() для закрытия. Это single-subscription стрим, то есть слушатель один, поэтому оборачивайте канал в свой сервис, если слушателей несколько
Переподключение с backoff: Disconnected → Connecting → Connected, экспоненциальные задержки с джиттером и потолком; шлите ping каждые 20-30 с и считайте отсутствие pong обрывом, потому что мёртвый сокет часто выглядит живым
Жизненный цикл приложения: закрывайте на paused и переподключайтесь на resumed, иначе ОС убьёт сокет, а вы будете писать в сломанный pipe
Аутентификация: токен уезжает в URL соединения или первым сообщением; при обновлении токена вы переподключаетесь, менять его на лету нельзя
receiveTimeout измеряет паузу между порциями данных, а не длину ответа, поэтому поток, молчащий дольше таймаута, например пока модель думает, в него упирается. Отключите его для этого запроса и разбирайте тело построчно.
Читайте потоком: ResponseType.stream с заголовком Accept: text/event-stream, прогоняя поток через utf8.decoder и LineSplitter
Чанк не равен событию: один TCP-чанк может содержать три события или половину одного, а многобайтовый символ может быть разрезан пополам; декодер это переживёт, самописный split нет
Формат на проводе: строки data:, event:, id: и retry:; пустая строка отправляет накопленное событие, а строка с : в начале это комментарий, которым серверы шлют heartbeat
Как не терять соединение: receiveTimeout: Duration.zero в Options только этого запроса, запоминайте последний id: и шлите его как Last-Event-ID при переподключении, а CancelToken отменяйте в dispose
Если поток выглядит зависшим: буферизующий прокси может держать всё, пока не заполнится его буфер; на web берите браузерный EventSource: он переподключается сам, но не умеет слать заголовок Authorization
Ограничьте число запросов в полёте и собирайте исход по каждому элементу отдельно, чтобы пул не захлёбывался, а одна ошибка не выбрасывала 199 удачных ответов.
**Future.wait сначала запускает всё**: список стартует все 200 запросов, а wait только наблюдает
И теряет успехи: по умолчанию он дожидается всех, завершается первой ошибкой и выбрасывает собранные значения; с eagerError: true падает сразу, а оставшиеся 199 продолжают жечь батарею
Ограниченная параллельность: 4-8 одновременно, через Pool(6).withResource(...) или воркеры над общей очередью; столько выдерживают рейт-лимитер, keep-alive пул и радиомодуль телефона
Сохраняйте частичный результат: futures.wait из Dart 3 бросает ParallelWaitError, где values и errors разложены по индексам: покажите пришедшее и повторите только упавшее
**Один CancelToken на всю пачку**: проверяйте его между элементами, чтобы уход с экрана опустошал очередь, а не дорабатывал её
Затем назовите настоящую проблему: 200 запросов указывают на проблему дизайна, поэтому попросите батч-эндпоинт.
Считайте payload недоверенным вводом и сделайте слой данных единственным местом, где изменение контракта имеет право упасть.
Новое поле должно игнорироваться: разбор через Map даёт это бесплатно, поэтому не добавляйте строгий декодер и проверки количества ключей
Незнакомое значение enum должно откатываться, а не бросать: unknownEnumValue: в json_serializable и член unknown в enum; рукописному парсеру нужен такой же default в switch
Поле, ставшее null, не должно ронять виджет тремя слоями выше: объявляйте nullable, решайте про запасное значение при маппинге в доменную сущность и никогда не ставьте ! на разобранный JSON
Разбирайте защитно на границе: маленькие типизированные ридеры, которые проверяют рантайм-тип и возвращают default, вместо голого as String; число, пришедшее строкой, оказывается самым частым сюрпризом. Ошибки разбора превращайте в свои типизированные ошибки, а не в исключение внутри build()
Держите реальные payload-ы фикстурами в тестах: именно это превращает правку бэкенда в красный CI, а не в инцидент. Будьте терпимым читателем и строгим писателем и всё равно просите версионированные эндпоинты