Сетевой стек · Анимированный гайд

Жизненный цикл URLSession

От конфигурации сессии и сборки URLRequest до фоновых задач, делегатов, WebSocket, кэша и грамотного завершения. Разбор каждой стадии, лучшие практики и нестандартные кейсы — всё, что должен знать iOS-разработчик о главном сетевом API системы.

34 раздела Полная диаграмма этапов Async/Await Background tasks WebSocket Best practices

01Что такое URLSession и зачем разбирать жизненный цикл

URLSession — это центральный API Foundation для работы с HTTP/HTTPS, FTP, WebSocket и фоновой передачей данных. Внутри Apple-платформ он стоит на CFNetwork, на Linux — на собственной реализации Swift Foundation. Когда мы видим в коде URLSession.shared.data(from:) — за этой одной строкой скрывается длинный конвейер: создание задачи, разрешение DNS, TLS-хендшейк, отправка заголовков, потоковое чтение тела ответа, обновление кэша, вызов делегатов и финальная очистка ресурсов.

Если бы это был UIKit, мы бы говорили про viewDidLoad → viewWillAppear → viewDidAppear. Если SwiftUI — про init → body → onAppear → onDisappear. У URLSession свой собственный, не менее структурированный жизненный цикл — и тот, кто знает его в деталях, пишет код, который не падает в плохом метро, корректно докачивает файлы при перезапуске приложения и не съедает у пользователя гигабайт трафика на повторные запросы.

Эта статья — попытка пройти по этому циклу шаг за шагом. Цикл удобно раскладывать на семь стадий: на каждой можно показать анимациями, что именно происходит, и связать её с реальными проблемами, с которыми мы сталкиваемся в продакшене — фоновая загрузка, обрыв соединения, обработка HTTPURLResponse, отмена при скролле, защита от повторных запросов, retry с экспоненциальным бэкоффом, корректное завершение сессий. Многие из этих тем подробно разбирались в отдельном посте о URLSession в канале — здесь мы соберём их в одну карту.

Почему вообще стоит знать API настолько глубоко, если есть Alamofire, AsyncHTTPClient, Moya, MultipartFormDataKit? Потому что все они построены поверх. Когда что-то ломается — а оно обязательно ломается — отлаживать всегда приходится в терминах URLSession, URLSessionDelegate, URLSessionTaskMetrics. Знание базового API — это не альтернатива библиотекам, это фундамент. И это лучшее, что вы можете сделать для своего сетевого слоя.

Ещё один аргумент: с появлением Swift Concurrency (см. пост про Concurrency) URLSession получил полноценный async/await-интерфейс, а с iOS 18 — улучшенную поддержку фоновых сессий и более удобный API метрик. Старое наследие никуда не делось: делегаты, конфигурации, кэширование, фоновая идентификация — всё это работает и сегодня, просто часть упаковок стала лаконичнее. Чтобы выбирать осознанно, нужно видеть всю картину.

Дальше будет много кода, диаграмм и анимаций. Если читаете с телефона — листайте без спешки, диаграммы рассчитаны и на мобильный экран. Если читаете с компьютера — обратите внимание на главную анимацию ниже: она показывает все семь стадий жизненного цикла в одном экране и подсвечивает каждую по очереди.

02Полный конвейер URLSession

Ниже — главная анимация статьи. Она показывает все семь стадий, через которые проходит каждый сетевой запрос. Стадии подсвечиваются по очереди: сначала система готовит конфигурацию, затем собирается URLRequest, создаётся задача, вызывается resume(), идёт сетевая работа, обрабатывается ответ и, наконец, всё завершается — успехом или ошибкой.

Схема · Полный жизненный цикл URLSession
STAGE 01
⚙︎
Configuration
default · ephemeral · background
STAGE 02
📝
URLRequest
URL, headers, body, policy
STAGE 03
🏷
Create Task
data / download / upload / ws
STAGE 04
▶︎
resume()
suspended → running
Жизненный цикл Семь стадий, через которые проходит каждая сетевая задача — от конфигурации сессии до её завершения.
STAGE 07
✓
Complete
cleanup · retry · cancel
STAGE 06
📦
Response
status · body · cache
STAGE 05
📡
Network I/O
DNS · TLS · bytes
S1·S2 Подготовка: что и куда мы хотим отправить, под какие правила игры.
S3·S4 Контейнер задачи и её запуск. Задача рождается в .suspended.
S5 Сетевой стек: разрешение хоста, TLS-хендшейк, передача байт.
S6 Парсинг ответа, обновление URLCache, валидация статуса.
S7 Завершение: успех, ошибка, отмена, retry, освобождение ресурсов.

Эта схема — каркас всей статьи. Каждой стадии посвящён отдельный раздел ниже: со своей анимацией, кодом и предостережениями. Можно читать линейно, можно прыгать по содержанию. Но прежде чем нырять в детали, заметим важное: жизненный цикл — не строго линейный. На любом этапе задача может перейти в .canceling, на седьмом — рестартовать с нуля (retry), на пятом — приостановиться и продолжиться позже (background). Анимация показывает «счастливый путь», но любая реальная сетевая работа — это граф, а не лента.

Важно

Конфигурация сессии задаётся один раз, в момент создания сессии. Менять её у живой сессии нельзя — Apple явно пишет об этом в документации. Все мутации делаются на новой сессии или через URLRequest, если параметр доступен на уровне запроса.

03Этап 1. Конфигурация сессии

Сетевой жизненный цикл начинается не с URL, а с того, в каком мире мы будем жить. URLSessionConfiguration описывает правила игры: разрешено ли использовать сотовую сеть, как кэшировать ответы, сколько секунд ждать соединения, какие сервисы устанавливать в качестве прокси, какие cookies переиспользовать. Это «вселенная» для всех будущих задач, которые мы создадим у этой сессии.

Три встроенных конфигурации

Foundation предоставляет три типичные конфигурации — это заготовки, которые покрывают 90% случаев. Все они мутабельны до момента создания сессии. Дальше — read-only.

.default

Обычные запросы

Самая универсальная сессия. Использует системные cookies, кэш на диске и в памяти, постоянное хранилище учётных данных. Подходит для большинства REST-вызовов.

URLCache cookies credentials
.ephemeral

Приватный режим

Ничего не пишет на диск. Ни кэш, ни cookies, ни credentials. Идеальна для приватного браузера, одноразовых сессий, токен-обмена.

in-memory no disk privacy
.background(identifier:)

Фоновая передача

Загрузки и выгрузки, которые система продолжит выполнять, даже если приложение убито. Требует уникальный идентификатор и обязательный делегат.

background discretionary launch-on-finish

Эту тройку важно держать в голове. Если вы пишете API-клиент общего назначения — стартуйте с .default. Если делаете отдельную «инкогнито»-секцию или хотите гарантировать, что сервер не получит ваши старые cookies — берите .ephemeral. Если задача переживёт жизнь приложения — нужна .background.

Что настраивается на конфигурации

let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest  = 15     // сек до неактивности
config.timeoutIntervalForResource = 60     // сек на всю задачу
config.httpMaximumConnectionsPerHost = 4
config.allowsCellularAccess      = true
config.allowsExpensiveNetworkAccess = false  // hotspot, дорогой роуминг
config.allowsConstrainedNetworkAccess = false // Low Data Mode
config.waitsForConnectivity      = true   // ждать сеть, а не падать сразу
config.requestCachePolicy        = .returnCacheDataElseLoad
config.httpAdditionalHeaders     = ["X-Client": "iOS-3.4"]

let session = URLSession(configuration: config)

Несколько важных пунктов, которые часто упускают:

Когда нужна своя сессия, а когда хватит shared

URLSession.shared — нормальный выбор для разовых запросов в коде вне сетевого слоя: «дёрнуть фид статусов», «получить превью». Но как только речь о центральном API-клиенте, лучше создавать свою сессию с явной конфигурацией: тогда легко менять политику кэша, добавлять авторизационные заголовки в один заход, тестировать через URLProtocol-моки и подменять её в DI-контейнере.

Подводный камень

Сессии — это дорогие объекты. Не создавайте новую URLSession для каждого запроса: внутри пул соединений, который теряется при пересоздании. Один синглтон на сетевой клиент — норма.

На этом этапе мы ещё не отправили ни одного байта. Но именно решения, принятые здесь, определят, как поведёт себя приложение в плохом интернете, в Low Data Mode, в фоне и при перезапуске устройства.

04Этап 2. Сборка URLRequest

Запрос — это конкретное «куда» и «как». Если сессия задаёт мир, то URLRequest описывает один конкретный поход в этот мир. У запроса есть URL, метод, заголовки, тело, политика кэша, политика повторных перенаправлений, политика сетевого сервиса (например, .responsiveData — для запросов, важных пользователю прямо сейчас).

