OkHttp представляет собой HTTP-клиент, который реально передаёт байты; Retrofit работает поверх него и превращает аннотированный Kotlin-интерфейс в типизированные вызовы, которые выполняет OkHttp.
OkHttp: соединения и пул соединений, HTTP/2, TLS, gzip, редиректы, таймауты, кэш ответов и цепочка интерсепторов.
Retrofit: собирает каждый запрос по аннотациям, отдаёт его в OkHttp Call и превращает тело ответа в ваш тип через конвертер (kotlinx.serialization, Moshi, Gson).
Интерсепторы, таймауты и кэш настраиваются на OkHttpClient, а не на Retrofit.
Создайте один OkHttpClient на всё приложение и переиспользуйте его: он владеет пулом соединений и потоками, лишние экземпляры тратят и то и другое.
OkHttp сам по себе подходит для «сырого» HTTP: WebSocket, произвольные URL. Обе библиотеки работают только на JVM/Android, поэтому в мультиплатформенном модуле обычно берут Ktor client.
AndroidMiddle
Сеть в Android
Retrofit, OkHttp, интерцепторы, kotlinx.serialization, обработка ошибок
Закрыто вопросов
0 / 25
- 0Повторить
- 3Часто спрашивают
Вопросы
Они говорят Retrofit, куда в HTTP-запросе попадает каждый аргумент: в путь URL, в query-строку, в тело, в поле формы или в заголовок, а @Url подменяет URL целиком.
@Path("id") подставляет значение в {id} относительного URL; значение percent-кодируется.
@Query("q") добавляет ?q=…; значение null пропускается, а List повторяет ключ. @QueryMap принимает динамический набор.
@Body: конвертер сериализует объект (обычно в JSON) в тело запроса.
@Field описывает одно поле тела формы; метод должен быть помечен @FormUrlEncoded. Для загрузки файлов пара другая: @Multipart и @Part.
@Header("X-…"): заголовок для конкретного вызова; @Headers(...) на методе добавляет фиксированные.
@Url: полный или относительный URL, переданный в рантайме, например ссылка next из постраничного ответа.
Заголовок или параметр, нужный в каждом вызове (авторизация, локаль, API-ключ), место в интерсепторе, а не на каждом методе.
execute() блокирует вызывающий поток, enqueue() выполняет вызов на пуле потоков OkHttp и зовёт колбэк, а suspend-функция внутри использует enqueue(), поэтому она main-safe, и withContext(Dispatchers.IO) ей не нужен.
Call<T>.execute(): синхронный вызов. На главном потоке бросает NetworkOnMainThreadException; на любом другом держит поток весь round trip. Отменяется через call.cancel().
Call<T>.enqueue(callback): асинхронный, старый стиль с колбэками. На Android Retrofit доставляет колбэк на главный поток.
suspend fun ставится в очередь на потоки диспетчера OkHttp, тело парсится там же, а корутина возобновляется на своём диспетчере. Отмена корутины отменяет HTTP-вызов.
Диспетчер переключают для блокирующего кода (execute(), самостоятельное чтение потока ResponseBody, работа с файлами), а не для кода, который просто медленный. Тяжёлая CPU-работа с результатом уходит на Dispatchers.Default.
Четыре причины покрывают почти все случаи: вызов идёт на главном потоке, нет разрешения INTERNET, URL на обычном http при заблокированном cleartext-трафике или localhost указывает на сам эмулятор.
**NetworkOnMainThreadException**: блокирующий вызов (execute(), URL.openStream()) на главном потоке. Используйте suspend-функцию или уберите блокирующий код с главного потока.
**Нет разрешения INTERNET в манифесте**: это normal-разрешение, оно выдаётся при установке, но без него падает любой сокет, обычно с SecurityException: Permission denied (missing INTERNET permission?).
Cleartext заблокирован: начиная с Android 9 (target API 28) обычный HTTP по умолчанию выключен, и OkHttp сообщает CLEARTEXT communication to <host> not permitted by network security policy. Переходите на HTTPS или разрешите один dev-хост в network security config только для debug-сборки.
**localhost в эмуляторе**: это сам эмулятор; хост-машина доступна по 10.0.2.2.
Непойманный IOException или HttpException из suspend-вызова во viewModelScope тоже роняет приложение, поэтому ловите их в репозитории.
Состояние сети наблюдают через network callback у ConnectivityManager, но запрос никогда им не ограничивают: делайте вызов и считайте IOException сигналом офлайна.
Почему предварительная проверка ошибочна:
Это гонка (race condition): сеть может пропасть между проверкой и вызовом, так что обработка ошибки всё равно нужна.
«Подключено» не значит «доступно»: captive portal, сломанный VPN или сбой DNS выглядят как живая сеть.
Для чего callback действительно нужен:
Баннер «нет сети» и автоматический повтор, когда сеть вернулась.
Откладывать большие загрузки в тарифицируемой сети (NET_CAPABILITY_NOT_METERED).
Проверять NET_CAPABILITY_VALIDATED, который означает, что система подтвердила реальный доступ в интернет.
activeNetworkInfo и isConnected объявлены deprecated; читайте NetworkCapabilities. ACCESS_NETWORK_STATE относится к normal-разрешениям. Для работы, которой достаточно выполниться, когда появится сеть, задайте WorkManager ограничение NetworkType.CONNECTED вместо собственного наблюдения за сетью.
HTTP/1.1 передаёт по одному запросу за раз на соединение, HTTP/2 мультиплексирует много стримов в одном TLS-соединении, а HTTP/3 делает то же поверх QUIC на UDP; OkHttp поддерживает HTTP/1.1 и HTTP/2, но не HTTP/3.
HTTP/1.1: keep-alive переиспользует соединение, но параллельным вызовам нужны параллельные соединения, и каждое платит за DNS, TCP- и TLS-рукопожатия.
HTTP/2: согласуется автоматически во время TLS-рукопожатия (ALPN), если сервер его поддерживает. Одно соединение на хост несёт все вызовы со сжатыми заголовками, поэтому один общий OkHttpClient с одним пулом соединений так важен.
Слабое место HTTP/2: один потерянный TCP-пакет останавливает все стримы на соединении, что больно в мобильных сетях с потерями.
HTTP/3 (QUIC): восстановление потерь по каждому стриму отдельно, более быстрое рукопожатие и миграция соединения, когда телефон переходит с Wi-Fi на мобильную сеть. На Android его дают через Cronet: платформенный HttpEngine на Android 14+ или Cronet из Google Play services.
TLS 1.3: включён по умолчанию с Android 10 и экономит round trip на каждом новом соединении.
Практический вывод: переиспользуйте соединения (один клиент, HTTP/2 на сервере, мало разных хостов). Каждый лишний хост означает ещё одно рукопожатие через медленное радио.
Он возвращает java.lang.reflect.Proxy, реализующий ваш интерфейс: каждый вызов метода попадает в один обработчик, который один раз разбирает аннотации метода, собирает запрос OkHttp и использует конвертер для тела и call adapter для возвращаемого типа.
Как обрабатывается вызов
При первом вызове аннотации метода разбираются в закэшированный ServiceMethod; последующие вызовы переиспользуют его. Поэтому опечатка в аннотации падает на первом вызове, если только validateEagerly(true) (обычно в debug) не разбирает все методы прямо в create().
Конвертер (Converter.Factory) превращает ResponseBody в ваш тип, а объект из @Body переводит в RequestBody. Фабрики опрашиваются по порядку, побеждает первая, принявшая тип, поэтому ScalarsConverterFactory ставят перед JSON-конвертером.
Call adapter (CallAdapter.Factory) превращает Call из OkHttp в объявленный тип результата: Call<T>, тип RxJava или свой result-тип. suspend-функции работают из коробки.
Это обязан быть интерфейс, потому что Proxy не умеет наследовать класс, а R8 должен сохранить интерфейсы и их generic-сигнатуры; Retrofit поставляет эти keep-правила сам.
Паттерны, которые ждут на собеседовании
Dynamic Proxy (create), Builder (Retrofit.Builder), Factory (фабрики конвертеров и call adapter-ов), Adapter (CallAdapter) и Chain of Responsibility в интерсепторах OkHttp.
Каждый вызов создаёт новые OkHttpClient и Retrofit, а вместе с ними новый пул соединений, пул потоков и кэш: соединения никогда не переиспользуются, каждый запрос платит за новое TCP- и TLS-рукопожатие, а простаивающие сокеты и потоки копятся.
Почему медленно и утекает: пул соединений живёт внутри клиента, поэтому новый клиент не переиспользует «тёплое» соединение предыдущего: каждый запрос платит за один–три лишних round trip (сотни миллисекунд в мобильной сети), мультиплексирование HTTP/2 не включается, а каждый брошенный клиент держит простаивающие сокеты (по умолчанию 5 минут) и потоки диспетчера (60 секунд).
Кэш: OkHttp запрещает двум экземплярам Cache одновременно работать с одной директорией; параллельные вызовы здесь создают ровно такую ситуацию, а это может повредить журнал и тихо отключить кэширование.
Исправление: создайте клиент и сервис один раз, как синглтоны в DI, и внедряйте UserApi. Если одному эндпоинту нужны другие настройки, получите клиент через client.newBuilder(); он разделяет пул, диспетчер и кэш.
Как поймать: на ревью любой OkHttpClient.Builder() или Retrofit.Builder() за пределами DI-модуля считается тревожным знаком; в рантайме EventListener, который логирует connectStart, покажет новое соединение на каждый запрос к одному и тому же хосту.
Retrofit разрешает путь из аннотации относительно baseUrl так же, как браузер разрешает ссылку: ведущий / означает «от корня хоста», поэтому @GET("/users") идёт на https://api.example.com/users и теряет /v2.
@GET("users") без ведущего слеша даёт https://api.example.com/v2/users; эту привычку и стоит держать.
baseUrl должен заканчиваться на /, иначе сам вызов baseUrl(...) бросит исключение.
user("a/b") запросит /v2/users/a%2Fb, потому что значения @Path percent-кодируются. @Path(value = "id", encoded = true) нужен только для значения, которое действительно состоит из нескольких сегментов.
Полный URL в аннотации или аргумент @Url полностью игнорируют baseUrl.
@GET("search?q={q}") будет отклонён: динамические значения query передаются через @Query.
Два хоста: по одному Retrofit на каждый base URL, оба на одном OkHttpClient. Хост, выбираемый в рантайме (например, переключатель на staging), проще сделать интерсептором, который переписывает request.url.newBuilder().host(...). Один тест на MockWebServer, проверяющий записанный путь, ловит весь этот класс багов.
Он генерирует сериализаторы во время компиляции, поэтому уважает nullability и значения по умолчанию из Kotlin, не требует рефлексии и keep-правил R8 и работает в мультиплатформенном коде; для чужого API экземпляр Json делают терпимым к лишним, отсутствующим и неожиданным значениям.
Почему не Gson
Gson создаёт объекты через Unsafe, не вызывая конструктор, поэтому отсутствующее поле оставляет null в не-nullable String, а значения по умолчанию игнорируются, так что падение случается позже, далеко от парсинга.
Он основан на рефлексии и требует keep-правил. Другой вариант, понимающий Kotlin: Moshi с кодогенерацией через KSP.
Настройки, которые важны
ignoreUnknownKeys = true: без него новое поле на сервере ломает декодирование во всех установленных версиях.
coerceInputValues = true: null для не-nullable свойства или неизвестная константа enum заменяется значением по умолчанию.
@SerialName("full_name") переименовывает поле; @Transient исключает его (нужно значение по умолчанию).
Свойство без значения по умолчанию обязательно и бросает исключение при отсутствии; иногда именно это и нужно.
Sealed-иерархия с @SerialName на каждом наследнике декодирует смешанный список по ключу "type".
Создайте один экземпляр Json и передайте его в конвертер Retrofit.
Оба реализуют Interceptor, но application-интерсептор выполняется один раз на вызов и видит логический запрос, а network-интерсептор выполняется на каждый реальный round trip, включая каждый редирект и повтор, и видит запрос в том виде, в каком он уходит в сеть.
Application (addInterceptor) выполняется, даже если ответ пришёл из кэша, и может ответить, не трогая сеть. Сюда идут общие заголовки (Accept-Language, версия API, авторизация), query-параметр для каждого вызова, подпись запросов и debug-заглушки.
Network (addNetworkInterceptor) видит заголовки, которые OkHttp добавляет сам (Host, Accept-Encoding), и сжатое тело; подходит для сетевых метрик или переписывания Cache-Control в ответах.
Порядок важен: интерсепторы выполняются в порядке добавления. Логгер, добавленный раньше auth-интерсептора, печатает запрос без Authorization, поэтому HttpLoggingInterceptor добавляют последним среди application-интерсепторов.
Логирование в релизе: Level.NONE вне debug-сборок и redactHeader("Authorization"); Level.BODY к тому же буферизует всё тело в памяти.
Не для обновления токена: 401 обрабатывает Authenticator из OkHttp, а не интерсептор.
Делайте каждый интерсептор с одной задачей и регистрируйте их один раз, на общем OkHttpClient.
ResponseBody представляет собой одноразовый поток поверх открытого соединения: string() читает его до конца и закрывает, поэтому когда конвертер Retrofit потом читает то же тело, он находит закрытый поток.
string() и bytes() загружают всё тело в память и закрывают его; повторное чтение бросает исключение.
Чтобы посмотреть, не потребляя, используйте response.peekBody(maxBytes): он копирует ограниченный префикс и не трогает настоящее тело.
Или прочитайте его и верните пересобранный ответ с text.toResponseBody(contentType) (с ограничением размера, иначе загрузка файла буферизуется целиком).
Тело, которое никто не закрыл, не возвращает соединение в пул, и OkHttp предупреждает A connection to ... was leaked. Did you forget to close a response body?.
В Retrofit для suspend fun x(): Dto тело закрывает конвертер. С Response<T> Retrofit уже буферизовал errorBody(), но прочитать его можно только один раз: прочитайте и сохраните String. Сырой ResponseBody в качестве результата закрываете целиком вы, лучше всего через use { }.
Request неизменяем: newBuilder()…build() возвращает новый запрос с заголовком, но код его выбрасывает и передаёт дальше исходный, поэтому ни один вызов не несёт Authorization, и сервер отвечает 401.
Почему это компилируется: if здесь используется как инструкция, поэтому его результат молча теряется; по умолчанию Kotlin не предупреждает об игнорируемом возвращаемом значении (его ловит только опциональный return-value checker). HttpUrl, Response и OkHttpClient устроены так же (неизменяемый объект плюс билдер), и та же ошибка случается и с ними.
Исправление: сделайте пересобранный запрос тем значением, которое уходит в chain.proceed(...): используйте if как выражение или соберите запрос через original.newBuilder().apply { token?.let { header("Authorization", "Bearer $it") } }.build().
header() заменяет существующее значение, addHeader() добавляет второе; для Authorization нужен header().
Как поймать: тест на MockWebServer: сделайте один вызов и проверьте, что server.takeRequest().headers["Authorization"] равен Bearer …. При отладке HttpLoggingInterceptor с уровнем HEADERS, добавленный после этого интерсептора, покажет, что заголовка нет; добавьте redactHeader("Authorization"), чтобы токен не попадал в логи.
Бросающий suspend-вызов падает тремя разными способами: IOException (нет соединения, таймаут), HttpException (сервер ответил не 2xx) и SerializationException (тело не совпало с моделью), и репозиторий один раз превращает каждый в доменную ошибку.
IOException: офлайн, сбой DNS, таймаут, сброс соединения. Можно повторить; пользователю стоит сказать, что дело в сети.
HttpException: читайте code() и разбирайте response()?.errorBody(), чтобы достать ошибку сервера. 401, 404 и 422 значат для UI разное.
SerializationException: контракт разъехался. Это баг, который надо залогировать, а не повторять.
Response<T> как тип результата даёт статус, заголовки и тело ошибки без исключения: это удобно, когда не-2xx считается нормальным исходом, например 404 «не найдено».
Свой CallAdapter.Factory может заставить каждый endpoint возвращать sealed result-тип ценой некоторой инфраструктуры.
Не пускайте HttpException во ViewModel: UI должен получать доменную ошибку, которую умеет показать. И никогда не считайте CancellationException сбоем.
viewModelScope отменяется, Retrofit вызывает call.cancel(), OkHttp закрывает сокет (HTTP/1.1) или сбрасывает стрим (HTTP/2), а корутина получает CancellationException, но сервер мог уже обработать запрос.
Путь: job скоупа отменяется, suspend-мост Retrofit вызывает call.cancel(), вызов в полёте падает с IOException: Canceled внутри OkHttp, а мост превращает это в CancellationException.
Отмена не означает отката: отменённый POST вполне мог создать заказ. Отправляйте ключ идемпотентности, чтобы повтор не создал дубль.
Работа, которая должна пережить экран, например отправка сообщения, живёт в CoroutineScope уровня приложения или в WorkManager, а не во viewModelScope.
Общий catch (e: Exception) ловит и CancellationException; ловите IOException/HttpException или пробрасывайте его дальше первым.
Отмена бывает ещё и фичей: в поиске по мере ввода flatMapLatest отменяет предыдущий запрос на каждое нажатие клавиши.
В OkHttp есть таймауты connect, read, write и call; сам он повторяет только неудачную попытку соединения (другой маршрут или новое соединение) и следует редиректам, а повторять запрос, который сервер мог уже обработать, решаете вы.
Таймауты
connectTimeout: только установка TCP-соединения, по умолчанию 10 с; TLS-рукопожатие после него идёт под readTimeout.
readTimeout / writeTimeout ограничивают максимальную паузу между байтами, по умолчанию по 10 с. Это не общий бюджет: сервер, отдающий байты по капле, его никогда не превысит.
callTimeout: весь вызов, включая редиректы и тело. По умолчанию выключен, и только он реально ограничивает запрос.
chain.withReadTimeout(...) в интерсепторе расширяет таймаут для одного endpoint, а не для всего клиента.
Свой retry
Только для идемпотентных запросов: GET, PUT, DELETE. POST повторять только с Idempotency-Key, по которому сервер убирает дубли.
Повторять на IOException, 5xx и 429 (с учётом Retry-After); на остальные 4xx не повторять никогда.
Экспоненциальная задержка с jitter и потолком, чтобы после сбоя устройства не возвращались все в один момент.
Цикл живёт в репозитории с delay(), а не в интерсепторе, где Thread.sleep игнорирует отмену корутины.
Посчитайте вслух: call timeout 30 с и три попытки дают спиннер на 90 с.
Используйте Authenticator из OkHttp: он вызывается только когда сервер ответил 401, возвращает запрос для повтора с новым токеном (или null, чтобы сдаться), и OkHttp сам его повторяет; обновление, спрятанное в интерсепторе, легко зацикливается или устраивает гонку.
Разделите роли: application-интерсептор прикрепляет текущий токен, а Authenticator его обновляет.
Одно обновление на много 401: десять параллельных вызовов падают одновременно. Обновляйте под Mutex и сравнивайте: если сохранённый токен уже отличается от того, с которым ушёл упавший запрос, его обновил другой вызов, так что просто используйте его.
Знайте, когда остановиться: если повторный запрос снова получил 401, верните null. Если endpoint обновления отверг refresh-токен, очистите сессию и отправьте на логин; сетевая ошибка во время обновления не означает логаута.
Без рекурсии: вызов обновления должен идти через клиент без этого authenticator-а, иначе 401 от обновления снова запустит обновление.
authenticate() выполняется на потоке OkHttp и блокирующий по контракту, поэтому runBlocking внутри него допустим.
Обновление незадолго до истечения экономит round trip, но путь через 401 оставьте: расхождение часов и отзыв токена на сервере делают его неизбежным.
В OkHttp есть дисковый кэш, который включают явно (Cache(dir, maxSize) на клиенте); он следует стандартному HTTP-кэшированию (Cache-Control, ревалидация через ETag) и отдаёт устаревшие ответы офлайн только для запросов с only-if-cached.
max-age от сервера определяет, сколько ответ свежий; no-store отключает для него кэширование.
После истечения OkHttp отправляет If-None-Match; ответ 304 Not Modified обновляет запись без повторной загрузки тела, и это самый ощутимый выигрыш на практике.
Если сервер не присылает полезных заголовков, network-интерсептор может переписать Cache-Control в ответах.
Чтение офлайн: отправьте запрос с only-if-cached, max-stale=… (или CacheControl.FORCE_CACHE). Если в кэше ничего нет, OkHttp вернёт синтетический 504 Unsatisfiable Request.
response.cacheResponse и response.networkResponse показывают, откуда пришёл ответ.
Для offline-first приложения HTTP-кэш не служит источником истины: к нему нельзя сделать запрос, он истекает по своим правилам, а система может очистить cacheDir. Храните данные в Room, а сеть используйте для обновления.
Пометьте метод @Multipart и передайте файл как MultipartBody.Part, собранный из потокового RequestBody; прогресс получают, оборачивая это тело и считая байты по мере того, как OkHttp их пишет.
@Part: RequestBody для обычного поля или MultipartBody.Part.createFormData(name, filename, body) для файла.
File.asRequestBody(mediaType) читает с диска потоком. Если сначала прочитать файл в ByteArray, большие загрузки упираются в OutOfMemoryError.
content:// Uri из photo picker не является File: напишите RequestBody, чей writeTo копирует contentResolver.openInputStream(uri) в sink.
@Body RequestBody отправляет сырые байты без multipart-обёртки, когда API принимает файл напрямую.
Прогресс: переопределите writeTo со считающим ForwardingSink и прореживайте события: он срабатывает каждые несколько килобайт.
Загрузке, которая должна пережить уход с экрана, место в WorkManager, а для очень больших файлов нужен протокол докачки, где сервер помнит смещение.
Загрузчик изображений качает их не на главном потоке, декодирует bitmap под отображаемый размер, а не под исходный, держит кэши в памяти и на диске и отменяет запрос, когда элемент уходит с экрана.
Даунсэмплинг: фото 4000×3000, декодированное в полном размере, занимает около 48 МБ как ARGB-bitmap; декодированное под аватар 48 dp (144 px при плотности 3x) занимает около 80 КБ.
Кэши: ограниченный LRU-кэш в памяти с ключом из URL и размера плюс общий для приложения дисковый кэш.
Жизненный цикл: запросы отменяются, когда composable выходит из композиции или view переиспользуется.
На вас остаётся:
Дать картинке ограниченный размер; неограниченные constraints означают декодирование в исходном разрешении.
Запрашивать у сервера миниатюру, если API это умеет: умное декодирование не сэкономит трафик.
Резервировать место (aspectRatio или фиксированный размер), чтобы список не прыгал при загрузке картинок, и давать элементам ленивого списка стабильные ключи.
Отдать загрузчику общий OkHttpClient ради заголовков авторизации и пула соединений и чистить его кэши при логауте, если картинки приватные.
Для Compose и мультиплатформы обычно берут Coil 3; в коде на View часто встречается Glide.
Пагинация по курсору считается безопасным выбором по умолчанию для меняющейся ленты, потому что offset-страницы сдвигаются при вставке строк; Paging 3 загружает страницы через PagingSource, а RemoteMediator записывает страницы из сети в Room, чтобы список работал и офлайн.
Offset или курсор
Offset или номер страницы (?page=3&size=20): просто и позволяет перейти на страницу N, но новый элемент сверху сдвигает все страницы, и появляются дубли и пропуски.
Курсор (?after=<id>): сервер возвращает курсор next; устойчив к вставкам и дёшев для базы, но без произвольного доступа.
Paging 3
PagingSource.load(params) возвращает LoadResult.Page(data, prevKey, nextKey) или LoadResult.Error; ключом служит ваш номер страницы или курсор.
Pager(...).flow.cachedIn(viewModelScope), где cachedIn сохраняет загруженные страницы при повороте экрана.
collectAsLazyPagingItems() в Compose; loadState.refresh управляет полноэкранным состоянием, а loadState.append управляет строкой повтора внизу.
Офлайн с RemoteMediator
DAO из Room возвращает PagingSource, так что UI всегда наблюдает только базу.
Когда локальные данные заканчиваются, RemoteMediator.load(loadType, state) загружает следующую страницу и сохраняет строки и следующий курсор в одной транзакции.
initialize() решает, достаточно ли свежий кэш, чтобы пропустить первое обновление.
Для списка из пары десятков элементов Paging оказывается лишней сложностью.
Запускайте Retrofit против MockWebServer (настоящего HTTP-сервера на localhost), чтобы реально отработали OkHttp, ваши интерсепторы, конвертер и маппинг ошибок, и проверяйте и запрос, который отправил клиент, и то, как он обработал заготовленный ответ.
enqueue(MockResponse()) задаёт ответы по порядку; Dispatcher отвечает по пути, когда вызовы идут параллельно.
takeRequest() возвращает то, что клиент реально отправил: путь, query, заголовки от вашего интерсептора, сериализованное тело.
Политики сокета и задержки детерминированно воспроизводят обрыв соединения и таймауты.
Что стоит покрыть: payload, снятый с реального API; 401/404/500, превращённые в нужную доменную ошибку; битый JSON; неизвестное лишнее поле.
Держите слои честными: тесты репозитория используют фейковую реализацию API-интерфейса, тесты ViewModel используют фейковый репозиторий, и только небольшой набор на MockWebServer знает, что существует HTTP. В OkHttp 5 появился mockwebserver3 с неизменяемым MockResponse.Builder; классический API из примера по-прежнему используется в большинстве кодовых баз.
REST служит простым и дружелюбным к кэшу вариантом по умолчанию; GraphQL позволяет экрану получить ровно нужные поля за один round trip; gRPC даёт строгие контракты на Protobuf, компактные бинарные сообщения и стриминг ценой инструментов и потери HTTP-кэширования.
REST + JSON: понятен любому инструменту, работает стандартное HTTP-кэширование (ETag, CDN), легко отлаживать. Минусы: лишние данные в ответах, и одному экрану часто нужно несколько вызовов.
GraphQL: один endpoint и типизированная схема; клиент сам выбирает поля. Apollo Kotlin генерирует модели из ваших запросов и имеет нормализованный кэш. Минусы: запросы обычно POST, так что HTTP-кэширование теряется, если не использовать persisted queries через GET; бэкенду надо защищаться от дорогих запросов; ошибки часто приходят внутри ответа 200.
gRPC / Protobuf: сгенерированные стабы (grpc-kotlin или Wire от Square), маленькие бинарные сообщения, серверный и двунаправленный стриминг поверх HTTP/2. Минусы: нужен HTTP/2 на всём пути, сообщения не читаются глазами, а части прокси и браузерам нужен шлюз.
Эволюция схемы: Protobuf и GraphQL делают добавляющие изменения безопасными по устройству; REST полагается на дисциплину и ignoreUnknownKeys на клиенте.
Обычно этот выбор делает бэкенд. Задача мобильного инженера состоит в том, чтобы понимать, чего каждый вариант стоит в нестабильной сети: размер сообщений, число round trip-ов и можно ли кэшировать ответы.
WebSocket подходит для двустороннего обмена вроде чата, SSE нужен для одностороннего потока с сервера, long polling годится только как запасной вариант, а FCM берут для всего, что должно дойти до устройства, пока приложение не запущено.
Варианты
WebSocket: одно соединение после upgrade, по которому фреймы идут в обе стороны; в OkHttp это client.newWebSocket(request, listener). Интерсепторы применяются к рукопожатию, так что заголовки авторизации работают как обычно.
SSE: обычный HTTP-стриминг от сервера к клиенту, с id событий и переподключением прямо в протоколе; его реализует артефакт okhttp-sse. Переживает прокси, которые ломают upgrade.
Long polling: сервер держит каждый запрос, пока не появятся данные. Просто на сервере, хуже всего для батареи и числа соединений.
FCM: ни один сокет не переживает убитый процесс или Doze; push-сообщение будит приложение для синхронизации.
Что добавляет senior-ответ
Жизненный цикл: держать сокет открытым только пока есть подписчик (callbackFlow плюс WhileSubscribed) и закрывать в фоне.
Переподключение: экспоненциальная задержка с jitter, затем досинхронизация пропущенного по id последнего события или REST-вызовом.
Heartbeat: pingInterval, потому что NAT операторов молча рвут простаивающие соединения.
Изоляция: по умолчанию OkHttp выполняет не больше 5 вызовов на хост, и открытый WebSocket или SSE держит один из этих слотов, поэтому стримам дают свой Dispatcher и не ставят callTimeout.
@Streaming не даёт Retrofit буферизовать всё тело в памяти, а запрос с Range, начинающийся с размера частичного файла на диске, позволяет продолжить загрузку после обрыва соединения.
Потоком на диск
Без @Streaming весь ResponseBody читается в память ещё до возврата из функции; это и есть OOM.
С ним вы получаете живой поток: копируйте его в файл через Okio на Dispatchers.IO и закрывайте и source, и sink через use.
Пишите в файл .part и переименовывайте его по завершении, чтобы убитый процесс не оставил обрезанный файл, который выглядит готовым.
Докачка
Отправьте Range: bytes=<скачано>-. Ответ 206 Partial Content означает дописывать в конец; полный размер указан в Content-Range, а не в Content-Length.
Ответ 200 значит, что сервер проигнорировал диапазон, поэтому начинайте заново с нуля.
Отправляйте If-Range с ETag из первого ответа, чтобы изменившийся на сервере файл скачался заново, а не склеился из двух версий.
Где это выполняется
callTimeout покрывает и тело, поэтому для загрузок держите его равным 0 и полагайтесь на readTimeout.
Запускайте это в CoroutineWorker из WorkManager с ограничением по сети и setForeground; viewModelScope умирает вместе с экраном.
Для файла, который пользователь увидит в общем хранилище, всё это уже делает системный DownloadManager.