REST представляет собой соглашение для HTTP API: URL называет ресурс, HTTP-метод говорит, что с ним сделать, а код ответа сообщает клиенту, чем всё закончилось; его первая цифра задаёт класс.
GET: чтение; без тела, его можно безопасно повторять и кешировать.
POST: создание или действие; не идемпотентен, поэтому повторная отправка может создать два заказа.
PUT: полная замена ресурса; идемпотентен, повтор оставляет то же состояние.
PATCH меняет только переданные поля.
DELETE: удаление; идемпотентен.
Классы кодов: 2xx успех (200, 201 Created, 204 No Content с пустым телом), 3xx перенаправление (304 Not Modified после проверки кеша), 4xx ошибка в вашем запросе (400, 401 не авторизован, 403 нет прав, 404, 422, 429 слишком часто), 5xx сбой на сервере.
Идемпотентность и есть причина, по которой метод важен клиенту: от неё зависит, какие запросы можно повторять автоматически.
iOSMiddle
Сеть в iOS
URLSession, Codable, async-API, интерцепторы, обработка ошибок
Закрыто вопросов
0 / 25
- 0Повторить
- 4Часто спрашивают
Вопросы
Система резолвит хост через DNS, открывает TCP- или QUIC-соединение, договаривается о TLS, отправляет метод, заголовки и тело, а затем читает статус, заголовки и тело ответа; в Swift всё это делает один вызов try await session.data(for:).
Переиспользование соединений: сессия держит соединения открытыми, поэтому следующий запрос к тому же хосту пропускает DNS, TCP и TLS. HTTP/2 и HTTP/3 к тому же передают много запросов по одному соединению.
data(from: url) выполняет простой GET; data(for: request) принимает URLRequest с методом, заголовками и телом.
URLSession.shared подходит для простых вызовов; свою сессию создают, когда нужны таймауты, кеш, заголовки по умолчанию или делегат.
Две вещи API намеренно оставляет вам. 404 или 500 означает завершённый запрос, а не брошенную ошибку, поэтому приведите ответ к HTTPURLResponse и проверьте statusCode. А декодирование идёт отдельным шагом через JSONDecoder, у которого свои ошибки; держите их отдельно от ошибок транспорта.
Старый dataTask(with:completionHandler:) по-прежнему работает, но новый код пишут на async-методах: они участвуют в отмене задач и не плодят вложенные колбэки.
URL содержит только адрес (схему, хост, путь и query), а URLRequest оборачивает URL всем, что нужно для отправки: HTTP-методом, заголовками, телом, таймаутом и политикой кеширования.
По умолчанию у URLRequest метод GET, timeoutInterval 60 секунд и .useProtocolCachePolicy; data(from: url) отправляет именно такой запрос.
Это структура, поэтому её собирают как var, а изменённая копия не влияет на оригинал, и это удобно, когда интерцептор добавляет заголовки.
Сам URL собирайте через URLComponents и URLQueryItem, чтобы значения query были закодированы, и только потом оборачивайте его в запрос.
Data task возвращает тело ответа в памяти, download task пишет его во временный файл и отдаёт URL этого файла, а upload task отправляет тело из памяти или прямо из файла.
Data (data(for:)): JSON и другие небольшие ответы; всё тело лежит в оперативной памяти.
Download (download(for:)): большие файлы; память не растёт. Файл лежит во временной папке, которую система чистит, поэтому сразу переносите его туда, где он нужен. Отменённую загрузку можно продолжить по resume data.
Upload (upload(for:from:) или upload(for:fromFile:)): вариант с файлом читает его с диска потоком, а не грузит целиком в память.
В фоновой сессии работают только download- и upload-задачи, причём выгрузка там возможна только из файла.
В API с колбэками каждая задача создаётся приостановленной; пока не вызван resume(), ничего не отправляется.
App Transport Security представляет собой системную политику, из-за которой URLSession и WKWebView по умолчанию отказываются от небезопасных соединений: обычного HTTP, TLS версии ниже 1.2, а также сертификатов и шифров слабее минимума Apple.
Заблокированный запрос падает с NSURLErrorAppTransportSecurityRequiresSecureConnection (-1022) ещё до отправки.
Исключения прописывают в Info.plist в разделе NSAppTransportSecurity: для конкретного домена через NSExceptionDomains и NSExceptionAllowsInsecureHTTPLoads или для веб-вью через NSAllowsArbitraryLoadsInWebContent.
NSAllowsArbitraryLoads выключает ATS для всего; App Review попросит это обосновать, поэтому лучше узкое исключение.
NSAllowsLocalNetworking покрывает локальные адреса, например dev-сервер в вашей локальной сети.
ATS проверяет качество соединения, а не то, кто на другой стороне; для этого есть отдельный шаг: certificate pinning (NSPinnedDomains).
Поисковая строка вставлена в URL интерполяцией, поэтому её & начинает новый параметр, и сервер получает q=C++ ; значения query передают через URLComponents и URLQueryItem, которые кодируют каждое значение.
URL(string:) разбирает строку, но не кодирует символы, у которых в URL есть смысл. & начинает следующий параметр, # начинает фрагмент, так что q=C# уйдёт как C, а = отделяет имя от значения.
С iOS 17 он процентно кодирует недопустимые в URL символы, например пробел, поэтому строка больше не даёт nil; до iOS 17 давала, и ! падал. Это изменение прячет ошибку, а не исправляет её.
URLComponents.queryItems кодирует &, # и = внутри каждого значения, поэтому C++ & Swift доходит одним значением.
**Дыра с +**: + допустим в query, поэтому URLComponents его не трогает, а сервер, разбирающий query как форму, читает его как пробел. Замените его в percentEncodedQuery после присвоения queryItems.
addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) не решение: этот набор разрешает &, = и +.
То же с путём: appending(path:) экранирует переданное, а интерполяция не экранирует.
default хранит кеш, cookie и учётные данные на диске; ephemeral держит их только в памяти и теряет вместе с сессией; background передаёт загрузки системному процессу, чтобы они продолжались, пока приложение приостановлено или завершено.
default: обычный API-клиент. Его кеш и cookie переживают выход из аккаунта: очищайте URLCache и HTTPCookieStorage сами, иначе следующий пользователь может увидеть закешированные ответы предыдущего.
ephemeral: приватный просмотр, изоляция аккаунтов и тесты. Если выбросить сессию, стирается всё, поэтому это чистый способ сбросить состояние при выходе.
background(withIdentifier:): большие загрузки и выгрузки, которые должны завершиться без приложения; работает только через делегат, выгрузка только из файла, а когда запускать передачу, решает система.
URLSession.shared устроен как фиксированный синглтон: без делегата, без изменения конфигурации и без фоновых передач.
Частая ошибка: сессия копирует конфигурацию в момент создания, поэтому изменение config.timeoutIntervalForRequest после этого на существующую сессию не влияет.
Полезные настройки у любого типа: timeoutIntervalForRequest, httpAdditionalHeaders, waitsForConnectivity, allowsConstrainedNetworkAccess, urlCache, httpMaximumConnectionsPerHost.
Он создаёт новую URLSession на каждый запрос: каждый вызов заново платит за TCP- и TLS-handshake, потому что пул соединений принадлежит сессии, а каждая сессия держит свой делегат, пока её не инвалидируют, поэтому ничего не освобождается.
URLSession рассчитана на долгую жизнь: по одной на конфигурацию, на всё время работы приложения или фичи. Запросы дешёвые, а сессии дорогие.
Пул соединений принадлежит сессии. HTTP/2 мультиплексирует множество запросов в одно TLS-соединение, и именно переиспользование убирает лишние раунды handshake. Сессия на запрос выбрасывает это каждый раз, и Instruments показывает новый handshake на каждый вызов.
Утечка: URLSession(configuration:delegate:delegateQueue:) держит делегат сильной ссылкой до finishTasksAndInvalidate() или invalidateAndCancel(). Здесь никто их не вызывает, поэтому каждый вызов оставляет после себя сессию с её соединениями, и память растёт по мере прокрутки.
Исправление: одна сессия, созданная в init и сохранённая в свойстве, а для колбэков конкретной задачи с iOS 15 есть session.data(for:delegate:): задача держит такой делегат только до своего завершения.
URLSession.shared годится для простого GET, но не принимает ни делегат, ни конфигурацию.
Между приложением и URLSession ставят один клиент, который собирает запросы из типизированных эндпоинтов, отправляет их, проверяет статус и декодирует тело, и бросает один тип ошибки, чьи кейсы соответствуют тому, что UI делает дальше.
Endpoint: значение с путём, методом и телом, обобщённое по типу ответа, поэтому client.send(.users) возвращает [User] без приведений типов.
Клиент: единственное место, которое знает базовый URL, заголовки, общий JSONDecoder и проверку статуса. Сквозная логика (авторизация, ретраи, логирование) оборачивает его интерцепторами, а не повторяется в каждом вызове.
Протокол транспорта: клиент зависит от протокола с одним методом, которому соответствует URLSession, поэтому в тестах подставляется фейк.
Ошибки по реакции: offline и timedOut (предложить повтор, возможно поставить запись в очередь), unauthorized (обновить токен или разлогинить), server(status:body:) (показать сообщение сервера), decoding (баг контракта: залогировать, показать общую ошибку) и отмена, которая для пользователя вообще не ошибка.
Исходный URLError или DecodingError храните внутри кейса для логов; UI должен делать switch по вашему enum, а не по сырым кодам.
Современный URLSession с async/await и Codable закрывает потребности большинства приложений в паре сотен строк вашего кода; Alamofire окупается, когда нужны готовые интерцепторы, политики ретраев, валидация, multipart и логирование, а не их написание и тестирование своими силами.
**Что на URLSession пишется самому: сборка запросов, проверка статуса, auth-интерцептор с одним общим обновлением токена, ретраи с задержкой, multipart-тела, логирование. Ничего из этого не сложно, но у каждого пункта есть известная ошибка, которую надо обойти.
Что даёт Alamofire**: RequestInterceptor (adapt и retry) для токенов, RetryPolicy, validate(), MultipartFormData, ServerTrustManager для пиннинга, EventMonitor для логов и async-API вроде serializingDecodable(_:).
Цена: зависимость, которую надо обновлять между мажорными версиями, рост размера бинарника и ещё одна абстракция, которую команде нужно знать. Под капотом всё тот же URLSession, так что ограничения фоновых передач и ATS те же.
Не тяните сторонний сетевой пакет в SDK, который отдаёте другим командам: ваша зависимость станет их конфликтом версий.
.iso8601 строгая: ей нужна полная дата со временем и часовым поясом, например 2026-03-01T12:00:00Z, поэтому дата без времени, время без Z или смещения и пробел вместо T приводят к ошибке; старые версии ОС отвергали и дробные секунды.
Лечится стратегией .custom, которая пробует форматы, что бэкенд реально присылает: ISO8601DateFormatter с .withFractionalSeconds и без него, затем DateFormatter для даты без пояса или без времени. Дробные секунды важны, только если минимальная версия ОС в проекте всё ещё включает релизы, которые их отвергают.
Пробуйте самый частый формат первым и принимайте все. Многие бэкенды обрезают .000, поэтому стратегия, знающая один формат, падает ровно на тех записях, что попали в целую секунду.
Если формат вообще не ISO, DateFormatter требует locale = Locale(identifier: "en_US_POSIX") и явного timeZone. Без POSIX-локали устройство с 12-часовым временем или негригорианским календарём разберёт HH:mm:ss иначе, и все даты сломаются (только у этого пользователя), поэтому баг и переживает ревью.
Форматтеры дороги в создании, стройте их один раз. Держите один настроенный JSONDecoder на клиент; создание нового на каждый ответ оказывается типичной находкой профайлера на списках.
Соседняя ловушка: с keyDecodingStrategy = .convertFromSnakeCase ключ к моменту сопоставления с CodingKeys уже стал createdAt, поэтому запись case createdAt = "created_at" не совпадёт ни с чем и бросит keyNotFound.
Декодирование массива работает по принципу «всё или ничего»: JSONDecoder бросает исключение на первом элементе, который не смог собрать, и вы теряете остальные сорок девять. Значит, неизвестные значения должны декодироваться, а ошибка должна оставаться внутри одного элемента.
У enum со строковым raw value синтезированный init(from:) бросает dataCorrupted на любом незнакомом значении. Напишите инициализатор сами и подставляйте кейс unknown: одно это закрывает большинство аварий «сервер выкатил новый тип».
Всё остальное декодируйте через обёртку, которая ловит ошибку поэлементно и сохраняет остальное. Ловушка внутри обёртки: неудачный decode не сдвигает currentIndex у UnkeyedDecodingContainer, поэтому битый элемент нужно «съесть» пустым Decodable, иначе цикл крутится вечно.
Для полей, которые сервер может перестать присылать, опциональность плюс значение по умолчанию лучше обязательного поля: decodeIfPresent и ?? дешевле аварии.
Логируйте то, что выбросили. codingPath у DecodingError называет индекс и ключ; без него «не удалось прочитать данные» бесполезно, а счётчик отброшенных строк как раз и показывает, что контракт сломался.
Будьте снисходительны только там, где это безопасно. Пропустить карточку ленты нормально, а молча пропустить транзакцию или флаг доступа хуже видимой ошибки, поэтому такие поля оставляйте обязательными и давайте запросу упасть громко.
Правило: терпимость к неизвестным значениям допустима, а к неизвестному смыслу недопустима.
Отменяйте Task, который ждёт запрос: он отменяет нижележащий URLSessionTask и бросает CancellationError или URLError(.cancelled); ожидание ограничивайте через timeoutIntervalForRequest и timeoutIntervalForResource, а отмену не показывайте пользователю как ошибку.
Отмена задачи: task.cancel() пробрасывается в нижележащий URLSessionTask, и передача останавливается. Дочерние задачи отменяются вместе с родителем, а .task во SwiftUI отменяется при исчезновении view.
Таймаут запроса: URLRequest.timeoutInterval, либо на конфигурации сессии timeoutIntervalForRequest (допустимая пауза между байтами) и timeoutIntervalForResource (потолок на всю передачу).
Ручной дедлайн: гонка работы против спящей задачи внутри withThrowingTaskGroup, когда у вызываемого API своего таймаута нет.
В catch отделяйте CancellationError от настоящих сбоев до показа алерта. Debounce плюс отмена предыдущего дают стандартный typeahead: каждое нажатие отменяет запрос в полёте, так что в сеть уходит только последний запрос.
guard … else { return } выходит, не возобновив continuation, поэтому когда декодирование падает (HTML-страница 502, изменившийся контракт) или data равно nil, ожидающая задача остаётся приостановленной навсегда и спиннер не останавливается.
Контракт: continuation нужно возобновить ровно один раз на каждом пути. Если ни разу, задача утекает, и всё, что её ждёт (.task, TaskGroup), зависает; если дважды, checked-вариант падает.
Почему баг прячется: try? превращает DecodingError в nil, а guard сводит его вместе со случаем без данных в молчаливый return. Успешный путь работает, поэтому баг проявляется только тогда, когда сервер ведёт себя неправильно.
Исправление: на пути с nil вместо данных возобновите continuation с ошибкой, а декодирование оберните в continuation.resume(with: Result { … }): брошенная ошибка станет failure, и возобновление окажется в одном месте.
Как поймать: withCheckedThrowingContinuation пишет в консоль «SWIFT TASK CONTINUATION MISUSE: … leaked its continuation», когда замыкание освобождается без возобновления; следите за консолью и держите юнит-тест, который отдаёт в колбэк HTML-тело и nil вместо данных и ожидает брошенную ошибку. Не переходите на withUnsafeThrowingContinuation, чтобы заглушить предупреждение.
Если у API уже есть async-вариант, например URLSession.data(for:), вызывайте его, а не мостите колбэк вручную.
Обрыв после начала запроса завершает его ошибкой URLError.networkConnectionLost: идемпотентный запрос повторяют с задержкой, а waitsForConnectivity включают, чтобы запросы, которые ещё не смогли стартовать, ждали сеть; проверять доступность сети перед вызовом не нужно.
waitsForConnectivity покрывает только время до появления соединения. Когда байты уже в пути, оборванное соединение сразу завершает задачу ошибкой networkConnectionLost; GET повторяют, а POST требует ключа идемпотентности (см. вопрос про повторы).
В пару к нему поставьте timeoutIntervalForResource как потолок для всей передачи, а urlSession(_:taskIsWaitingForConnectivity:) используйте как сигнал показать офлайн-плашку вместо алерта с ошибкой.
Не закрывайте вызов проверкой доступности сети. Reachability описывает прошлое, а не судьбу этого запроса, и любой captive portal отрапортует прекрасный маршрут.
NWPathMonitor из Network framework служит актуальным способом следить за связью, и нужен он для решений в интерфейсе: плашка офлайна, заблокированная кнопка отправки, возобновление очереди, а не для проверки перед каждым запросом.
Отделяйте ошибки со смыслом «попробуй позже», notConnectedToInternet и networkConnectionLost, от настоящих сбоев: только первая группа попадает в очередь повторов.
Записи пользователя складывайте в локальную очередь и отправляйте, когда связь вернётся, показывая состояние ожидания, а не выбрасывая работу.
Повторять стоит только те ошибки, которые следующая попытка в принципе может исправить. Разводите попытки экспоненциальной задержкой со случайным разбросом, чтобы поднявшийся сервер не получил все устройства разом.
Повторяемые: транспортные сбои вроде таймаута и обрыва соединения, а также 5xx и 429 от сервера. Если в ответе есть Retry-After, слушайтесь его, а не своего расписания.
Неповторяемые: семейство 4xx, описывающее ваш запрос: 400, 403, 404, 422. Ответ 401 означает не повтор, а обновление токена и ровно один повторный вызов.
Задержка: база, умноженная на двойку в степени номера попытки, с потолком в несколько секунд и случайной добавкой. Без разброса все клиенты, упавшие одновременно, одновременно и вернутся, и авария повторится.
Идемпотентность: GET, PUT и DELETE безопасно повторять, POST нет. Для платежей и создания заказа отправляйте ключ идемпотентности, по которому сервер отбросит дубль, и тогда повтор безопасен.
Ограничивайте: две-три попытки, общий дедлайн и полный отказ от повторов после отмены задачи. Проверяйте отмену на паузе, чтобы закрытый экран перестал долбить сеть.
Не складывайте повторы слоями: три попытки в клиенте, три в репозитории и три в UI дают двадцать семь запросов на одно нажатие.
URLCache работает как HTTP-кеш, встроенный в URLSession: Cache-Control: max-age позволяет ответить вообще без запроса, а ETag даёт перепроверить ответ, и тогда сервер отвечает 304 без тела.
URLCache работает на обычной сессии: ответ с Cache-Control или Expires отдаётся из памяти или с диска вообще без обращения к сети. Размер задайте сами через URLCache(memoryCapacity:diskCapacity:directory:) в конфигурации, потому что дисковый лимит по умолчанию невелик.
Вторая половина: валидация. Сохраните ETag (или Last-Modified) из прошлого ответа и отправьте его как If-None-Match (или If-Modified-Since); сервер ответит 304 без тела, и вы заплатите за один короткий запрос вместо всей выдачи.
URLRequest.cachePolicy задаёт поведение для конкретного запроса: значение по умолчанию слушается заголовков, .reloadIgnoringLocalCacheData подходит для pull-to-refresh и для запроса, где вы валидируете вручную, а .returnCacheDataElseLoad даёт мгновенную первую отрисовку.
Помните про исключения: ephemeral-сессия кеширует только в памяти, а ответы на POST не кешируются.
Закешированные ответы принадлежат тому, кто был залогинен: при выходе вызовите removeAllCachedResponses() у кеша сессии, иначе следующий аккаунт увидит ленту предыдущего.
HTTP-кеш экономит трафик, но не даёт офлайна. То, что пользователь должен увидеть и завтра, кладите в собственное хранилище.
В URLSession нет сборщика multipart, поэтому тело вы собираете сами: уникальная строка-boundary, по одной части на поле со своим заголовком Content-Disposition и закрывающий boundary с двумя лишними дефисами на конце.
upload(for:from:): async-метод для тела, которое уже лежит в памяти; возвращает (Data, URLResponse) так же, как data(for:).
upload(for:fromFile:) стримит файл с диска, но для multipart этот файл должен быть полным multipart-телом, поэтому сначала запишите части во временный файл.
Content-Type запроса ставьте в multipart/form-data; boundary=<ваш boundary>, а внутри тела используйте переводы строк CRLF, потому что голый перевод строки многие серверы отвергают.
Для прогресса передайте URLSessionTaskDelegate в async-вызов upload и реализуйте didSendBodyData.
Если аплоад должен пережить сворачивание приложения, стройте сессию из URLSessionConfiguration.background(withIdentifier:). Фоновые сессии принимают только файловые аплоады, никогда Data в памяти, а завершение приходит через delegate после перезапуска.
Собранное руками тело обычно ломается в двух местах: пропущенный CRLF перед закрывающим boundary и целый большой файл, удерживаемый в Data на устройстве с дефицитом памяти.
Хватает двух приёмов: подставлять фейк через протокол, когда вы тестируете собственный клиент, и регистрировать наследника URLProtocol, когда нужно прогнать настоящий путь загрузки.
Внедрение через протокол: опишите крошечный протокол HTTPTransport с тем единственным методом, который вы используете, подпишите под него URLSession в продакшене и передайте заглушку в тестах. Быстро настраивается и подходит для тестов репозитория или вью-модели.
Заглушка на URLProtocol: наследуйтесь, возвращайте из startLoading() заготовленные Data и HTTPURLResponse, а сам класс подставляйте через configuration.protocolClasses. Только так проверяется код, который реально собирает запрос: заголовки, политику кеша, разбор кодов ответа.
Собирайте сессию из свежей URLSessionConfiguration.ephemeral в каждом тесте, а не регистрируйте заглушку глобально, иначе заглушки протекают между тестами и падения начинают зависеть от порядка.
Проверяйте то, что действительно ломается: 500, 204 с пустым телом, битый JSON, URLError(.timedOut) и отмену на середине.
Держите контрактные тесты декодирования на записанных фикстурах, чтобы переименование поля на сервере роняло сборку, а тесты с живой сетью не пускайте в юнит-набор вовсе.
WebSocket держит одно двустороннее соединение открытым, чтобы сервер мог присылать сообщения сразу, как они появились (котировки, чат, мультиплеер); URLSessionWebSocketTask даёт отправку и приём, а цикл чтения, поддержание связи и переподключение вы пишете сами.
webSocketTask(with:) и затем resume() открывают сокет, send(.string) или send(.data) пишут, а receive() отдаёт ровно одно сообщение, поэтому за следующим нужно обращаться снова.
Заверните этот цикл в AsyncThrowingStream, чтобы экран читал сообщения через for await, а отмена читающей задачи закрывала сокет вместе с собой.
Поддерживайте связь через sendPing(pongReceiveHandler:) по таймеру: промежуточные узлы спокойно рвут сокет, простоявший меньше минуты.
Переподключайтесь с задержкой и случайным разбросом и после каждого переподключения подписывайтесь заново. Клиент, который тихо восстановил соединение, но не переподписался, даёт классическую продовую ошибку, поэтому же запрашивайте пропущенное по порядковому номеру.
Учитывайте уход в фон: iOS усыпляет сокет, поэтому подключайтесь заново при возвращении на передний план, а не надейтесь, что соединение выжило. Закрывайте вежливо через cancel(with: .goingAway, reason: nil).
Выбирайте транспорт осознанно. Для односторонних обновлений server-sent events или опрос дешевле в эксплуатации, а до пользователя с выключенным приложением дойдёт только пуш.
Приложение отправляет с каждым запросом короткоживущий access-токен (часто JWT) и хранит долгоживущий refresh-токен, чтобы получать новый; когда несколько запросов одновременно получают 401, они должны разделить одно обновление через актор, а затем каждый повторяется один раз.
Два токена
Access-токен: уходит в Authorization: Bearer … и живёт минуты. JWT содержит закодированный в base64url JSON плюс подпись: приложение может прочитать exp, чтобы обновиться заранее, но проверяет токен только сервер.
Refresh-токен: живёт дни или недели и отправляется только на эндпоинт обновления. Оба хранят в Keychain, а не в UserDefaults; если обновляться нужно из фоновой работы, выбирайте класс доступности вроде kSecAttrAccessibleAfterFirstUnlock.
Вход через сторонний сервис или SSO строится на OAuth 2.0 authorization code с PKCE через ASWebAuthenticationSession, а не на веб-вью, которое видит пароль.
Единственное обновление (single-flight)
Десять запросов падают одновременно; без координации они запускают десять обновлений. При ротации refresh-токенов сервер аннулирует старый refresh-токен при первом использовании, поэтому остальные девять получают ошибку, и пользователя выкидывает из аккаунта.
actor хранит выполняющуюся Task, а следующие вызывающие делают await той же задачи. Работает это благодаря реентерабельности актора: пока первый вызывающий приостановлен, остальные заходят в актор и находят задачу.
Передавайте токен, с которым ушёл упавший запрос. Если сохранённый токен уже другой, кто-то успел его обновить, так что просто повторите запрос.
Крайние случаи
Запрос обновления идёт в обход интерцептора, иначе 401 на обновлении запустит новое обновление, и так бесконечно.
Повторяйте ровно один раз. Если обновление упало с invalid_grant, очистите Keychain и отправьте пользователя на логин.
Не повторяйте вслепую неидемпотентный POST; дайте ему idempotency key.
data(for:) перед возвратом складывает всё тело в память, а bytes(for:) отдаёт ответ как AsyncSequence, который вы разбираете по мере поступления, поэтому пик памяти равен одной записи, а не всей выгрузке.
let (bytes, response) = try await session.bytes(for: request) возвращает URLSession.AsyncBytes. Проверьте статус HTTPURLResponse до начала чтения: тело ошибки окажется не тем, что ждёт ваш декодер строк.
bytes.lines даёт AsyncSequence из String, он подходит для NDJSON, где каждая запись занимает одну строку. Пустые строки он пропускает, поэтому не найдёт пустую строку, которой заканчивается событие server-sent events; для SSE читайте сырые bytes и делите по пустой строке сами.
Backpressure работает по-настоящему: последовательность читает лишь немного вперёд, поэтому медленный потребитель тормозит передачу, а не раздувает память. Выход из цикла for try await или отмена задачи отменяют и сам URLSessionTask.
Ловушка в том, что вы делаете внутри цикла: складывать каждую декодированную строку в один массив значит получить тот же data(for:), только длиннее, а запись каждой строки в хранилище на главном акторе превращает проблему памяти в зависший интерфейс. Копите пачками по несколько сотен и пишите вне главного актора.
Если приходит файл, а не записи, не стримьте вручную: задача загрузки пишет сразу на диск и отдаёт URL. В фоновой сессии это downloadTask(with:) с URLSessionDownloadDelegate; async-метод download(for:) там недопустим.
Правило: память должна расти вместе с одной записью, а не вместе с ответом.
Пиннинг заставляет приложение принимать сервер, только если в цепочке его сертификата есть ключ, зашитый в приложение, поэтому man-in-the-middle с сертификатом от любого другого доверенного CA (корпоративного прокси, скомпрометированного CA, корневого сертификата, который пользователя уговорили установить) будет отвергнут.
Реализация
Декларативно: NSPinnedDomains в Info.plist (iOS 14+) с NSPinnedLeafIdentities или NSPinnedCAIdentities, каждый элемент содержит хеш SPKI-SHA256-BASE64. Кода нет, и правило действует для всех запросов URLSession к этому домену.
В коде: обработайте server trust challenge в URLSessionDelegate: сначала обычный SecTrustEvaluateWithError, затем сравните ключи в цепочке со своими пинами. Пиннинг дополняет системную проверку и никогда её не заменяет.
Пиньте публичный ключ (SPKI), а не файл сертификата: сертификат можно перевыпустить с тем же ключом, и пин на ключ переживёт продление; договоритесь об этом с бэкендом. Считайте пин так же, как храните его (в коде ниже используется SHA-256 от сырых байтов ключа); NSPinnedDomains ждёт хеш полной структуры SPKI.
Что ломается
Ротация убивает приложение: в день, когда сервер переходит на незапиненный ключ, все установленные версии перестают работать, и пользователям приходится обновляться. Всегда кладите запасной пин на следующий ключ, а если бэкенд не может обещать тот же ключ, пиньте промежуточный CA.
Отладочные прокси вроде Charles или Proxyman перестают работать; отключайте пиннинг только в debug-сборках.
Это не защита от пользователя, который контролирует своё устройство (на джейлбрейке проверку можно перехватить); пиннинг защищает пользователя от других участников сети.
Он оправдан для банков, платежей и медицины. Для типичного приложения хватает ATS и системного хранилища доверенных сертификатов, а эксплуатационный риск перевешивает выгоду.
Фоновая сессия передаёт загрузку системному демону, который продолжает работу, пока приложение выгружено или убито, и сообщает о результате, перезапустив приложение.
Создавайте её через URLSessionConfiguration.background(withIdentifier:), по одному идентификатору на сессию, и держите ровно один экземпляр на идентификатор: второй с тем же id ведёт к неопределённому поведению.
Фоновая сессия работает только через делегат. Здесь нет async-метода data(for:) и нет completion-блоков, поэтому вы реализуете URLSessionDownloadDelegate и синхронно переносите файл из временного расположения прямо в didFinishDownloadingTo: после выхода из метода система его удалит.
Отправлять данные (upload) можно только из файла, но не из Data в памяти.
Про обработку перезапуска забывают чаще всего: система вызывает application(_:handleEventsForBackgroundURLSession:completionHandler:), вы сохраняете этот обработчик, пересоздаёте сессию с тем же идентификатором и вызываете обработчик на главной очереди из urlSessionDidFinishEvents.
Принудительное закрытие пользователем из переключателя приложений отменяет загрузку, и система приложение для неё не перезапустит; покрывается только завершение самой системой.
Момент реальной передачи выбирает система, ориентируясь на питание и сеть; isDiscretionary и earliestBeginDate остаются пожеланиями, поэтому не обещайте пользователю время завершения.
Прогресс приходит в didWriteData, а отменённую загрузку можно продолжить через cancel(byProducingResumeData:) и downloadTask(withResumeData:).
Две ловушки стоит назвать вслух: URLSession.shared на такое не способна, а после перезапуска никакого экрана уже нет, поэтому всё нужное делегату состояние должно лежать на диске.
Опишите каждую сквозную задачу как middleware, который получает запрос и замыкание next, обязанное быть вызванным, а затем сверните массив в одну функцию отправки: позиция в массиве задаёт порядок вложенности, а порядок вложенности задаёт поведение.
Форма сводится к одному методу: intercept(_ request: URLRequest, next: Send) async throws -> (Data, HTTPURLResponse), где Send имеет ту же сигнатуру. Middleware может изменить запрос, вызвать next ноль, один или много раз и изменить или подменить результат.
Свёртка идёт с конца массива поверх транспортного замыкания, поэтому нулевой элемент оказывается самым внешним. Один reduce заменяет ту цепочку классов-обёрток, которую обычно пишут руками.
Порядок и есть дизайн. Авторизация снаружи retry ставит заголовок один раз, поэтому каждый повтор уходит с тем же, возможно протухшим, токеном; retry снаружи авторизации проходит через неё на каждой попытке, и каждая попытка несёт актуальный заголовок. Логирование снаружи даёт строку на вызов, внутри даёт строку на попытку, а метрики должны стоять вплотную к транспорту, чтобы мерить реальную передачу.
Middleware, который начинает другой запрос (обновление токена, загрузка конфига), обязан обращаться к транспорту напрямую, а не к next, иначе он заново входит во всю цепочку и может зациклиться.
Middleware разделяются между параллельными задачами, поэтому это Sendable-значения; всё изменяемое (токен, бюджет ретраев) живёт в акторе за ними.
URLProtocol для продакшена здесь не подходит: он лежит ниже сессии и плохо дружит с await по вашим акторам; оставьте его тестам.
Правило: весь пайплайн запроса объявлен одним массивом, и чтение его сверху вниз объясняет, что происходит с запросом.