ClientiOS app
Serverapi.example.com
Запрос → ответ: одно «биение» цикла

Как собирается URL

Самая частая боль — пытаться склеить URL строками. Это работает, пока у вас не появятся пробелы, кириллица, query-параметры с амперсандами или ASCII-несовместимые символы. Современный подход — URL.appending(queryItems:) и URLComponents. Это рассматривалось отдельно в посте про современные практики работы с URL: там есть примеры, как Apple постепенно убирает легаси-инициализаторы и заводит безопасные альтернативы.

var components = URLComponents(string: "https://api.example.com/v3/items")!
components.queryItems = [
  URLQueryItem(name: "q",    value: "привет мир"),
  URLQueryItem(name: "page", value: "1"),
  URLQueryItem(name: "per",  value: "20"),
]

var request = URLRequest(url: components.url!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(payload)
request.timeoutInterval = 10
request.cachePolicy     = .reloadIgnoringLocalCacheData

Политики кэширования

На уровне запроса cachePolicy позволяет переопределить решение, заданное в конфигурации:

Allows-cellular на уровне запроса

Не все знают, но политика «можно ли по сотовой» задаётся и на запросе через request.allowsCellularAccess. Если конфигурация разрешает, а конкретный «тяжёлый» запрос мы хотим выполнять только по Wi-Fi — выставляем флаг прямо на нём. Сочетание двух уровней даёт точный контроль: «качай прелоад изображений по Wi-Fi, а CRUD-операции — по любой сети».

Хорошая практика

Не складывайте логику авторизации в каждое место, где создаётся URLRequest. Заведите построитель (RequestBuilder), который принимает на вход эндпоинт и сам подмешивает токены, общий User-Agent, корреляционный ID и язык. Любая попытка добавить заголовок в десятке мест заканчивается одним «забыли — упало в проде».

05Этап 3. Создание задачи

Сессия и запрос — это описания. Задача — это исполнение. URLSession предоставляет несколько типов задач, и выбор правильного типа влияет на всё: на потребление памяти, на возможность выполнять работу в фоне, на способ обработки данных.

Тип задачи Когда использовать Особенности
URLSessionDataTask REST/JSON, маленькие и средние ответы, in-memory. Данные накапливаются в памяти. Не работает в фоне.
URLSessionDownloadTask Загрузка файлов, медиа, обновлений. Пишет во временный файл. Можно в фоне.
URLSessionUploadTask Отправка файлов, мультипарт-форм, бэкап. Поддерживает fromFile, что важно для фоновой выгрузки.
URLSessionStreamTask Произвольный TCP-стрим над URL (редкие случаи). Низкоуровневое, чаще берут Network.framework.
URLSessionWebSocketTask Двунаправленный обмен сообщениями. iOS 13+. Сам управляет фреймингом и пингами.

Правило простое: под крупные файлы — download/upload, под JSON — data, под двусторонний поток — websocket. Многие используют data-задачу для всего, и в итоге получают «я не могу запустить загрузку в фоне» или «память на 200 МБ улетела на одном файле». Подробный разбор того, какой тип под какие задачи подходит, есть в отдельном посте про DownloadTask.

Создание задачи в трёх стилях

// 1. Completion handler (старый, простой, до сих пор живой)
let t1 = session.dataTask(with: request) { data, response, error in
    // колбэк зовётся на delegateQueue (по умолчанию — фоновая)
}
t1.resume()

// 2. Делегат (полный контроль, прогресс, аутентификация)
let t2 = session.downloadTask(with: request)
t2.resume()

// 3. async/await (iOS 15+, самый чистый)
let (data, response) = try await session.data(for: request)

Самое важное на этом этапе: задача не стартует сама. Создав её, вы получаете объект в состоянии .suspended. Запуск — отдельным шагом, resume(). Это сделано намеренно: вы можете повесить делегата, прицепить метаданные, поменять приоритет, добавить в свой реестр для отмены — и только после этого нажать «старт».

Частая ошибка

Метод dataTask(with:) возвращает задачу, но не запускает её. Если забыть resume(), запрос просто никогда не уйдёт — а в логах ничего не появится. Это самая частая «ничего не работает, и я не понимаю почему».

06Этап 4. resume(): задача стартует

Метод resume() переводит задачу из .suspended в .running. С этого момента сессия отдаёт работу в свой внутренний пул, и URLSession готов к жизни в реальном мире. У задачи есть стейт-машина из четырёх состояний, и нам важно понимать, между какими переходы возможны, а между какими — нет.

Четыре состояния URLSessionTask
.suspended
.running
.canceling
.completed
после create() / suspend() после resume() после cancel() финальное

Что важно знать про переходы

Знание этих переходов критично для пары проблем:

// Отмена при уходе с экрана — классика
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    currentTask?.cancel()
}

// Приоритет — простая, но недооценённая фича
task.priority = URLSessionTask.highPriority   // 1.0
task.priority = URLSessionTask.defaultPriority // 0.5
task.priority = URLSessionTask.lowPriority    // 0.0

Приоритет — подсказка планировщику. Если у вас параллельно тянутся скучные превью и важный «контент текущего экрана» — повышайте приоритет последнего. Это особенно заметно при медленной сети, где порядок выгрузки превращается в порядок UX.

07Этап 5. Сетевой ввод-вывод

Это самый длинный этап и единственный, который мы реально не контролируем. Внутри происходит:

  1. DNS-резолвинг. Хост превращается в IP. Кэш у системы свой, обычно несколько секунд.
  2. TCP-соединение. Трёхстороннее рукопожатие, если не используется QUIC.
  3. TLS-хендшейк. Сертификаты, цепочка доверия, шифры. Тут же отрабатывает SSL pinning, если вы его подключили.
  4. HTTP-запрос. Отправка заголовков и тела.
  5. HTTP-ответ. Получение headers, статуса, потоковое чтение тела.

Каждый шаг занимает время и может провалиться. Apple даёт нам прекрасный инструмент для замеров — URLSessionTaskMetrics. Это объект, который приходит в делегата после завершения задачи и содержит детальные тайминги для каждого «транзакционного» соединения внутри неё.

func urlSession(_ session: URLSession, task: URLSessionTask,
                didFinishCollecting metrics: URLSessionTaskMetrics) {
    for m in metrics.transactionMetrics {
        print("DNS:  \(m.domainLookupEndDate!.timeIntervalSince(m.domainLookupStartDate!))")
        print("TLS:  \(m.secureConnectionEndDate!.timeIntervalSince(m.secureConnectionStartDate!))")
        print("TTFB: \(m.responseStartDate!.timeIntervalSince(m.requestStartDate!))")
        print("Protocol: \(m.networkProtocolName ?? \"-\")")
    }
}

Логирование метрик в проде — лучший способ найти, где именно проседает сеть. TTFB (time to first byte) — это про сервер. DNS и TLS — про инфраструктуру и провайдера. Полезно дополнительно вытаскивать networkProtocolName — там можно увидеть h2, h3 или http/1.1 и понять, кто из бэкенда не перешёл на HTTP/2.

Что значит «потоковое» чтение

Когда речь о большом ответе, важно понимать: с completion handler URLSession не отдаст вам байты по мере прихода. Он соберёт ответ полностью, потом вызовет колбэк. Если вы хотите стримить — нужен делегат и метод urlSession(_:dataTask:didReceive data: Data). С iOS 15 появился изящный async-вариант: session.bytes(for:) — он возвращает AsyncSequence байтов, и можно итерироваться по строкам.

let (bytes, _) = try await session.bytes(for: request)
for try await line in bytes.lines {
    // каждая строка SSE-стрима, лога, NDJSON
    process(line)
}
Совет

Server-Sent Events и NDJSON-стримы (например, ответы от LLM) идеально стримятся через .lines. Это пара строк против самописного парсера.

08Этап 6. Ответ, кэш, валидация

Когда сервер прислал заголовки, URLSession может позвать делегата urlSession(_:dataTask:didReceive response:completionHandler:) — и спросить нас, продолжать ли. В большинстве случаев мы отвечаем .allow, но это место, где можно отменить заведомо плохие ответы (например, статус 401 без права на retry).

HTTPURLResponse: ваш главный союзник

Возвращаемый URLResponse — это абстрактный тип. У HTTP-запросов он на самом деле HTTPURLResponse, и от его статуса зависит, считаем ли мы запрос успешным. URLSession сам считает любой ответ с заголовками «успешным» — даже 500. Ошибку он выдаст только при сетевой проблеме (соединение разорвалось, TLS не сошёлся). Поэтому валидация — на нашей стороне.

struct HTTPError: Error { let status: Int; let data: Data }

func validate(_ data: Data, _ response: URLResponse) throws -> Data {
    guard let http = response as? HTTPURLResponse else {
        throw URLError(.badServerResponse)
    }
    switch http.statusCode {
    case 200...299: return data
    case 401:        throw AuthError.expired
    case 429:        throw HTTPError(status: 429, data: data)
    default:        throw HTTPError(status: http.statusCode, data: data)
    }
}

В отдельном посте разобрано, как удобно превращать коды ошибок в человекочитаемые сообщения через HTTPURLResponse.localizedString(forStatusCode:). Это статический метод, который возвращает локализованную строку вроде «not found» или «request timeout». Удобно для лога, не для пользователя.

Кэш — забытый герой

Если сервер прислал Cache-Control: max-age=3600, а у вас URLCache на 50 МБ диска, следующий идентичный запрос даже не пойдёт в сеть. Это бесплатная оптимизация, которую любит ронять кастомный сетевой клиент.

Cache Hit · ответ из кэша

Запрос совпал по URL и валидным заголовкам. Ответ берётся из URLCache и колбэк отрабатывает мгновенно.

Cache Miss · поход в сеть

Нет валидной записи. Запрос уходит в сеть и, в случае успеха, оседает в кэше для следующего вызова.

Тонкость: URLCache по умолчанию кэширует только ответы на безопасные методы — практически это GET. Это следствие HTTP cache semantics из RFC 7234 §4, которому подчиняется реализация (см. также правила кэширования в Apple Developer · URLCache и условия вызова willCacheResponse). POST вы, как правило, кэшируете самостоятельно — в памяти или через отдельную БД. И не забывайте про URLRequest.cachePolicy: иногда нужно явно сказать «иди в сеть», например при pull-to-refresh.

Редиректы

URLSession сама обрабатывает 30x по умолчанию. Если хочется вмешаться (например, заменить редирект на собственный экран авторизации), реализуем urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:). Можно подставить нужный URLRequest или передать nil, чтобы редирект не выполнился.

Антипаттерн

Парсить тело ответа до проверки HTTPURLResponse.statusCode. Сервер может вернуть HTML-страницу ошибки или пустой JSON — и ваш JSONDecoder сломается на «непонятный» декод, хотя истинная проблема — 502.

09Этап 7. Завершение, отмена, retry

Финальная стадия — это .completed. Сюда приходим тремя способами: успех, ошибка, отмена. Все три ветви обязательно вызывают urlSession(_:task:didCompleteWithError:), где error — либо nil при успехе, либо инстанс URLError.

Анатомия URLError

URLError — это Error с богатой информацией. Полезные коды:

Идеи для умного retry

Слепо повторять запрос — плохая стратегия. Хороший retry смотрит на код ошибки и решает: имеет ли смысл повторять, через сколько и сколько раз.

Экспоненциальный бэкофф между попытками
attempt 1
0.5 s
attempt 2
1.0 s
attempt 3
2.0 s
attempt 4
4.0 s
attempt 5
8.0 s
func retrying<T>(max: Int = 4, _ op: () async throws -> T) async throws -> T {
    var attempt = 0
    while true {
        do { return try await op() }
        catch let err as URLError where shouldRetry(err) {
            attempt += 1
            guard attempt <= max else { throw err }
            let base = pow(2.0, Double(attempt - 1)) * 0.5
            let jitter = Double.random(in: 0...0.3)
            try await Task.sleep(for: .seconds(base + jitter))
        }
    }
}

Главный приём — jitter. Если 10 000 клиентов попадают в одну ошибку 503 и все повторяют через ровно 1 секунду, вы получаете «грозовую тучу». Случайная добавка размазывает нагрузку.

Когда retry — это плохая идея

Лайфхак

Используйте Idempotency-Key в заголовках для POST/PUT, чтобы безопасно повторять. Многие современные API поддерживают этот заголовок «из коробки» — см., например, Stripe Idempotent Requests и Square Idempotency. Точные имена и формат у каждого свои (где-то встречается Idempotence-Key с другим написанием), сверяйтесь с документацией конкретного сервиса.

10Три типа конфигурации детальнее

Мы кратко прошлись по конфигурациям в начале. Теперь — глубокое сравнение, потому что от выбора зависит судьба ваших данных. Особенно интересен .background — это не «обычная сессия, но в фоне», это принципиально другой режим работы, в котором даже общение с задачами идёт через делегата.

Свойство.default.ephemeral.background
Хранение кэшадискпамятьдиск
Cookiesсистемныев памятисистемные
CredentialsKeychainсессияKeychain
Работа в фоненетнетда
Completion handlerдаданет, только делегат
Идентификаторне нуженне нуженобязателен и уникален
Что внутри задачилюбые типылюбые типыupload/download из файла
discretionaryнетнетда (Wi-Fi, заряд)

Когда брать ephemeral

Идеален в трёх случаях: режим «инкогнито», тестовые сборки, временные обмены токенами (там, где не нужны cookies между запросами). Ещё это удобный способ убедиться, что ваш слой авторизации не «забывает» куки от предыдущей сессии — стартуйте интеграционные тесты в ephemeral.

Когда брать background

Фоновая сессия — это контракт с системой: «продолжай этот upload/download, даже если приложение умрёт, и разбуди приложение, когда закончится». Платой за это становятся ограничения: нельзя ставить тело запроса в память, нужно ставить файлом; нельзя использовать completion handler, только делегата; appDelegate должен сохранить backgroundCompletionHandler, который система вызывает на пробуждении. Тонкости — в посте про подводные камни фоновой загрузки.

let id = "com.iosiq.bgsession"
let bgConfig = URLSessionConfiguration.background(withIdentifier: id)
bgConfig.isDiscretionary = true             // система выберет момент (Wi-Fi, зарядка)
bgConfig.sessionSendsLaunchEvents = true    // разбудить приложение
bgConfig.isWaitingForConnectivity = true

let session = URLSession(configuration: bgConfig,
                         delegate: BackgroundDownloader.shared,
                         delegateQueue: nil)
Фоновая загрузка продолжается без приложения
Иконка приложения гаснет, когда пользователь свайпает наверх, но фоновая сессия живёт. Прогресс продолжается под управлением nsurlsessiond, и система разбудит приложение по завершении.
foregroundbackgroundterminatedrelaunchcomplete

Важная деталь: после перезапуска приложения вы пересоздаёте сессию с тем же идентификатором. URLSession при этом возвращает уже выполняющиеся задачи через делегата. Никакого «сохранить ID задачи в UserDefaults и потом достать» — система сама помнит весь свой контекст.

Тонкость

В фоновой сессии запрещены задачи с телом из Data. Только URLSessionUploadTask с fromFile:. Если попробуете передать httpBody — система молча проигнорирует или вернёт ошибку. Это не баг, это намеренно: фоновый демон не должен полагаться на in-memory данные приложения.

11Каскад делегатов

Когда вы используете completion handler, делегаты у задачи всё равно вызываются — просто вы их не видите. Если же передать сессии собственный URLSessionDelegate, открывается доступ к десяткам колбэков. Это «низкоуровневый» API, и именно он позволяет реализовать прогресс загрузки, аутентификацию по сертификату, кастомные редиректы.

Иерархия делегатов:

Каскад делегатов на одной задаче
SESSIONurlSession(_:didReceive challenge:)// TLS / pinning
TASKurlSession(_:task:willBeginDelayedRequest:)// для waitsForConnectivity
TASKurlSession(_:taskIsWaitingForConnectivity:)// нет сети, ждём
DATAurlSession(_:dataTask:didReceive response:)// заголовки пришли
DATAurlSession(_:dataTask:didReceive data:)// каждый чанк
TASKurlSession(_:task:didFinishCollecting metrics:)// метрики
TASKurlSession(_:task:didCompleteWithError:)// финал

Аутентификация: pinning и basic auth

Самый востребованный делегат — didReceive challenge. Его зовут на каждое «защищённое» событие: сервер просит сертификат, прокси — пароль, HTTPS — проверку pinned-сертификата.

func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @Sendable @MainActor (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let trust = challenge.protectionSpace.serverTrust
    else {
        completionHandler(.performDefaultHandling, nil)
        return
    }

    if isTrusted(serverTrust: trust) {
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

SSL pinning — отдельная большая тема. Коротко: вы сравниваете отпечаток сервера с зашитым в приложение. Если совпало — пускаем. Иначе — отказ. Это блокирует MITM-атаки в публичных сетях и подобные сценарии.

12async/await: новый облик старого API

С iOS 15 у нас три практически эквивалентных по возможностям async-метода:

Каждый из них поддерживает кооперативную отмену через Task.cancel(). То есть никакого «достать dataTask, чтобы отменить руками» — отмена идёт по дереву задач Swift Concurrency.

struct APIClient {
    let session: URLSession

    func items(page: Int) async throws -> [Item] {
        var request = URLRequest(url: URL(string: "https://api.example.com/items?page=\(page)")!)
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await session.data(for: request)
        try validate(data, response)
        return try JSONDecoder().decode([Item].self, from: data)
    }
}

// Использование
let task = Task {
    let items = try await api.items(page: 1)
    render(items)
}
// task.cancel() — корректная отмена сетевого запроса по цепочке

В посте про API-клиент на async/await разобран паттерн «один билдер запроса + один валидатор + один декодер». Это базовый каркас, который позволяет не плодить десятки сетевых функций.

Несколько запросов параллельно

С async/await параллелить — одно удовольствие:

async let profile = api.profile(id: userID)
async let feed    = api.feed()
async let notif   = api.notifications()

let (p, f, n) = try await (profile, feed, notif)

Все три запроса стартуют одновременно, мы ждём всех. Если какой-то упал — упадёт и общая операция, остальные будут отменены через Task.cancel().

Совет

Не отказывайтесь от старого делегатского API целиком. async-методы — для большинства случаев, но если нужны метрики, прогресс или сложная аутентификация — делегат всё ещё нужен. Можно создать сессию с делегатом и при этом дёргать у неё data(for:).

13Состояния задачи в большом плане

Мы уже видели анимацию состояний. Здесь — практические рецепты для каждого перехода.

.suspended → .running

Стандартный путь. Можно осознанно создавать задачи в .suspended, складывать в очередь и запускать партиями — например, при возобновлении доступа к сети.

.running → .suspended

«Пауза, но не отмена». Используется редко — когда вы хотите придержать загрузку, но не хотите терять прогресс. Например, при просмотре фотогалереи: пользователь свайпает быстро, и вы ставите тяжёлые preview на паузу, чтобы дать дорогу видимым.

.running → .canceling

Самая частая отмена — при уходе с экрана. Для download-задач есть особый рецепт: cancel(byProducingResumeData:). Он отменяет задачу, но даёт Data, по которой можно возобновить загрузку позже:

downloadTask.cancel(byProducingResumeData: { resumeData in
    // сохранить на диск
    store(resumeData)
})

// позже:
let resumed = session.downloadTask(withResumeData: load())
resumed.resume()

Это «святой грааль» для загрузчиков медиа: пользователь свернул приложение, через час открыл — и фильм качается с того места, где остановился.

.canceling → .completed

Финальный шаг отмены. URLError(.cancelled) приходит в делегата. Важно: ваша completion-функция тоже зовётся — обработайте отмену тихо, без алертов «что-то пошло не так».

if let urlErr = error as? URLError, urlErr.code == .cancelled {
    return // никаких сообщений пользователю
}

14WebSocket и стримы

URLSessionWebSocketTask — официальный способ работать с WebSocket в iOS 13+. Это аккуратная замена сторонним библиотекам вроде Starscream для большинства случаев. Сообщения двух типов — .string и .data. Подробно работу через async-итератор можно посмотреть в этом посте.

WebSocket · двусторонний обмен сообщениями
Client
Server
ping/pong автоматический fragmented messages tls по wss:// completion + delegate
let task = session.webSocketTask(with: URL(string: "wss://api.example.com/ws")!)
task.resume()

// отправка
try await task.send(.string("{\"event\":\"subscribe\"}"))

// чтение через async-цикл
while true {
    let message = try await task.receive()
    switch message {
    case .string(let text): handleText(text)
    case .data(let data):  handleData(data)
    @unknown default: break
    }
}

Хорошие практики WebSocket:

Что ломается

WebSocket-задача не возобновится по resume-data, как download. Если соединение упало — это новое соединение, новая задача, новая аутентификация. Учитывайте это в дизайне протокола: восстанавливайте состояние через идемпотентные subscribe-сообщения.

15URLCache: незаслуженно забытый герой

Этот раздел стоит выделить отдельно. Многие команды тратят недели на собственный кэш, тогда как URLCache уже встроен и работает на двух уровнях — память и диск.

let memory = 20 * 1024 * 1024     // 20 МБ
let disk   = 200 * 1024 * 1024    // 200 МБ
let cache  = URLCache(memoryCapacity: memory,
                       diskCapacity: disk,
                       directory: nil)
config.urlCache = cache

Что важно знать:

В канале есть отдельный разбор кэширования — целая статья со схемами стратегий обновления: TTL, версионирование, инвалидация при логауте, плюсы и минусы каждого подхода. Здесь не повторяемся, но URLCache — это первый слой, и часто его одного хватает.

16waitsForConnectivity: бесшумно ждать сеть

До iOS 11 поведение по умолчанию было драматичным: запрос без сети падал с .notConnectedToInternet, и приложению приходилось самому подписываться на reachability, ждать, перезапускать. С появлением waitsForConnectivity мы можем переложить эту работу на систему.

Ожидание сети. Антенна пульсирует, пока URLSession ждёт появления подключения. Запрос не падает с ошибкой — он висит «на низком старте», и как только сеть появилась, начинает работу.

Главное место для понимания — делегат urlSession(_:taskIsWaitingForConnectivity:). Его зовут, когда сети нет и задача ждёт. В этот момент вы можете показать ненавязчивый индикатор «ждём интернет» и обновлять его, если ситуация затянется.

config.waitsForConnectivity    = true
config.timeoutIntervalForResource = 60 * 60  // 1 час, чтобы реально подождать

extension MyNetworkDelegate: URLSessionTaskDelegate {
    func urlSession(_ session: URLSession,
                    taskIsWaitingForConnectivity task: URLSessionTask) {
        Logger.net.info("Waiting for connectivity for \(task.currentRequest?.url?.host ?? \"-\")...")
    }
}
Решение в одну строку

Если у вас «вечно» бывают пользователи в метро — выставьте waitsForConnectivity = true и timeoutIntervalForResource побольше. UX мгновенно улучшится: люди привыкли, что приложения сразу пишут «нет сети», но ничто не мешает работать иначе.

17Лучшие практики, проверенные продом

1. Один URLSession на сетевой клиент

Повторим важное: не создавайте сессии под каждый запрос. Один синглтон на API-клиент — норма. Если у вас несколько контекстов (основной API, аналитика, фоновая загрузка) — заведите по сессии на каждый.

2. Логируйте метрики

В проде включите сбор URLSessionTaskMetrics и шлите в систему мониторинга. Время DNS, TLS, TTFB, размер тела, протокол — это ваши «глаза» на стороне клиента. Без них вы будете долго гадать, у кого проблема — у бэка, у провайдера или у вашего кода.

3. Сделайте обёртку над URLRequest

Никаких голых URLRequest в репозиториях. Заведите Endpoint-протокол или enum со всеми эндпоинтами. Это даст вам автодополнение, единый стиль, лёгкое тестирование.

4. Используйте URLProtocol для тестов

Mock-сессий через подмену shared — это плохо. URLProtocol позволяет перехватывать запросы прозрачно и подсовывать любые ответы. Подключается в URLSessionConfiguration.protocolClasses, и тесты выполняются без сети.

final class MockURLProtocol: URLProtocol {
    static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let handler = MockURLProtocol.handler else { return }
        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
    override func stopLoading() {}
}

5. Отмена — на каждом экране

Когда пользователь уходит со списка, отменяйте все его сетевые задачи. Это и про экономию батареи, и про то, чтобы не получить «опоздавший ответ», который перезапишет состояние нового экрана.

6. Идемпотентность для POST

POST по сути неидемпотентен — повторный вызов может создать дубль. Если ваш API поддерживает Idempotency-Key — используйте. Если нет — сделайте свой идентификатор на клиенте (UUID) и логируйте, какие операции уже завершились.

7. Уважайте Low Data Mode

Если пользователь включил режим экономии, не качайте предзагрузки. allowsConstrainedNetworkAccess = false — это уважение, а не ограничение.

8. Не парсите ошибки только по тексту

Сервер может прислать локализованную ошибку, и завтра она будет на другом языке. Парсите по кодам, по структуре. Тексты — для пользователя.

9. Decoder — один

Не создавайте JSONDecoder() на каждый запрос. Это не критично, но за тысячи запросов накапливается. И не забывайте про .iso8601, .convertFromSnakeCase и кастомные стратегии.

10. delegateQueue — отдельный OperationQueue

Если ваши делегаты делают что-то заметное (логируют, парсят), не лейте всё в main thread. Передайте сессии собственный OperationQueue с разумным maxConcurrentOperationCount.

Чек-лист в одной строке

Одна сессия на клиент, метрики в проде, обёртка над URLRequest, тесты через URLProtocol, отмена на каждом экране, идемпотентность POST, уважение Low Data Mode, разумный JSONDecoder, отдельный delegateQueue.

18Необычные кейсы, которые пригодятся

Кейс 1. Скачать большой файл «когда удобно системе»

Включаем isDiscretionary = true у фоновой сессии — и iOS сама выбирает момент: предпочтительно Wi-Fi, в идеале с зарядкой. Идеально для обновлений контента, словарей, моделей машинного обучения. Пользователь не платит за мобильный трафик, а вы получаете гарантированную загрузку — пусть и не мгновенную.

Кейс 2. Загрузка с продолжением через дни

timeoutIntervalForResource может быть огромным — Apple сама пишет, что 7 дней нормально. В сочетании с waitsForConnectivity и фоновой сессией вы получаете «качай, когда сможешь» — пользователь начал загрузку фильма в самолёте, открыл приложение через два дня — и фильм уже на диске.

Кейс 3. Кастомный URLProtocol для оффлайн-режима

Зарегистрируйте свой URLProtocol, который проверяет наличие записи в локальной БД и отдаёт её как ответ. Сетевой код не меняется — он просто думает, что запрос ушёл и вернулся. Удобно для офлайн-первых приложений и тестовых сборок без интернета.

Кейс 4. DNS-over-HTTPS для приложения

С iOS 14+ можно настроить DoH (или DoT) на уровне отдельного приложения — через NEDNSSettingsManager из NetworkExtension либо через privacy-контекст в Network.framework (nw_privacy_context_t). После активации все запросы URLSession в этом приложении пойдут через зашифрованный резолвер, а в URLSessionTaskMetrics можно увидеть, сколько занял DNS.

Кейс 5. Локальный HTTP-сервер вместо unix-сокета

URLSession не поддерживает unix://-схему напрямую: официально работают только HTTP(S), FTP, file и data URL. Unix-сокеты умеет Network.framework и серверные библиотеки вроде AsyncHTTPClient. Если нужна локальная межпроцессная коммуникация в обычном iOS-приложении, проще поднять loopback-HTTP-сервер на http://127.0.0.1:port и ходить туда обычными data-задачами — весь существующий сетевой слой переиспользуется без изменений.

Кейс 6. Многошаговая загрузка с предвидением

Иногда мы знаем, что после первого ответа будет ещё пять — например, листинг страниц. Можно предзагрузить следующую страницу в момент, когда пользователь дочитал до середины текущей. Маленький приоритет (URLSessionTask.lowPriority) + параллельный запуск — и переходы становятся мгновенными.

Кейс 7. Прерывание по таймауту бизнес-логики

Внутренний таймаут URLSession — это таймаут сети. Но бывает, что вы хотите ограничить операцию по времени бизнес-логики (например, поиск должен ответить за 2 секунды, иначе мы показываем «пока пусто»). Оборачиваем async-запрос в Task и сами вызываем cancel() через Task.sleep(for: .seconds(2)).

func withTimeout<T>(_ seconds: Double, _ op: @escaping () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await op() }
        group.addTask {
            try await Task.sleep(for: .seconds(seconds))
            throw URLError(.timedOut)
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

Кейс 8. Загрузка только при Wi-Fi и зарядке

Конфигурация .background с isDiscretionary учитывает условия. Но можно жёстче — самостоятельно ставить задачу только при условиях. Используем NWPathMonitor и проверяем path.isExpensive == false и UIDevice.current.batteryState == .charging. Запускаем загрузку только тогда.

Кейс 9. Загрузка с прогрессом и пре-валидацией размера

Зная Content-Length, можно отказаться от загрузки, если файл оказался слишком большим. В делегате didReceive response проверяем response.expectedContentLength и при превышении лимита возвращаем .cancel.

Кейс 10. Аутентификация по сертификату клиента

Сервер может требовать клиентский сертификат (mTLS). В делегате didReceive challenge мы достаём SecIdentity из связки ключей и возвращаем URLCredential(identity:certificates:persistence:). Это редкий, но рабочий путь для корпоративных приложений.

19Подводные камни, на которых можно споткнуться

1. URLSession удерживает делегата strong

Если передаёте сессии делегата, он retainится сессией. Чтобы освободить — нужно вызвать session.invalidateAndCancel(). Иначе утечка памяти и сессии, которые висят после диалогов.

2. Background-сессия — singleton

Для одного identifier может существовать только одна сессия. Попытка создать вторую — крэш в проде. Делайте через DispatchQueue.once или static let.

3. Completion handler приходит на delegateQueue

По умолчанию это не main thread. Если обновляете UI — оборачивайте в DispatchQueue.main.async или используйте @MainActor-замыкания. С async/await это не проблема, но при completion-стиле — главный источник «UI не обновляется».

4. JSONDecoder не понимает snake_case

По умолчанию декодер ищет точное соответствие имён. Установите decoder.keyDecodingStrategy = .convertFromSnakeCase — и забудьте про CodingKeys на каждый ключ.

5. URLCache отдаёт устаревший кэш при отсутствии сети

Это фича: при .useProtocolCachePolicy и отсутствии сети URLSession отдаст последнюю валидную запись. Если для вас это плохо — переключайтесь на .reloadIgnoringCacheData.

6. Не все URLError возвращаются как URLError

Иногда вы получите NSError с доменом kCFErrorDomainCFNetwork. Это редкость, но проверяйте domain прежде чем кастить.

7. Загрузка в фоне без backgroundCompletionHandler

Apple передаёт в application(_:handleEventsForBackgroundURLSession:completionHandler:) handler, который мы обязаны вызвать после обработки. Если забыть — система будет думать, что приложение зависло, и в следующий раз даст меньше времени.

8. HTTPS-несоответствие в build схеме

App Transport Security в Debug может «прощать» localhost через NSAllowsArbitraryLoads, но в Release — нет. Не нарывайтесь на это перед самой публикацией: проверяйте Release-сборку заранее.

9. WebSocket-задача не разбирает фрагментированные сообщения сама

В большинстве случаев — да, разбирает. Но если сервер шлёт очень крупные фреймы, проверьте maximumMessageSize на задаче и поднимите при необходимости.

10. Xcode debugger может мешать в фоновой загрузке

Это известная история: при подключенном дебаггере фоновые задачи иногда ведут себя «не как в реальности» (см. пост про подводные камни). Проверяйте на отключённом устройстве — без LLDB.

20Чек-лист продакшен-готовности

Используйте при code review сетевого слоя:

21Три парадигмы: completion, Combine, async/await, делегаты

За годы существования URLSession у нас накопилось четыре способа получить данные. Все они живы, все используются в проде, и стоит понимать, в чём разница и где какой уместен.

Completion handler

Самый старый стиль. Его сильная сторона — минимальные требования к окружению: ни Swift Concurrency, ни Combine. Работает с iOS 7. Слабая сторона — callback hell, ручная отмена, отсутствие структуры на уровне типов. В современном коде используем там, где не можем поднять минимальную версию iOS до 15.

session.dataTask(with: request) { data, response, error in
    if let error = error { return done(.failure(error)) }
    guard let data = data, let response = response as? HTTPURLResponse
    else { return done(.failure(URLError(.badServerResponse))) }
    done(.success((data, response)))
}.resume()

Combine

URLSession отдаёт dataTaskPublisher(for:) начиная с iOS 13. Это удобно, когда у вас вся остальная архитектура построена на Combine: подписки, операторы, ретраи через .retry(_:). Combine отлично подходит для функциональных пайплайнов «трансформируй → отфильтруй → сложи». Но сам Combine — это другой большой мир, и сегодня Apple явно двигает Swift Concurrency как первый класс.

session.dataTaskPublisher(for: request)
    .tryMap { data, response in
        guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode)
        else { throw URLError(.badServerResponse) }
        return data
    }
    .decode(type: Items.self, decoder: decoder)
    .retry(2)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { _ in }, receiveValue: { items in ... })
    .store(in: &cancellables)

async/await

С iOS 15 — рекомендованный стиль. Свой пакет async-методов, кооперативная отмена через дерево Task, простая параллелизация через async let и TaskGroup. Главный плюс — линейность кода: ошибки бросаются как обычно, нет вложенных замыканий, легко читать. Главный минус — нужен iOS 15 как минимум.

Делегаты

Самый «низкоуровневый» способ. Дают полный контроль: прогресс на каждом байте, аутентификация, метрики, редиректы, paused state. Невозможно построить корректную фоновую загрузку без них. С async/await прекрасно сочетаются — создаём сессию с делегатом, а данные забираем через data(for:).

Когда что брать

СитуацияРекомендуемый стиль
Обычный JSON-запросdata(for:) async/await
Загрузка файла в фонеdelegate + background config
WebSocket-стрим сообщенийasync/await + цикл receive()
Сервер-сент эвентыbytes(for:) + .lines
Пайплайн с трансформациямиCombine (если стек уже на нём)
Прогресс загрузкиdelegate либо URLSessionTask.progress + KVO
Поддержка iOS 14completion handler + ручная обёртка

22Multipart-загрузка в деталях

Загрузка файлов с метаданными — самая частая «не очень очевидная» история. Multipart/form-data — это формат тела с разделителями. URLSession его не строит сам — мы готовим тело руками или через библиотеки. Покажу минимальный собранный пример, без зависимостей.

func multipartBody(boundary: String,
                   parameters: [String: String],
                   file: (name: String, filename: String, mime: String, data: Data)) -> Data {
    var body = Data()
    let lineBreak = "\r\n"

    for (key, value) in parameters {
        body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"\(key)\"\(lineBreak)\(lineBreak)".data(using: .utf8)!)
        body.append("\(value)\(lineBreak)".data(using: .utf8)!)
    }

    body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\(lineBreak)".data(using: .utf8)!)
    body.append("Content-Type: \(file.mime)\(lineBreak)\(lineBreak)".data(using: .utf8)!)
    body.append(file.data)
    body.append(lineBreak.data(using: .utf8)!)
    body.append("--\(boundary)--\(lineBreak)".data(using: .utf8)!)

    return body
}

Тонкости:

Совет

Если файл больше десятка мегабайт, очень хорошая практика — отдавать его серверу чанками (Tus.io, S3 multipart upload). У вас будет прогресс на каждом чанке, ретрай только по упавшим частям и возможность возобновления.

Multipart — один из тех случаев, где имеет смысл написать собственную крошечную обёртку и не тянуть гигантскую библиотеку. Это сто строк кода, которые не растут, не зависят от обновлений и тестируются за час.

23OAuth2, refresh-токены и единая точка обновления

Аутентификация — место, где сетевой слой ломается чаще всего. Самая болезненная история — refresh-токен: сервер отвечает 401, нам нужно бесшумно обновить токен и повторить исходный запрос. Если обработать наивно, два параллельных запроса спровоцируют две одновременные попытки рефреша, и один из ответов перезатрёт другой.

Правильное решение — сериализация рефреша. Делаем актора, который гарантирует, что в любой момент времени активна одна задача обновления, остальные дожидаются её результата.

actor TokenStore {
    private var token: String?
    private var refreshTask: Task<String, Error>?

    func currentToken() async throws -> String {
        if let token = token { return token }
        return try await refresh()
    }

    func refresh() async throws -> String {
        if let task = refreshTask { return try await task.value }
        let task = Task<String, Error> {
            defer { refreshTask = nil }
            let newToken = try await requestNewToken()
            token = newToken
            return newToken
        }
        refreshTask = task
        return try await task.value
    }
}

Дальше — middleware, который перехватывает 401, дёргает рефреш и повторяет запрос:

func authorized<T>(_ build: () -> URLRequest,
                   decode: (Data, URLResponse) throws -> T) async throws -> T {
    func go() async throws -> (Data, URLResponse) {
        var request = build()
        let token = try await tokens.currentToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return try await session.data(for: request)
    }

    let (data, response) = try await go()
    if let http = response as? HTTPURLResponse, http.statusCode == 401 {
        _ = try await tokens.refresh()
        let (data2, response2) = try await go()
        return try decode(data2, response2)
    }
    return try decode(data, response)
}

Этот же приём работает для логаута: если рефреш вернул 401 — токен полностью невалиден, чистим Keychain и кидаем пользователя на экран входа.

Важная мелочь

Никогда не складывайте refresh-токен в UserDefaults. Только Keychain с уровнем доступа .afterFirstUnlock или строже. UserDefaults — это plist в Documents, который читается простым доступом к файловой системе при наличии физического доступа к устройству.

24HTTP/2 и HTTP/3 внутри URLSession

URLSession сам решает, какой протокол использовать на основе ALPN при TLS-хендшейке. Если сервер поддерживает HTTP/2 — будет HTTP/2. С iOS 15+ Apple включает поддержку HTTP/3 (QUIC) — поверх UDP. Что это значит для нас?

Чтобы увидеть, по какому протоколу шёл запрос — посмотрите в URLSessionTaskTransactionMetrics.networkProtocolName. Возвращает "h2", "h3" или "http/1.1".

Что важно знать о соединениях

URLSession переиспользует соединения. После запроса соединение какое-то время живёт в пуле — следующий запрос на тот же хост сэкономит TLS-хендшейк. Чтобы пул работал эффективно, не пересоздавайте сессии. Это вторая причина (после конфигурации) держать сессию синглтоном.

Идемпотентность и HTTP/2

HTTP/2 поощряет «дёшево попробовать ещё раз». Если соединение упало в середине, можно безопасно повторить идемпотентный запрос. Поэтому GET и PUT на HTTP/2 могут ретрайтиться чаще, чем вы ожидаете — URLSession делает это сам в некоторых случаях. Это нормально, но важно понимать, что «один вызов» не всегда означает «один запрос на сервер».

25URLSession и SwiftUI: модификатор .task

В SwiftUI у каждого View есть модификатор .task { ... }, который запускает async-замыкание в момент появления и автоматически отменяет, когда View уходит. Это идеальное место для сетевых вызовов, и оно стыкуется с URLSession как родное.

struct FeedView: View {
    @StateObject var vm = FeedViewModel()

    var body: some View {
        List(vm.items) { ItemRow(item: $0) }
            .overlay { if vm.isLoading { ProgressView() } }
            .task {
                await vm.load()       // отменится при уходе с экрана
            }
            .refreshable {
                await vm.load()       // pull-to-refresh
            }
    }
}

@MainActor
final class FeedViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var isLoading = false

    func load() async {
        isLoading = true; defer { isLoading = false }
        do { items = try await api.items(page: 1) }
        catch is CancellationError { }
        catch { logger.error("feed load failed: \(error)") }
    }
}

Главный плюс — отмена идёт автоматически. Никаких cancellables, никаких currentTask?.cancel() в onDisappear. Если view ушёл — async-задача внутри получит CancellationError, и URLSession-запрос внутри неё отменится по цепочке через async-bridge.

Один частый источник багов: .task(id:). Этот вариант перезапускает задачу, когда меняется id. Если вы покажете FeedView для разных пользователей подряд, без id запрос не перезапустится. Делайте так:

.task(id: vm.userID) {
    await vm.load()
}

Стыковка SwiftUI и URLSession разобрана в отдельном посте — там есть рабочий пример с .task-модификатором, .refreshable и обработкой ошибок без алертов на каждый чих.

Микро-улучшение

Если у вас контейнер с пейджингом — пагинируйте через .task(id: lastVisibleID). View сам перезапустит async-задачу, когда последняя видимая запись изменится, и подгрузит следующую страницу без сложного state-машины.

26Отладка: Network Link Conditioner, Charles, Proxyman

Лучший способ найти сетевые баги — намеренно ухудшать сеть и смотреть, как ведёт себя приложение. Apple даёт нам Network Link Conditioner — системная утилита для эмуляции медленных, нестабильных соединений.

Для просмотра реального трафика — Charles или Proxyman. Они работают как HTTPS-прокси и показывают каждый запрос с заголовками, телом и таймингами. Чтобы расшифровать TLS на симуляторе, нужно поставить рут-сертификат прокси в систему. На реальном устройстве это сложнее (Apple усиливает требования), но возможно.

Для логирования внутри приложения — отдельный URLSessionTaskDelegate, который пишет каждое событие в OS Log с категорией "net". Тогда в Console.app можно фильтровать только сетевые события и видеть полную картину без брейкпойнтов.

import OSLog

extension Logger {
    static let net = Logger(subsystem: "com.iosiq.app", category: "net")
}

extension NetDelegate: URLSessionTaskDelegate {
    func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        Logger.net.debug("\(task.currentRequest?.url?.absoluteString ?? \"-\", privacy: .public) \(error?.localizedDescription ?? \"ok\")")
    }
}

И самое главное — не отлаживайте сетевые проблемы только в идеальном Wi-Fi. Прогоните приложение в метро, в лифте, в роуминге. Большинство багов сетевого слоя проявляются именно там — а воспроизводимо они проявятся под NLC.

27Безопасность: ATS, pinning, TLS, cookies

App Transport Security

ATS — это политика iOS, требующая HTTPS с TLS 1.2+ для всех сетевых запросов. По умолчанию все наши запросы должны идти по защищённому каналу. Послаблений три:

В реальной жизни старайтесь не отключать ATS вообще. Серверы, которые ещё не научились в TLS 1.2, постепенно должны исчезнуть — это вопрос политики, а не возможностей.

SSL pinning

Pinning — это сравнение отпечатка сертификата (или публичного ключа) с зашитым в приложение. Зачем: даже если злоумышленник установит на устройство свой корневой сертификат и сможет расшифровывать TLS, ваше приложение откажется работать с «чужим» сертификатом.

Есть два варианта:

Реализация на стороне делегата — десяток строк через сравнение SecCertificateCopyData. Не буду повторять код, который легко гуглится — важнее принципы. И второе: обязательно держите план Б на случай, если ключ нужно срочно сменить. Иначе одна ошибка на сервере — и все ваши пользователи остаются без приложения.

Cookies и HTTPCookieStorage

URLSession по умолчанию использует общее системное хранилище cookies. Это удобно для веб-вью и URLSession-запросов, разделяющих авторизацию. Но это анти-приватно, если у вас разные пользователи в одном приложении. В этом случае дайте сессии собственное хранилище:

config.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: "group.com.app")
config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain

TLS 1.3 и форвард-секретность

iOS 12.2+ умеет TLS 1.3, и URLSession согласовывает его автоматически, если сервер поддерживает. TLS 1.3 быстрее (один round-trip), безопаснее (нет старых шифров) и поддерживает 0-RTT для повторных соединений. Никаких настроек не нужно — оно «само».

Не делайте так

Не отключайте проверку сертификатов «для разработки» через cancelAuthenticationChallenge с заглушкой «всегда trust». Эту строчку забудут вытащить, и она доберётся до прода. Лучше держать DEBUG-секцию через флаги компилятора, чтобы код в Release-сборке физически не существовал.

28Прогресс загрузки: Progress, KVO, async-stream

У каждой задачи есть свойство progress типа Progress. Это объект из Foundation, который умеет агрегироваться, делиться долями и рассчитывать оставшееся время. Можно подписаться на него тремя способами.

KVO

Классика. Подписываемся на fractionCompleted, получаем колбэки.

let observation = task.progress.observe(\.fractionCompleted) { p, _ in
    Task { @MainActor in ui.setProgress(p.fractionCompleted) }
}

Через делегата

Для загрузки — didWriteData. Для выгрузки — didSendBodyData. Самый точный способ, потому что зовётся ровно тогда, когда происходит передача.

func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
                didWriteData bytesWritten: Int64,
                totalBytesWritten: Int64,
                totalBytesExpectedToWrite: Int64) {
    let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
    Task { @MainActor in ui.setProgress(fraction) }
}

AsyncStream

Для async-кода удобно превратить прогресс в AsyncStream и отдавать наружу. Тогда View сам подписывается через цикл for await.

func download(_ url: URL) -> AsyncThrowingStream<DownloadEvent, Error> {
    AsyncThrowingStream { continuation in
        let task = session.downloadTask(with: url)
        let obs = task.progress.observe(\.fractionCompleted) { p, _ in
            continuation.yield(.progress(p.fractionCompleted))
        }
        continuation.onTermination = { _ in obs.invalidate(); task.cancel() }
        task.resume()
    }
}

Это особенно удобно в SwiftUI: .task { for try await event in download(url) { state = event } }. Никаких подписок, никакого ручного управления — отмена идёт автоматически вместе с View.

29Ещё больше необычных кейсов

Кейс 11. Перехват домена для оффлайн-докеров

Если ваше приложение работает в корпоративной сети с DNS-разрезами (test-стенды по интранету), URLSession уважает /etc/hosts-подобное поведение через системный resolver. На уровне приложения можно подменить домен через URLProtocol, перехватив запрос и переписав хост на актуальный IP.

Кейс 12. Гонка запросов по A/B

Бывает, что у вас два кандидата на бэкенд (старый и новый), и нужно временно слать запросы в оба, сравнивая ответы. Без TaskGroup это превратится в лапшу, с ним — десять строк. Запускаем оба, ждём более быстрого через first(where:), остальные отменяем.

Кейс 13. Прогрев соединений

На старте приложения можно «прогреть» TLS-соединения к нашим хостам пустым HEAD-запросом. Тогда первый реальный запрос сэкономит ~150 мс на хендшейке. Простой трюк, заметный на медленной сети.

func warmUp(hosts: [URL]) async {
    await withTaskGroup(of: Void.self) { group in
        for url in hosts {
            group.addTask {
                var request = URLRequest(url: url)
                request.httpMethod = "HEAD"
                _ = try? await session.data(for: request)
            }
        }
    }
}

Кейс 14. Тайные «канарейка»-запросы

Перед миграцией трафика на новый эндпоинт можно отправлять 1% запросов параллельно на оба и сравнивать структуры ответов. Любые расхождения — в Sentry. Так вы поймаете несоответствия до выкладки.

Кейс 15. Прогресс в одном объекте

У Progress есть полезное свойство localizedDescription и localizedAdditionalDescription — система сама напишет «3 МБ из 12 МБ», «осталось примерно 4 секунды». Это бесплатная локализация прогресс-индикаторов.

Кейс 16. Двойная сессия для аналитики

Аналитические запросы не должны конкурировать с UX-критичными за пул соединений. Заведите отдельную сессию с маленьким httpMaximumConnectionsPerHost (например, 1) и низким приоритетом задач — это «фоновый» канал, который не мешает основному.

Кейс 17. Пакетные запросы

Если у вас есть 50 маленьких запросов, отправить их вразнобой через 50 задач — плохо. Лучше попросить бэкенд сделать batch-эндпоинт и отправить один большой POST. Если бэкенд не умеет — упакуйте 50 идентификаторов в один query-параметр ?ids=1,2,3,...,50. Один запрос вместо пятидесяти — это всегда быстрее.

Кейс 18. Прокси-сессия для тестов на CI

На CI часто нет интернета. Поднимите внутри тестов локальный HTTP-сервер (например, через NIO или SwiftNIOHTTP1), а сессию настройте на него через override host. Тесты остаются «реалистичными», но выполняются за миллисекунды.

Кейс 19. Загрузка по диапазону (Range)

Если сервер поддерживает заголовок Range, можно качать файл частями параллельно. Это редкий, но мощный приём — особенно для крупных медиафайлов на медленной сети. Параллельные части суммарно качаются быстрее, чем последовательная одна.

Кейс 20. Прогноз ETA по средней скорости

Записывайте среднюю скорость прошлых загрузок в UserDefaults. Тогда при новой загрузке вы сразу можете показать «осталось ~12 секунд» — без ожидания первых байт. Маленькая фича, но она ощутимо повышает доверие к интерфейсу.

30Ещё десяток практик, которые могут пригодиться в самом начале

11. Делайте «чистые» доменные ошибки

Из сетевого слоя наружу должны выходить ошибки вашего домена, а не голые URLError. Маппинг — на границе слоя. ViewModel не должен думать о .notConnectedToInternet, у него должно быть FeedError.offline.

12. Не доверяйте Content-Type

Сервер может прислать «application/json» с пустым телом или HTML внутри. Проверяйте тело перед декодом и логируйте странности — найдёте бэкенд-баги быстрее, чем команда бэка.

13. Логируйте Request-ID

Если бэкенд отдаёт заголовок X-Request-Id — обязательно складывайте его в лог. Это «золотой ключ» к отладке: вы говорите бэкендеру ID, и он за минуту находит вашу проблему в своих логах.

14. Делайте «сухие» интеграционные тесты

Не дёргайте прод в тестах. Кладите образцы JSON в bundle и подсовывайте через URLProtocol. Тесты остаются «настоящими» с точки зрения декодера, но не зависят от сети.

15. Включайте .shouldUseExtendedBackgroundIdleMode

На некоторых сценариях этот флаг позволяет URLSession чуть дольше держать соединение в фоне. Полезен для WebSocket-сессий.

16. Уважайте Retry-After

Если сервер вернул 429 с заголовком Retry-After, не делайте свой бэкофф. Сервер уже сказал, через сколько ему позвонить — повинуйтесь.

17. Стартуйте сетевой слой лениво

Сессия — синглтон, но создание сессии — синхронное, оно зовёт под капотом несколько системных вызовов. Делайте через lazy var, чтобы не платить старт-ап стоимость до первого запроса.

18. Готовьте JSON-фикстуры с tooling

Apple дала нам JSONEncoder.OutputFormatting.sortedKeys и .prettyPrinted. Запишите образцы из реальных ответов сервера в JSON-файлы в репозитории — это и документация, и фикстуры для тестов одновременно.

19. Не падайте на «лишних» полях

JSONDecoder игнорирует лишние поля — это нормально. Но если у вас Codable-структура с обязательным полем, которое сервер вдруг не прислал — это краш. Делайте поля Optional, либо проверяйте обязательность вручную.

20. Делайте обратную совместимость дельта-релизов

Если ваше приложение читает запросы из background-сессии после релиза, а вы поменяли формат идентификаторов задач — пользователи с pending-задачами от старой версии получат «битые» события. Сохраняйте обратную совместимость на одну-две версии.

31Производительность и тонкая настройка

Сеть — это не только корректность, но и время. Долгий запрос — это секунды, проведённые пользователем в ожидании, и батарея, потраченная зря. Несколько практик, которые ощутимо ускоряют сетевой слой.

Reuse соединений

Одна сессия на клиент — это уже половина выигрыша. URLSession держит keep-alive соединения в пуле, и повторные запросы на тот же хост экономят TLS-хендшейк. Если у вас 10 запросов к одному API подряд — будет одно физическое соединение для всех, а не десять отдельных.

HTTP/2 пуш и сервер-хинты

Бэкенд может прислать заголовок Link: </style.css>; rel=preload. URLSession не пушит превентивно, но рекомендации логировать в метрики стоит — это маркер «здесь можно подкачать заранее».

Сжатие тела

URLSession сам отправляет Accept-Encoding: gzip, deflate, br и сам распаковывает ответ. Сервер должен поддерживать сжатие. Для запросов с большим телом (POST/PUT) сжатие тоже работает, но требует ручного Content-Encoding: gzip и предварительно сжатого httpBody. На больших телах это сокращает трафик в три-пять раз.

Размеры пакетов и Nagle

URLSession сам управляет TCP-параметрами, но если вы делаете WebSocket с очень маленькими сообщениями, имеет смысл отключить Nagle через URLSessionStreamTask или Network.framework. По умолчанию Nagle копит мелкие пакеты — это полезно для трафика, но повышает латентность.

Кэш на уровне модели

URLCache — это байты. Но в продвинутом случае вам нужен кэш декодированных структур: запрос вернул JSON, мы распарсили в [Item], и было бы здорово в следующий раз отдать готовые Item, не парся заново. Решение — простая обёртка-декоратор: при cache-hit пропускаем JSONDecoder, при miss — декодируем и кладём в in-memory словарь по URL.

Pre-fetching и idle-warming

Когда пользователь смотрит на экран профиля, мы знаем: следующим шагом он, скорее всего, нажмёт «настройки». Можно тихонько прогреть запрос настроек с низким приоритетом. К моменту реального тапа данные уже будут готовы. Это пограничный случай — экономьте ресурсы пользователя и не качайте слишком агрессивно.

Бенчмарки

Никогда не верьте «ощущениям». Если кажется, что сеть тормозит — измерьте. URLSessionTaskMetrics вам в помощь. Записывайте p50/p95 для каждого эндпоинта в течение недели. Дальше — ищите аномалии: вот эндпоинт, у которого TTFB 1.2 с при p95 — значит, сервер тормозит. Вот этот — DNS 800 мс, значит, инфраструктура нестабильна. Без метрик это всё гадание на кофейной гуще.

Cancellation как фича

Отмена — это не только про корректность, это про производительность. Если вы при скролле списка не отменяете запросы на превью невидимых ячеек, ваш пул соединений забит «мусором». Отмена сразу освобождает соединение для нужных запросов.

32FAQ: частые вопросы про URLSession

Кликните по вопросу, чтобы развернуть ответ.

Можно ли менять конфигурацию у живой сессии?

Нет. Конфигурация копируется в момент создания сессии и становится read-only. Изменить можно только через создание новой сессии. Это намеренное архитектурное решение: оно гарантирует, что задачи внутри сессии живут в стабильном мире.

Что делать, если delegate не зовётся?

Проверьте три вещи: (1) сессия создана с делегатом — URLSession(configuration:delegate:delegateQueue:), а не URLSession(configuration:); (2) делегат не отпущен из памяти (URLSession держит strong-ссылку, но если объект weak в другом месте — никто не удержит); (3) для completion-стиля dataTask(with:completionHandler:) некоторые делегатные методы не зовутся, только финальный.

Почему задача в фоне не запустилась?

Самая частая причина — попытка передать httpBody вместо файла. В фоновой сессии можно только uploadTask(with:fromFile:) или downloadTask. Вторая причина — не реализован application(_:handleEventsForBackgroundURLSession:completionHandler:).

Что лучше: shared или своя сессия?

Своя. shared удобна для разовых запросов в служебных местах, но как только речь о центральном API-клиенте, лучше создавать свою сессию с явной конфигурацией. Это упрощает тестирование, логирование и контроль политик.

Нужен ли Alamofire?

Зависит от задач. Если требования к multipart, цепочкам интерсепторов, валидации сложные — Alamofire даёт всё из коробки. Если речь о REST с JSON — URLSession + 200 строк собственной обёртки закроют почти всё. В продакшене часто достаточно URLSession: меньше зависимостей, меньше сюрпризов при обновлениях.

Что с тестами?

URLProtocol-моки. Они полностью прозрачны для сетевого слоя, не требуют mock-фреймворков и работают одинаково в Unit/UI тестах. Подробный пример выше в разделе «Лучшие практики».

Когда нужен Network.framework вместо URLSession?

Когда нужен UDP, кастомный TCP-протокол, multipath, очень тонкое управление соединениями. Большинству приложений Network.framework не нужен — URLSession поверх него и так использует всё лучшее. См. также пост про UDP-клиент.

Стоит ли использовать background-сессию для маленьких файлов?

Нет. Накладные расходы на background больше, чем выгода. Background-сессия имеет смысл для крупных и длительных передач. Маленькие — обычная сессия с retry.

Можно ли запускать background-задачи при заблокированном экране?

Да, при ровно тех же условиях, что и в фоне приложения. Систему интересует — есть ли сеть, есть ли заряд, не приоритетнее ли что-то ещё. Защита экрана паролем — не препятствие.

Что делать с сессиями после логаута пользователя?

Полная гигиена: session.invalidateAndCancel(), очистка URLCache, очистка HTTPCookieStorage, очистка Keychain. После этого — создание новой сессии. Половинчатые меры приводят к утечке данных между аккаунтами.

33Bonus: дерево решений

Простая схема — какой инструмент брать в зависимости от характера данных. Четыре типичных сценария закрывают почти все продакшен-задачи.

Какие данные передаём?
JSON или маленький ответ
session.data(for:) async/await · одна общая сессия
Файл или крупная выгрузка
session.download(for:)
session.upload(for:fromFile:) + .background для долгих задач
Двусторонний обмен сообщениями
session.webSocketTask(with:) iOS 13+ · текстовые и бинарные кадры
Серверный поток (SSE / NDJSON)
session.bytes(for:) .lines для построчной обработки

34Полезные посты из канала iOS Dev

Все ссылки ниже — посты канала, в которых разобраны отдельные грани URLSession и сетевого стека. Сохраняйте, делитесь, читайте под кофе.

URLSession

URLSession — применение, особенности, свойства

Базовый разбор: типы сессий, что под капотом, как настраивается и что важно знать про фоновые задачи.

Открыть в Telegram →
Background

Распространённые ошибки фоновой загрузки

Подводные камни background-задач: дебаггер, реальное устройство, особенности API.

Открыть в Telegram →
Connectivity

waitsForConnectivity — ждать сеть, а не падать

Как настроить URLSession, чтобы он бесшумно дожидался появления подключения.

Открыть в Telegram →
async/await

API-клиент на async/await

Паттерн «эндпоинт + билдер + валидатор» для аккуратного сетевого слоя.

Открыть в Telegram →
Errors

Как понять код ошибки от сервера

HTTPURLResponse.localizedString — кратчайший путь к человеко­читаемой ошибке.

Открыть в Telegram →
Download

Фоновые загрузки через URLSessionDownloadTask

Как качать файлы, даже если приложение убито пользователем.

Открыть в Telegram →
WebSocket

WebSocket с async/await

Итерация сообщений через async-цикл, минимум кода — максимум выразительности.

Открыть в Telegram →
SwiftUI

Загрузка данных в SwiftUI через URLSession + async

Как уживаются View, .task-модификатор и сетевой клиент.

Открыть в Telegram →
URL

Современные практики работы с URL в Swift

URLComponents, queryItems, безопасные инициализаторы.

Открыть в Telegram →
Network.framework

UDP-сервер и клиент в Swift

Когда URLSession не хватает и нужно идти в Network.framework.

Открыть в Telegram →
Cache

Виды кэширования в iOS — большой разбор

Стратегии, TTL, версионирование, mermaid-диаграммы, плюсы и минусы.

Открыть в Telegram →
Concurrency

Swift Concurrency: где мы сейчас

Контекст для async-методов URLSession и общая картина по конкурентности.

Открыть в Telegram →

Полные разборы, задачи на чтение кода и закрытая база — на Boosty

В iOS IQ собрана углублённая база: примеры, разборы интервью, задачи, схемы. URLSession — лишь одна из тем. Если хочется усилить тему сети и идти глубже в архитектуру, многопоточность, производительность — заходите.

Boosty · iOS IQ Канал Telegram Все статьи